Как работает volatile и почему он не подходит для атомарных операций умножения
Что проверяют
Понимаете ли вы разницу между видимостью и атомарностью в модели памяти Java, на которой работает и Kotlin под Android. Следом почти всегда просят показать, как правильно умножить общий счётчик из нескольких потоков или корутин.
Как отвечать
Начните с того, что даёт volatile (в Kotlin это аннотация @Volatile):
- видимость: запись в volatile-поле связана отношением happens-before с каждым последующим чтением этого поля, поэтому другой поток увидит свежее значение, а не закешированное в регистре;
- запрет переупорядочивания: операции до записи в volatile не «уезжают» за неё, на этом держится double-checked locking в синглтоне;
- атомарность отдельного чтения и записи: включая
longиdouble, которые без volatile формально могут записаться половинками.
Затем объясните, чего volatile не даёт. x *= 2 это три шага: прочитать, умножить, записать. Два потока читают одно и то же значение, оба умножают, и одно обновление теряется. Volatile гарантирует лишь, что каждый шаг увидит свежие данные, но не склеивает шаги в один.
Варианты правильного решения:
- *`AtomicInteger.updateAndGet { it 2 }
** или цикл сcompareAndSet`: неблокирующая операция на CAS; synchronizedилиReentrantLockдля обычного потокового кода;Mutex.withLockв корутинах, потому что внутриsynchronizedточку приостановки вызвать нельзя, компилятор это запрещает;MutableStateFlow.update {}, если значение и так живёт в StateFlow во ViewModel.
Где спотыкаются
Считают, что @Volatile var counter делает counter++ потокобезопасным. Это классическая ошибка, и интервьюер часто подводит к ней специально.
Объясняют volatile как «чтение всегда из основной памяти в обход кеша». Процессорные кеши согласованы аппаратно, а реальная гарантия формулируется через барьеры памяти и happens-before. Лучше говорить в этих терминах.
Берут AtomicInteger, но пишут set(get() * 2). Каждый вызов атомарен, а связка из двух нет, и гонка возвращается. Нужен именно updateAndGet или compareAndSet в цикле.