synchronized и volatile

Вопрос-крючок: «Может ли volatile заменить synchronized?»

synchronized — блокировка, которая гарантирует, что кусок кода одновременно выполняет только один поток, и делает изменения переменных видимыми другим потокам. volatile — модификатор поля, который гарантирует ТОЛЬКО видимость изменений между потоками, но не защищает от одновременного изменения.

Это вопрос-ловушка. Кандидат, который отвечает «да, volatile — это облегчённая версия synchronized», почти наверняка не понимает, что происходит на самом деле. Разберёмся, чем они отличаются и почему путать их — серьёзная ошибка.

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

Ключевое слово synchronized можно повесить на метод или на блок кода. Оно даёт два разных, но связанных эффекта: во-первых, взаимное исключение (только один поток одновременно может находиться внутри), во-вторых — видимость изменений (когда поток выходит из synchronized-блока, все его изменения переменных «публикуются» и становятся видны следующему потоку, который войдёт в блокировку).

class Counter {
    private int value = 0;

    public synchronized void increment() {
        value++;
    }

    public synchronized int get() {
        return value;
    }
}

Здесь synchronized гарантирует, что операция value++ (на самом деле это три шага: прочитать, увеличить, записать) не будет прервана другим потоком посередине. Пока один поток внутри increment(), второй поток, который тоже хочет вызвать increment() или get() на этом же объекте, будет ждать снаружи.

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

volatile — это модификатор поля, а не блокировка. Он решает совсем другую проблему: видимость. Без volatile у каждого потока в теории может быть своя «локальная копия» значения переменной (из-за кеширования в регистрах процессора или оптимизаций компилятора), и поток может годами не замечать, что другой поток изменил значение.

class Flag {
    private volatile boolean running = true;

    public void stop() {
        running = false;
    }

    public void work() {
        while (running) {
            // полезная работа
        }
        System.out.println("Остановлено");
    }
}

Здесь volatile гарантирует, что как только один поток вызовет stop(), другой поток в цикле while (running) увидит новое значение false и выйдет из цикла — а не будет крутиться вечно, глядя на устаревшую закешированную копию.

Почему volatile НЕ заменяет synchronized

Ключевая проблема: volatile не даёт взаимного исключения. Он ничего не знает про операции «прочитать-изменить-записать» (read-modify-write) — он только гарантирует, что чтение всегда возвращает самое свежее записанное значение. Но если два потока одновременно выполняют value++ над volatile-полем, это всё равно сломается.

class BrokenCounter {
    private volatile int value = 0;

    public void increment() {
        value++; // НЕ атомарно, даже с volatile!
    }
}

value++ — это на самом деле три отдельных шага: прочитать текущее значение, прибавить единицу, записать обратно. volatile гарантирует, что каждый из этих трёх шагов видит актуальные данные, но не гарантирует, что между шагом «прочитать» и шагом «записать» не вклинится другой поток. Если оба потока прочитают одно и то же значение до того, как кто-то из них успеет записать своё — один инкремент потеряется. Об этом подробно поговорим в следующем уроке про race condition.

Когда volatile действительно достаточно

Есть случаи, где volatile — правильный и достаточный инструмент, без всякого synchronized: когда переменная не участвует в составных операциях, а просто читается и записывается целиком, как флаг running выше. Типичный пример — флаг остановки потока или ссылка на объект, которую один поток публикует, а другие только читают.

Как это работает под капотом

И synchronized, и volatile работают через так называемые барьеры памяти (memory barriers) — команды, которые запрещают процессору и компилятору переупорядочивать операции чтения и записи определённым образом и заставляют сбрасывать закешированные значения в основную память. Разница в масштабе: volatile ставит такой барьер вокруг одного конкретного поля при каждом обращении к нему, а synchronized ставит барьер вокруг целого блока кода и вдобавок гарантирует, что внутрь блока одновременно попадёт только один поток.

Частые ошибки на собеседовании

  • Утверждают, что volatile делает операцию value++ потокобезопасной — это не так, инкремент остаётся составным и незащищённым от одновременного доступа.
  • Считают volatile «более лёгкой заменой» synchronized — на деле у них разное назначение: один даёт видимость, другой — видимость плюс взаимное исключение.
  • Не могут привести пример, где volatile самодостаточен (флаг остановки, публикация готового объекта) — путают его исключительно с«недоделанной блокировкой».
  • Забывают, что synchronized-методы блокируются по объекту (this) или по классу (для static-методов) — два потока, вызывающих synchronized-методы на РАЗНЫХ объектах, друг друга не блокируют.

Шпаргалка

  • synchronized = взаимное исключение + видимость изменений между потоками.
  • volatile = только видимость, без защиты от одновременного изменения.
  • volatile безопасен для простых флагов и публикации ссылок, но не спасает составные операции вроде counter++.
  • Оба механизма используют барьеры памяти, но synchronized дополнительно ограничивает доступ к блоку кода одним потоком за раз.
Проверьте себя
1. Что из перечисленного НЕ гарантирует volatile?
AЧто поток увидит самое свежее значение переменной, записанное другим потоком
BЧто операция вида counter++ над этой переменной выполнится атомарно, без потери инкрементов
CЧто значение переменной не будет закешировано локально в отдельном потоке навсегда
DВидимость изменения между потоками через барьер памяти
2. В каком случае volatile-поля достаточно, без synchronized?
AКогда несколько потоков одновременно увеличивают общий счётчик
BКогда поле служит простым флагом остановки, который один поток читает, а другой только выставляет в false
CКогда нужно защитить блок кода из нескольких операций от параллельного выполнения
DКогда переменная — это список, в который несколько потоков одновременно добавляют элементы