В чём разница между 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() на уровне байт-кода делится на три фазы:
- Выделение памяти под объект.
- Инициализация полей конструктором
SafeDclSingleton(). - Присвоение ссылки на выделенную память переменной
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 не сразу является тяжелым системным мьютексом:
- Lightweight Locking (Тонкая блокировка): Поток пытается захватить объект через быстрый атомарный CAS в заголовке объекта (
Mark Word), сохраняя запись в своем стеке (Lock Record). Захват происходит за несколько наносекунд без переключения контекста ядра ОС. - Адаптивный спиннинг (Adaptive Spinning): Если монитор занят, поток не сразу переходит в
BLOCKED, а делает несколько циклов холостого вращения (spin-wait) в надежде, что удерживающий поток освободит монитор. - 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).