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

Пов’язані теми