W3docs

Ключевое слово 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

Четыре вещи:

  1. Видимость. Запись в поле volatile гарантированно будет видна последующим операциям чтения в любом другом потоке.
  2. Атомарность чтения и записи — но только для самого поля. Чтение/запись volatile long и volatile double атомарны; без volatile 64-битная операция чтения/записи может быть разорвана на 32-битной JVM.
  3. Упорядоченность (happens-before). Всё, что произошло до записи в volatile в пишущем потоке, видимо для всего, что происходит после соответствующего чтения volatile в читающем потоке. Это аспект, который не виден в синтаксисе, но является наиболее мощной гарантией.
  4. Запрет переупорядочения через обращение. Компилятор и CPU не могут переставить обычные операции чтения/записи через обращение к volatile в любом направлении.

Третий пункт — happens-before через пару операций записи/чтения volatile — иногда называют дешайшим примитивом публикации. Если вы записываете поле, а затем делаете volatile boolean ready = true, другой поток, который видит ready == true, гарантированно увидит и предшествующую запись. Именно так реализуется потокобезопасная инициализация без блокировки.

Что volatile не гарантирует

Самая распространённая ошибка:

volatile int counter = 0;

void increment() { counter++; }                       // STILL BROKEN

volatile делает чтение атомарным и запись атомарной — но 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 секунды, чтобы демонстрация завершилась.

java— editable, runs on the server

Что следует вынести из запуска:

  • Рабочий поток 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 — правильным инструментом для «увеличить счётчик из многих потоков» и любого другого безблокировочного составного обновления.

Практика

Практика
Вы объявляете `private volatile int counter = 0;` и вызываете `counter++` из нескольких потоков. После того как 4 потока выполнили по 100 000 приращений, какое значение обычно содержит `counter`?
Вы объявляете `private volatile int counter = 0;` и вызываете `counter++` из нескольких потоков. После того как 4 потока выполнили по 100 000 приращений, какое значение обычно содержит `counter`?
Was this page helpful?