В чому різниця між 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-компілятор та процесор мають право переставити інструкції (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) до високої (М’ютекс ОС) | Мінімальна (інструкція 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).