Питання 1 · Розділ 9

В чому різниця між synchronized та volatile?

synchronized та volatile — це два ключові слова в Java для роботи з багатопоточністю. Вони вирішують різні задачі:

Мовні версії: English Russian Ukrainian

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 НЕ підходить

  1. Складні операції (increment, check-then-act): volatile count++ НЕ атомарний — використовуйте AtomicInteger
  2. Групи змінних: якщо потрібно атомарно оновити два поля — volatile не допоможе, потрібен synchronized
  3. Залежні змінні: якщо значення 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 забезпечує:

  1. Взаємне виключення (Mutual Exclusion) — тільки один потік виконує код одночасно
  2. Видимість (Visibility) — при вході в synchronized блок кеш інвалідується, при виході — дані скидаються в пам’ять
  3. Атомарність (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 можлива ситуація, коли:

  1. Потік A починає створювати instance (виділяє пам’ять, але конструктор не завершений)
  2. JVM переупорядковує: спочатку присвоює посилання, потім викликає конструктор
  3. Потік 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

  1. volatile — для прапорців та публікації immutable об’єктів
  2. synchronized — для складних операцій (read-modify-write)
  3. Atomic* — для простих лічильників (CAS швидше за synchronized)
  4. Private lock — використовуйте private final Object lock замість synchronized(this)
  5. Уникайте volatile long/double без необхідності атомарності (на 32-бітних JVM)

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • volatile гарантує тільки видимість, synchronized — видимість + атомарність + mutual exclusion
  • volatile застосовується тільки до полів, 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-класів