Понимание ключевого слова volatile в Java

Published June 17, 2024

Ключевое слово volatile в Java указывает, что значение переменной может изменяться разными потоками. Объявление переменной как volatile гарантирует, что поток читает её актуальное значение, а не использует устаревшую копию из локального кэша. В многопоточной среде это важно: без такой гарантии один поток может не заметить изменение, сделанное другим.

Зачем нужен volatile?

Для ускорения работы каждый поток и процессор могут хранить значения переменных в регистрах или кэше. В результате поток иногда продолжает работать со старым значением, хотя другой поток уже записал новое. volatile устанавливает правила видимости: запись публикуется для остальных потоков, а последующее чтение получает обновлённое значение.

Основные свойства volatile

  1. Видимость. Изменения volatile-переменной становятся видимыми другим потокам. Запись не может остаться только в локальном представлении одного потока.
  2. Атомарность. volatile не делает составные операции атомарными. Например, counter++ включает чтение, увеличение и запись, поэтому два потока могут потерять одно из обновлений.
  3. Порядок операций. Java Memory Model ограничивает переупорядочивание чтений и записей вокруг volatile. Запись в такую переменную happens-before последующего чтения того же значения другим потоком.

Когда использовать volatile

  1. Когда одна простая переменная используется несколькими потоками.
  2. Когда читатель должен видеть последнее значение, опубликованное другим потоком.
  3. Когда состояние изменяется одиночной записью и не участвует в операциях read-modify-write, check-then-act или в сложных инвариантах.

Типичные примеры — флаг остановки, признак готовности или ссылка на неизменяемую конфигурацию.

Пример использования volatile

public class VolatileExample {
    private volatile boolean flag = false;

    public void writer() {
        flag = true;  // запись в volatile-переменную
    }

    public void reader() {
        if (flag) {  // чтение volatile-переменной
            System.out.println("Flag is true!");
        }
    }

    public static void main(String[] args) {
        VolatileExample example = new VolatileExample();

        Thread writerThread = new Thread(() -> {
            try {
                Thread.sleep(1000); // имитация работы
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            example.writer();
            System.out.println("Flag set to true by writer thread");
        });

        Thread readerThread = new Thread(() -> {
            while (!example.flag) {
                // ожидаем, пока значение станет true
            }
            example.reader();
        });

        writerThread.start();
        readerThread.start();
    }
}

В этом примере поле flag объявлено как volatile. Поток-писатель ждёт одну секунду и присваивает ему true. Поток-читатель проверяет флаг в цикле и благодаря правилам видимости замечает обновление. После этого он выходит из цикла и вызывает reader().

Без volatile Java Memory Model не обязывает поток-читатель повторно получать значение из общей памяти. Теоретически он может продолжать видеть false и ждать бесконечно. Сам цикл активного ожидания при этом не является хорошим решением для реального приложения — он лишь наглядно показывает проблему видимости.

Ограничения volatile

  1. Нет атомарности составных действий. Инкремент, сравнение с последующей записью или обновление нескольких полей требуют других средств синхронизации.
  2. Нельзя защитить сложное состояние. Если корректность зависит от согласованности нескольких значений, применяйте synchronized, ReentrantLock или потокобезопасную структуру данных.
  3. Не заменяет координацию потоков. Для ожидания событий лучше подходят CountDownLatch, условия блокировок, очереди или другие высокоуровневые примитивы.

Итоги

volatile — компактный инструмент для публикации простого состояния между потоками. Он гарантирует видимость и определённый порядок операций, но не превращает составные действия в атомарные. Для флагов и независимых присваиваний этого часто достаточно; для счётчиков и сложных переходов состояния нужны атомарные классы, блокировки или синхронизированные секции. Правильный выбор механизма делает многопоточный код одновременно корректным и понятным.