🧵 Раздел 9 · Вопрос #1

В чём разница между synchronized и volatile

Ключевые слова synchronized и volatile решают разные задачи координации потоков в Java:


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Ключевые слова synchronized и volatile решают разные задачи координации потоков в Java:

  • volatile решает проблему видимости (visibility) и предотвращает переупорядочивание инструкций процессором (instruction reordering). Запись в volatile-переменную сразу сбрасывается в общую память и становится видимой всем читающим потокам. Однако volatile не обеспечивает атомарность составных операций (например, count++).
  • synchronized решает сразу три задачи: видимость, атомарность (atomicity) и взаимное исключение (mutual exclusion). Код внутри блока или метода synchronized может выполняться только одним потоком одновременно за счет захвата монитора объекта.

Сравнительная таблица

Характеристика volatile synchronized
Что гарантирует Видимость + порядок инструкций Видимость + атомарность + взаимное исключение
К чему применяется Только к полям класса К методам или блокам кода
Блокировка потоков Нет (Lock-Free) Да (захват монитора, перевод в BLOCKED)
Атомарность i++ ❌ Нет (гонка данных) ✅ Да
Риск Deadlock ❌ Невозможен ⚠️ Возможен

Примеры на Java 21

public class SynchronizedVsVolatileDemo {

    // 1. Использование volatile: флаг остановки потока (один пишет, многие читают)
    public static class Worker implements Runnable {
        private volatile boolean running = true;

        public void stop() {
            running = false; // Запись сразу видна в цикле другого ядра
        }

        @Override
        public void run() {
            while (running) {
                // Выполнение фоновой работы
            }
        }
    }

    // 2. Использование synchronized: атомарный инкремент составной операции
    public static class Counter {
        private int count = 0;

        // Без synchronized операция count++ приведёт к потере инкрементов (Race Condition)
        public synchronized void increment() {
            count++; // чтение -> модификация -> запись
        }

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

🟡 Middle Level

Java Memory Model (JMM): Happens-Before и барьеры памяти

Оба механизма специфицированы в модели памяти Java (JMM / JSR-133):

1. Правило Happens-Before для volatile:

Запись в volatile-поле happens-before каждому последующему чтению этого же поля.

Это означает, что любые записи в обычные (non-volatile) переменные, совершенные потоком $A$ до записи в volatile-переменную, гарантированно становятся видны потоку $B$ после того, как он прочитает эту volatile-переменную:

int a = 0;
volatile boolean ready = false;

// Поток 1:
a = 42;
ready = true; // Запись в volatile публикует значение 'a'

// Поток 2:
if (ready) {
    System.out.println(a); // Всегда выведет 42, чтение 0 исключено!
}

2. Барьеры памяти (Memory Barriers):

Компилятор JIT вставляет специальные процессорные инструкции — барьеры памяти:

  • Перед записью в volatile: вставляется StoreStore барьер (все предшествующие записи завершаются до записи volatile).
  • После записи в volatile: вставляется самый дорогой StoreLoad барьер (запись сбрасывается в память до начала последующих чтений).
  • После чтения из volatile: вставляются LoadLoad и LoadStore барьеры.

Паттерн Double-Checked Locking (DCL) и почему volatile обязателен

Классическая реализация потокобезопасного ленивого Singleton:

public class SafeDclSingleton {
    // ОБЯЗАТЕЛЬНО volatile!
    private static volatile SafeDclSingleton instance;

    private SafeDclSingleton() {}

    public static SafeDclSingleton getInstance() {
        if (instance == null) { // Первая проверка (быстрая, без блокировки)
            synchronized (SafeDclSingleton.class) {
                if (instance == null) { // Вторая проверка (под монитором)
                    instance = new SafeDclSingleton();
                }
            }
        }
        return instance;
    }
}

Что произойдет без volatile:

Создание объекта new SafeDclSingleton() на уровне байт-кода делится на три фазы:

  1. Выделение памяти под объект.
  2. Инициализация полей конструктором SafeDclSingleton().
  3. Присвоение ссылки на выделенную память переменной instance.

JIT-компилятор и CPU имеют право переставить инструкции (reordering) местами: шаг 3 выполнить до шага 2. В этот микросекундный интервал второй поток, зайдя в первую проверку if (instance == null), увидит ненулевую ссылку и вернет вызывающему коду частично инициализированный объект с пустыми полями! Модификатор volatile запрещает вынос шага 3 за барьер, предотвращая эту катастрофу.


🔴 Senior Level

Аппаратный уровень: Префикс LOCK на x86 и протокол MESI

На аппаратном уровне процессоров архитектуры x86/x64:

  • Запись в volatile-переменную обычно компилируется в инструкцию lock or [rsp], 0 или lock add (либо mfence).
  • Префикс LOCK заставляет процессор временно заблокировать шину кэш-памяти (Cache Lock) для соответствующей 64-байтной кэш-линии (Cache Line).
  • По аппаратному протоколу когерентности кэшей MESI (Modified, Exclusive, Shared, Invalid) процессорное ядро переводит свою кэш-линию в статус Modified и рассылает сигнал инвалидации (Invalidate) всем остальным ядрам. Копии этой строки в кэшах L1/L2 соседних ядер становятся Invalid и при следующем обращении перечитываются заново.

Байт-код synchronized: парные monitorenter и monitorexit

Синхронизированный блок на уровне байт-кода превращается в инструкции:

aload_1
monitorenter      // Захват монитора
... тело блока ...
aload_1
monitorexit       // Освобождение монитора при штатном выходе
goto ...
astore_2
aload_1
monitorexit       // Второй monitorexit: освобождение монитора в скрытом блоке finally при исключении!
aload_2
athrow

Компилятор всегда генерирует два monitorexit, гарантируя освобождение блокировки даже в случае аварийного выброса Throwable.


Эволюция монитора в HotSpot: от Biased Locking к LightWeight Lock

В виртуальной машине HotSpot блокировка synchronized не сразу является тяжелым системным мьютексом:

  1. Lightweight Locking (Тонкая блокировка): Поток пытается захватить объект через быстрый атомарный CAS в заголовке объекта (Mark Word), сохраняя запись в своем стеке (Lock Record). Захват происходит за несколько наносекунд без переключения контекста ядра ОС.
  2. Адаптивный спиннинг (Adaptive Spinning): Если монитор занят, поток не сразу переходит в BLOCKED, а делает несколько циклов холостого вращения (spin-wait) в надежде, что удерживающий поток освободит монитор.
  3. Inflated Lock (Тяжелый монитор ObjectMonitor): При высокой конкуренции (contention) блокировка «раздувается» до уровня объекта операционной системы (pthread_mutex), а ожидающие потоки паркуются планировщиком ОС с затратами на Context Switch (1-5 мкс).

🎯 Шпаргалка для интервью

30-секундный ответ

«volatile гарантирует видимость изменений между ядрами и запрещает переупорядочивание инструкций с помощью барьеров памяти (StoreLoad), но не обеспечивает атомарность составных операций вроде i++. synchronized обеспечивает как видимость, так и взаимное исключение с атомарностью за счет захвата монитора объекта. На аппаратном уровне volatile транслируется в инструкции с префиксом LOCK и сброс кэш-линий по протоколу MESI, тогда как synchronized компилируется в monitorenter/monitorexit и при конкуренции переводит поток в статус BLOCKED через мьютекс ОС.»


Сводная матрица различий

Критерий volatile synchronized AtomicInteger
Область применения Поля примитивов/ссылок Методы и блоки Классы-обертки
Атомарность count++ ❌ Нет ✅ Да (через монитор) ✅ Да (через CAS)
Блокировка потоков Lock-Free Блокирующая (BLOCKED) Lock-Free / CAS-цикл
Deadlock Невозможен Возможен Невозможен
Цена накладных расходов Низкая (барьер кэша) От низкой (CAS) до высокой (Mutex ОС) Минимальная (инструкция CMPXCHG)

Топ-4 каверзных вопроса на интервью

1. Почему операция volatile count++ не является потокобезопасной?

  • Инкремент count++ не атомарен. Он состоит из трех независимых машинных шагов: чтение переменной из памяти в регистр (read), увеличение регистра на 1 (modify), запись значения обратно в память (write). Если два потока одновременно прочитают значение 5, оба увеличат его до 6 и запишут 6, одно обновление будет безвозвратно потеряно. Для атомарного инкремента нужен synchronized или AtomicInteger.

2. Зачем в паттерне Double-Checked Locking поле instance объявляется как volatile?

  • Без volatile процессор и JIT могут переупорядочить шаги инициализации: сначала записать адрес неинициализированной памяти в поле instance, а затем запустить конструктор объекта. Другой поток, выполняющий первую проверку if (instance == null), увидит ссылку не равной null и начнет работать с наполовину сконструированным объектом. volatile ставит барьер памяти, запрещающий публикацию ссылки до завершения конструктора.

3. Какие барьеры памяти вставляет JVM при записи и чтении volatile?

  • При записи: перед записью ставится StoreStore (завершает предыдущие обычные записи), после записи ставится StoreLoad (сбрасывает запись в кэш до начала чтений).
  • При чтении: после чтения ставятся LoadLoad и LoadStore (запрещают чтению и записи опускаться выше чтения volatile).

4. Как synchronized гарантирует освобождение монитора, если внутри метода выброшено исключение?

  • Компилятор javac генерирует не один, а два байт-кодовых вызова monitorexit: первый ставится перед штатным возвратом (return), а второй помещается в автоматически созданный блок обработки исключений any (скрытый try-finally), который ловит любой Throwable, вызывает monitorexit и повторно выбрасывает исключение (athrow).

Типичные ошибки и Red Flags

  • 🚩 Использование volatile для счетчиков в расчете на потокобезопасность инкремента.
  • 🚩 Попытка синхронизировать группу зависимых переменных через отдельные volatile-поля.
  • 🚩 Синхронизация по мутабельным объектам или переиспользуемым литералам (synchronized("lock") или synchronized(Integer.valueOf(1))).
  • 🚩 Утверждение, что volatile полностью отключает кэширование процессора (кэш не отключается, используется инвалидация кэш-линий по протоколу MESI).

Связанные темы