В чому різниця між synchronized та volatile?
synchronized та volatile — це два ключові слова в Java для роботи з багатопоточністю. Вони вирішують різні задачі:
Junior рівень
Базове розуміння
synchronized та volatile — це два ключові слова в Java для роботи з багатопоточністю. Вони вирішують різні задачі:
| Характеристика | volatile | synchronized |
|---|---|---|
| Що робить | Гарантує видимість (запис одного потоку одразу видно всім читаючим, а не лежить у локальному кеші ядра) | Гарантує видимість + атомарність (складна операція виконується цілком, без втручання інших потоків) |
| Де застосовується | Тільки до полів (змінних) | До методів або блоків коду |
| Блокування | Ні | Так (захоплення монітора об’єкта) |
| Deadlock | Неможливий | Можливий |
Приклад volatile
public class Worker {
private volatile boolean running = true;
public void stop() {
running = false; // Всі потоки одразу побачать це значення
}
public void doWork() {
while (running) {
// Працюємо...
}
}
}
Приклад synchronized
public class Counter {
private int count = 0;
public synchronized void increment() {
count++; // Атомарна операція — тільки один потік може виконувати
}
public synchronized int getCount() {
return count;
}
}
Коли що використовувати
volatile— для прапорців (stop flag, ready flag), коли один потік пише, інші читаютьsynchronized— коли потрібно атомарно прочитати-змінити-записати (лічильники, складні операції)
Коли volatile НЕ підходить
- Складні операції (increment, check-then-act):
volatile count++НЕ атомарний — використовуйте AtomicInteger - Групи змінних: якщо потрібно атомарно оновити два поля — volatile не допоможе, потрібен synchronized
- Залежні змінні: якщо значення B залежить від A — volatile не гарантує атомарність пари (A, B)
Middle рівень
Java Memory Model (JMM)
Обидва механізми працюють в рамках Java Memory Model (JMM), визначеної в JSR-133.
Як працює volatile
При запису в volatile змінну JVM вставляє Memory Barriers:
Memory Barrier (бар’єр пам’яті / memory fence) — інструкція процесора, що забороняє переупорядковування операцій читання/запису навколо бар’єру. Без бар’єру процесор може переставити інструкції для оптимізації, що призведе до некоректної поведінки в багатопоточному середовищі.
Типи бар’єрів у JVM:
- LoadLoad: заборонити переупорядковування двох читань
- StoreStore: заборонити переупорядковування двох записів
- LoadStore: читання не може бути переставлене після запису
- StoreLoad: запис не може бути переставлений після читання (найдорожчий)
Де “Load” = читання з пам’яті, “Store” = запис у пам’ять.
- Store-Store бар’єр — гарантує, що всі звичайні записи до volatile будуть скинуті в пам’ять
- Store-Load бар’єр — гарантує, що наступні читання побачать актуальні дані
// Без volatile — проблема видимості
int x = 0;
boolean ready = false; // без volatile
// Потік 1:
x = 42;
ready = true;
// Потік 2:
if (ready) {
System.out.println(x); // Може вивести 0! (x ще не видно)
}
// З volatile — гарантія видимості
int x = 0;
volatile boolean ready = false;
// Потік 1:
x = 42;
ready = true; // Store-Load бар'єр — x гарантовано записаний
// Потік 2:
if (ready) {
System.out.println(x); // Завжди виведе 42!
}
Happens-Before гарантія
Запис у volatile happens-before наступному читанню тієї ж змінної. Це означає, що всі зміни, зроблені до запису в volatile, будуть видні потоку, який читає цю змінну.
Як працює synchronized
synchronized забезпечує:
- Взаємне виключення (Mutual Exclusion) — тільки один потік виконує код одночасно
- Видимість (Visibility) — при вході в synchronized блок кеш інвалідується, при виході — дані скидаються в пам’ять
- Атомарність (Atomicity) — складні операції виконуються як єдине ціле
// Проблема: volatile НЕ забезпечує атомарність
volatile int counter = 0;
counter++; // НЕ безпечно! Це 3 операції: read → modify → write
// Рішення: synchronized забезпечує атомарність
synchronized(lock) {
counter++; // Безпечно — тільки один потік виконує
}
volatile vs AtomicBoolean: volatile гарантує тільки видимість. AtomicBoolean гарантує видимість + атомарність через CAS. Для простого прапорця (running = true) volatile достатньо. Для if (!flag.compareAndSet(false, true)) потрібен AtomicBoolean.
Double-Checked Locking патерн
Класичний приклад, де volatile критично важливий:
public class Singleton {
// ОБОВ'ЯЗКОВО volatile! Без нього можлива частково ініціалізований об'єкт
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // Перша перевірка (без блокування)
synchronized (Singleton.class) {
if (instance == null) { // Друга перевірка (з блокуванням)
instance = new Singleton(); // Створення об'єкта
}
}
}
return instance;
}
}
Без volatile можлива ситуація, коли:
- Потік A починає створювати
instance(виділяє пам’ять, але конструктор не завершений) - JVM переупорядковує: спочатку присвоює посилання, потім викликає конструктор
- Потік B бачить
instance != nullі отримує частково ініціалізований об’єкт
Senior рівень
Under the Hood: Реалізація на рівні CPU
volatile на рівні процесора
На архітектурі x86 запис у volatile поле транслюється в інструкцію з префіксом lock, який:
Префікс lock — x86-інструкція, що блокує кеш-лінію на час операції. Інші ядра не можуть читати/писати цю кеш-лінію, поки операція не завершена.
- Блокує кеш-лінію на час запису
- Посилає сигнал інвалідації іншим ядрам (протокол MESI)
- Забороняє переупорядковування інструкцій через бар’єр
Протокол MESI (Modified, Exclusive, Shared, Invalid) — протокол когерентності кешів багатоядерних CPU. Коли ядро записує дані (Modified), воно розсилає сигнал іншим ядрам — їхні копії стають Invalid і мають бути перечитані з RAM.
# Звичайний запис (без volatile):
mov [rax], 1
# Volatile запис (з memory barrier):
lock or dword ptr [rax], 0 # Store barrier
mov [rax], 1
lock or dword ptr [rax], 0 # Load barrier
synchronized на рівні байт-коду
synchronized(obj) { /* code */ }
Байт-код:
aload_1 // Завантажуємо obj
dup
astore_2
monitorenter // Захоплення монітора
/* code */
aload_2
monitorexit // Звільнення монітора
Важливо: компілятор генерує два monitorexit — один для нормального виходу, другий для обробки виключення (прихований finally).
Reordering та Memory Barriers
Процесори та JIT-компілятор переупорядковують інструкції для оптимізації. volatile накладає обмеження:
| Тип бар’єру | Забороняє |
|---|---|
| LoadLoad | Читання після читання не може бути переміщене вгору |
| StoreStore | Запис після запису не може бути переміщений вгору |
| LoadStore | Запис після читання не може бути переміщений вгору |
| StoreLoad | Читання після запису не може бути переміщений вгору (найдорожчий) |
volatile запис = StoreStore + StoreLoad бар’єри
volatile читання = LoadLoad + LoadStore бар’єри
Продуктивність та Highload
Cache Invalidation та False Sharing
public class Counters {
public volatile int counter1; // Можуть бути в одній кеш-лінії
public volatile int counter2; // Запис у counter1 інвалідує counter2!
}
Рішення — @Contended (Java 8+):
public class ContendedCounters {
@Contended
public volatile int counter1;
@Contended
public volatile int counter2; // Тепер у різних кеш-лініях
}
Адаптивне очікування (Adaptive Spinning)
Сучасна JVM не відправляє потік у BLOCKED одразу. Спочатку потік виконує spin-wait — порожній цикл, очікуючи звільнення монітора. Якщо спиннинг виявився успішним — економимо на context switch. Якщо ні — паркуємо потік через ОС.
Порівняльна таблиця (Advanced)
| Характеристика | volatile | synchronized |
|---|---|---|
| Тип гарантії | Видимість + впорядковування | Видимість + атомарність + виключення |
| Рівень | Змінна (поле) | Метод або блок коду |
| Блокування | Ні (Lock-free) | Так (Blocking) |
| Кеш CPU | Відключає кешування змінної | Скидає весь локальний кеш при вході/виході |
| Deadlock | Неможливий | Можливий |
| Overhead | Низький (memory barrier) | Середній/високий (залежить від contention) |
| Реентерабельність | Н/Д | Так |
Діагностика
# jstack покаже BLOCKED потоки для synchronized
jstack <pid> | grep "BLOCKED"
# Для volatile проблем — тільки через аналіз поведінки
# JCStress для стрес-тестування
Best Practices
- volatile — для прапорців та публікації immutable об’єктів
- synchronized — для складних операцій (read-modify-write)
- Atomic* — для простих лічильників (CAS швидше за synchronized)
- Private lock — використовуйте
private final Object lockзамістьsynchronized(this) - Уникайте
volatile long/doubleбез необхідності атомарності (на 32-бітних JVM)
🎯 Шпаргалка для співбесіди
Обов’язково знати:
volatileгарантує тільки видимість,synchronized— видимість + атомарність + mutual exclusionvolatileзастосовується тільки до полів,synchronized— до методів і блоків кодуvolatileНЕ захищає складні операції (count++ не атомарний навіть з volatile)volatileвикористовує memory barriers (StoreStore, StoreLoad),synchronized— захоплення монітора- Deadlock неможливий з
volatile, але можливий зsynchronized - На x86
volatileзапис транслюється вlockпрефікс та інвалідацію кеш-ліній (MESI) - Double-Checked Locking вимагає
volatileдля коректної публікації Singleton volatile— lock-free, overhead нижче ніж уsynchronizedпри низькій конкуренції
Часті уточнюючі запитання:
- Чи можна використовувати volatile для лічильника? — Ні, count++ = read-modify-write (3 операції), використовуйте AtomicInteger
- Що станеться без volatile в Double-Checked Locking? — JVM може переупорядкувати: посилання присвоєно до завершення конструктора → частково ініціалізований об’єкт
- Які memory barriers вставляє volatile? — Запис: StoreStore + StoreLoad; Читання: LoadLoad + LoadStore
- Коли volatile краще за synchronized? — Для простих прапорців (stop, ready), де один потік пише, інші читають
Червоні прапори (НЕ говорити):
- “Volatile робить операції атомарними” — ні, це про AtomicInteger/CAS
- “Synchronized та volatile взаємозамінні” — ні, вони вирішують різні задачі
- “Volatile захищає групу змінних” — ні, тільки одну змінну
- “Synchronized потрібен для видимості, volatile для атомарності” — рівно навпаки
Пов’язані теми:
- [[2. Що таке happens-before relationship]] — volatile запис happens-before читанню
- [[3. Що таке visibility problem]] — volatile вирішує проблему видимості
- [[8. Що таке Atomic класи]] — lock-free альтернатива для лічильників
- [[9. Що таке CAS (Compare-And-Swap)]] — основа роботи Atomic-класів