Ключевое слово volatile в Java
Используйте volatile в Java для гарантии видимости и упорядоченности операций чтения и записи полей между потоками — без захвата блокировки.
synchronized решает сразу две задачи: взаимное исключение и видимость памяти. Иногда нужна только вторая. Простой флаг типа boolean, который один поток устанавливает, а другой опрашивает, не требует монопольного доступа — есть только один пишущий и один или несколько читающих, и сама работа не является составной операцией. Применять блокировку здесь избыточно; не применять её — значит получить ошибку. volatile — это ключевое слово, которое даёт видимость без исключения.
Это узкоспециализированный инструмент. Применяйте его по назначению — и он станет самым быстрым потокобезопасным примитивом в Java. Применяйте неправильно — например, для составных обновлений вроде ++ — и вы получите те же ошибки, которые должен был исправить synchronized.
Проблема, для решения которой существует volatile
Без какой-либо синхронизации:
class Worker implements Runnable {
boolean stop = false; // plain field
public void run() {
while (!stop) { // hot loop
doWork();
}
}
}
Worker w = new Worker();
new Thread(w).start();
Thread.sleep(1000);
w.stop = true; // ask it to stopЭта программа может никогда не завершиться. Причина: JVM не обязана сбрасывать stop из кэша CPU одного потока в основную память, и ничто не обязывает рабочий поток инвалидировать кэшированную копию, когда главный поток выполняет запись. JIT также вправе полностью вынести !stop за пределы цикла — компилятор видит, что тело цикла не изменяет stop, и кэширует значение в регистре. Навсегда.
Это и есть ошибка видимости. Исправление:
volatile boolean stop = false;Теперь каждая запись в stop сбрасывается в основную память, и каждое чтение берётся из основной памяти. JIT не может вынести чтение за пределы цикла; цикл увидит новое значение в течение микросекунд после записи пишущим потоком.
Что гарантирует volatile
Четыре вещи:
- Видимость. Запись в поле
volatileгарантированно будет видна последующим операциям чтения в любом другом потоке. - Атомарность чтения и записи — но только для самого поля. Чтение/запись
volatile longиvolatile doubleатомарны; безvolatile64-битная операция чтения/записи может быть разорвана на 32-битной JVM. - Упорядоченность (happens-before). Всё, что произошло до записи в
volatileв пишущем потоке, видимо для всего, что происходит после соответствующего чтенияvolatileв читающем потоке. Это аспект, который не виден в синтаксисе, но является наиболее мощной гарантией. - Запрет переупорядочения через обращение. Компилятор и CPU не могут переставить обычные операции чтения/записи через обращение к
volatileв любом направлении.
Третий пункт — happens-before через пару операций записи/чтения volatile — иногда называют дешайшим примитивом публикации. Если вы записываете поле, а затем делаете volatile boolean ready = true, другой поток, который видит ready == true, гарантированно увидит и предшествующую запись. Именно так реализуется потокобезопасная инициализация без блокировки.
Что volatile не гарантирует
Самая распространённая ошибка:
volatile int counter = 0;
void increment() { counter++; } // STILL BROKENvolatile делает чтение атомарным и запись атомарной — но counter++ — это операция чтение-изменение-запись, то есть три операции. Два потока оба читают 42, оба вычисляют 43, оба записывают 43. Одно приращение всё равно теряется.
Для составных обновлений volatile недостаточно. Используйте synchronized, Lock или — почти всегда это правильный ответ — AtomicInteger.
Что ещё не делает volatile:
- Он не обеспечивает взаимного исключения. Два потока могут одновременно писать в поле
volatile; результат — «один побеждает, другой теряется», что является правильным ответом для флагоподобного состояния и неправильным для «объединить эти два значения». - Он не синхронизирует несколько полей атомарно вместе. Если ваш инвариант гласит «если A истинно, то B должно быть 42», запись каждого в отдельное поле
volatileможет оставить другой поток видящим новое A и старое B.
Шаблон публикации
Наиболее полезное применение volatile вне случая с флагом остановки:
class LazyResource {
private Resource cached; // not volatile
private volatile boolean ready = false; // the publication flag
public Resource get() {
if (!ready) {
synchronized (this) {
if (!ready) {
cached = buildResource(); // expensive init
ready = true; // publishes cached
}
}
}
return cached;
}
}Запись volatile ready = true публикует предшествующую запись в cached. Любой поток, который впоследствии прочитает ready == true, гарантированно увидит полностью инициализированный cached. Блокировка конкурируется только при первом вызове; последующие вызовы просто читают ready и пропускают синхронизацию полностью. Это классический идиом двойной проверки блокировки, исправленный с помощью volatile.
Без volatile на ready оптимизация сломана — второй поток может увидеть ready == true и затем прочитать cached как null. С volatile это невозможно.
(Современная альтернатива — просто сделать private final Resource cached = buildResource();, если охотная инициализация подходит, или Supplier<Resource> lazy = Suppliers.memoize(...) из библиотеки на ваш выбор. Рукописная двойная проверка блокировки редка в современном коде; шаблон стоит знать, потому что вы будете его читать.)
Когда volatile — именно то, что нужно
Небольшой набор сценариев, где volatile является лучшим ответом:
- Флаг состояния с одним пишущим, который читается многими потоками. Сигналы остановки, флаги «готов», номера версий конфигурации.
- Ссылка на неизменяемое значение, которое атомарно заменяется. Кэш, который писатель перестраивает и публикует; читатели видят старый или новый целый объект, но никогда — недостроенный.
- Публикация результата одноразовой инициализации через шаблон двойной проверки блокировки.
- Временная метка или порядковый номер, которые один поток устанавливает, а другие читают — где пропустить самое последнее обновление допустимо, но значение всегда должно быть самосогласованным.
Вне этих случаев обычно нужен инструмент более высокого уровня. Не мудрствуйте с volatile — его семантика слишком тонка для управления общим состоянием.
volatile long и volatile double на 32-битных JVM
Историческая особенность. На 32-битной JVM операция чтения/записи не-volatile long или double может быть разбита на два 32-битных обращения. Два потока могут получить разорванное значение — старшие 32 бита одной записи и младшие 32 бита другой. volatile long и volatile double гарантированно атомарны независимо от размера слова.
Современные JVM почти всегда 64-битные, и 64-битные JVM делают все примитивы атомарными в любом случае, но правило по-прежнему находится в спецификации. Если у вас есть long/double, разделяемый между потоками, пометьте его volatile (или используйте AtomicLong/AtomicDouble).
Разобранный пример: ошибка с флагом остановки
Программа ниже запускает рабочий поток сначала с обычным boolean (который обычно никогда не останавливается), затем с volatile boolean (который останавливается оперативно). Сторожевой таймер обрывает сломанный случай через 2 секунды, чтобы демонстрация завершилась.
Что следует вынести из запуска:
- Рабочий поток
PLAINзачастую не останавливался в течение окна сторожевого таймера. Главный поток записалstop = true; рабочий поток со своей кэшированной в регистре копиейstopтак и не заметил этого. Добавьтеvolatile— и ошибка исчезнет. Это проблема видимости — каждая многопоточная программа имеет её разновидность. - Рабочий поток
VOLATILEостановился в течение микросекунды или двух после записи. Такова стоимость барьера памяти, который JVM генерирует для пары записи/чтенияvolatile— в худшем случае однозначные микросекунды, а часто меньше. Дёшево для правильного сценария использования. - Счётчик
VOLATILE++оказался меньше200,000.volatileне превращаетn++в атомарную операцию — это по-прежнему чтение-изменение-запись, и два потока могут оба прочитать42и оба записать43. Это наиболее распространённое неправильное использованиеvolatile. Для составных обновлений используйтеAtomicInteger(следующая глава). - Стоимость
volatileмала, но реальна — каждое чтение и каждая запись затрагивают основную память и генерируют барьер памяти. Для флага, который читается раз за итерацию цикла, это ничто. Для поля, которое читается во внутреннем цикле, стоимость барьера накапливается. Если вы видите горячийvolatileв профиле, подумайте, можно ли вынести чтение в локальную переменную и выполнять тело цикла на этом снимке. volatileтакже является механизмом публикации для ссылки — записать данные, затем выполнитьvolatile-запись ссылки. Читающий поток, который видит новую ссылку, гарантированно видит все данные, подготовленные писателем. Это строительный блок шаблонов двойной проверки блокировки и атомарной замены неизменяемого значения.
Что дальше
Следующая глава, Java Atomic Variables, знакомит с AtomicInteger, AtomicLong, AtomicReference и остальными членами семейства java.util.concurrent.atomic — правильным инструментом для «увеличить счётчик из многих потоков» и любого другого безблокировочного составного обновления.