🧵 Розділ 9 · Питання #5

Як працює synchronized на рівні монітора

На рівні монітора ключове слово synchronized забезпечує взаємне виключення (mutual exclusion), гарантуючи, що тільки один потік одночасно може виконувати критичну секцію коду, п...


🟢 Junior Level

Коротка відповідь для співбесіди (30 секунд)

На рівні монітора ключове слово synchronized забезпечує взаємне виключення (mutual exclusion), гарантуючи, що тільки один потік одночасно може виконувати критичну секцію коду, прив’язану до монітора цільового об’єкта.

Механіка роботи поділяється на два рівні:

  1. На рівні байт-коду:
    • synchronized-блок компілюється в явну пару інструкцій monitorenter (захоплення монітора) та monitorexit (звільнення монітора, генерується як для нормального завершення, так і в блоці обробки будь-яких винятків).
    • synchronized-метод не містить інструкцій monitorenter/monitorexit, а позначається прапорцем доступу ACC_SYNCHRONIZED у таблиці методів класу. Сама JVM автоматично виконує захоплення монітора при вході в метод і звільнення при поверненні.
  2. На рівні JVM (HotSpot): при вході потік намагається атомарно зафіксувати право володіння в заголовку об’єкта (Mark Word). Якщо монітор вільний, потік входить без системних викликів; якщо зайнятий — потік або короткочасно обертається в адаптивному циклі (Adaptive Spinning), або паркується операційною системою у стані BLOCKED.

Об’єкти синхронізації в Java

Кожен тип виклику synchronized неявно або явно прив’язаний до конкретного об’єкта-монітора:

Конструкція Об’єкт монітора Модифікатор / Інструкція
synchronized void method() Поточний екземпляр this Прапорець ACC_SYNCHRONIZED
static synchronized void method() Об’єкт класу ClassName.class Прапорець ACC_SYNCHRONIZED
synchronized(lock) { ... } Явно вказаний об’єкт lock monitorenter / monitorexit
synchronized(this) { ... } Поточний екземпляр this monitorenter / monitorexit

Приклад на Java 21

public class SynchronizedDemo {
    private final Object customLock = new Object();
    private int counter = 0;

    // 1. Метод синхронізується за монітором 'this' через ACC_SYNCHRONIZED
    public synchronized void incrementInstance() {
        counter++;
    }

    // 2. Блок синхронізується за виділеним об'єктом customLock через monitorenter/exit
    public void incrementBlock() {
        synchronized (customLock) {
            counter++;
        }
    }

    // 3. Статичний метод синхронізується за об'єктом SynchronizedDemo.class
    public static synchronized void globalReset() {
        // Доступ до статичних ресурсів
    }
}

🟡 Middle Level

Байт-код: monitorenter / monitorexit проти ACC_SYNCHRONIZED

Розглянемо відмінності в байт-коді, що генерується компілятором javac.

1. Синхронізований блок (monitorenter / monitorexit)

public void execute(Object lock) {
    synchronized (lock) {
        doSomething();
    }
}

Декомпіляція через javap -c -v:

public void execute(java.lang.Object);
  Code:
     0: aload_1              // Завантаження посилання lock на стек
     1: dup
     2: astore_2             // Збереження посилання в локальну змінну 2
     3: monitorenter         // Атомарне захоплення монітора
     4: aload_0
     5: invokevirtual #7     // Виклик doSomething()
     8: aload_2              // Завантаження посилання lock
     9: monitorexit          // Нормальне звільнення монітора
    10: goto          18
    13: astore_3             // Перехоплення винятку (будь-який Throwable)
    14: aload_2
    15: monitorexit          // ГАРАНТОВАНЕ звільнення монітора при збої!
    16: aload_3
    17: athrow               // Повторний викид спійманого винятку
    18: return
  Exception table:
     from    to  target type
         4    10    13   any   // Охоплює тіло критичної секції
        13    16    13   any   // Охоплює обробник винятку

2. Синхронізований метод (ACC_SYNCHRONIZED)

public synchronized void executeMethod() {
    doSomething();
}

Декомпіляція:

public synchronized void executeMethod();
  flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
     0: aload_0
     1: invokevirtual #7     // Method doSomething:()V
     4: return

У байт-коді методу немає інструкцій monitorenter та monitorexit. При виклику методу виконавчий механізм JVM перевіряє прапорець ACC_SYNCHRONIZED:

  • Для екземплярного методу JVM неявно запитує монітор об’єкта this (переданого в нульовій локальній змінній).
  • Для статичного методу запитується монітор об’єкта java.lang.Class, який представляє цей клас.
  • При нормальному поверненні (return) або викиді винятку JVM звільняє монітор автоматично на рівні фрейму виклику.

Реентерабельність (Reentrancy) на рівні JVM

Монітори в Java є суворо реентерабельними: якщо потік уже володіє монітором, він може повторно увійти в будь-який інший блок коду, захищений цим самим монітором, не викликаючи взаємного блокування (self-deadlock).

public synchronized void outer() {
    inner(); // Той самий монітор 'this', deadlock НЕ відбувається
}

public synchronized void inner() {
    // Виконання критичного коду
}

Механізм підтримки реентерабельності:

  1. При Lightweight Lock (тонка блокування):
    • Перший вхід створює структуру Lock Record на стеку потоку і копіює туди Displaced Mark Word.
    • Кожен наступний повторний вхід тим самим потоком створює на його стеку новий Lock Record, але з нульовим Displaced Mark Word (null). Це слугує маркером вкладеного входу.
    • При кожному виході monitorexit стек знімає верхній Lock Record. Лише коли знімається останній Lock Record (який містить початковий Displaced Mark Word), монітор фізично звільняється через CAS.
  2. При Heavyweight Lock (товста блокування):
    • У системному об’єкті ObjectMonitor інкрементується поле _recursions.
    • При monitorexit поле _recursions декрементується. Монітор звільняється і стає доступним іншим потокам лише тоді, коли _recursions стає рівним 0.

🔴 Senior Level

Еволюція та ескалація блокувань (Lock Escalation) в HotSpot

Для досягнення максимальної продуктивності JVM HotSpot оптимізує роботу synchronized, розділяючи захоплення на Fast Path (на асемблері) та Slow Path (системні виклики ядра).

[ Unlocked Object ] (Mark Word: 01)
         │
         │ Потік виконує monitorenter
         v
[ Lightweight Locking (Thin Lock) ] (Mark Word: 00)
    • CAS у Mark Word посилання на Lock Record стека
    • Витрати: одиниці наносекунд, без звернення до ядра ОС
         │
         │ Виникнення конкуренції (CAS зазнає невдачі)
         v
[ Adaptive Spinning ]
    • Циклічне обертання на CPU в очікуванні звільнення
         │
         │ Перевищено ліміт спінінгу
         v
[ Lock Inflation -> Heavyweight Lock (Fat Lock) ] (Mark Word: 10)
    • Алокація C++ об'єкта ObjectMonitor у нативній пам'яті
    • Потоки шикуються в черги _cxq та _EntryList
    • Системний виклик паркування ОС (futex у Linux, WaitForSingleObject у Windows)
    • Стан потоку: BLOCKED. Витрати на context switch: ~1000-5000 ns

[!IMPORTANT] Видалення Biased Locking: Починаючи з Java 15 Biased Locking було вимкнено за замовчуванням, а в Java 21 повністю видалено (JEP 416). Причини: величезна складність кодової бази HotSpot, дорогі Safepoint-паузи для відкликання блокувань (Revocation STW) та малий виграш на сучасних багатоядерних архітектурах із розвиненими атомарними інструкціями.


Семантика Моделі Пам’яті Java (JMM): Бар’єри пам’яті

Ключове слово synchronized накладає суворі вимоги на компілятор і процесор у межах JSR-133:

  1. Вхід у монітор (monitorenter):
    • Має семантику Acquire.
    • Жодна операція читання або запису, що розташована всередині синхронізованого блоку, не може бути винесена процесором або JIT-компілятором перед входом у монітор.
    • Локальні процесорні кеші потоку інвалідуються, змушуючи читати актуальні значення змінних із загальної пам’яті.
  2. Вихід із монітора (monitorexit):
    • Має семантику Release.
    • Жодна операція запису, що розташована всередині синхронізованого блоку, не може бути відкладена і виконана після звільнення монітора.
    • Усі зміни, зроблені потоком усередині критичної секції, скидаються зі Store Buffer / L1 кешу в загальну видимість (L3/RAM).
  3. Відношення Happens-Before:

    Звільнення монітора (monitorexit) на об’єкті happens-before кожному наступному захопленню (monitorenter) того ж самого монітора.


Внутрішні черги HotSpot: різниця між _cxq та _EntryList

У структурі ObjectMonitor конкуруючі потоки організуються у дві незалежні структури даних:

Нові конкуруючі потоки
           │
           │ CAS push (Lock-Free LIFO Stack)
           v
      [  _cxq  ] (Contention Queue)
           │
           │ Потік-власник при виході з monitorexit переносить елементи
           v
     [ _EntryList ] (Черга готових до захоплення потоків)
           │
           │ Пробудження наступного потоку (_succ)
           v
     [ Потік-Власник (_owner) ]
  • _cxq (Contention Queue): однозв’язний неблокуючий LIFO-стек. Усі потоки, яким не вдалося захопити монітор через CAS, атомарно додаються в голову _cxq без глобальних блокувань.
  • _EntryList: двозв’язний список, який використовується власником монітора при виборі наступника. Залежно від внутрішньої політики HotSpot (Knob_QMode), потік, що виходить, переносить вузли з _cxq в _EntryList у порядку FIFO або LIFO.
  • _succ (Successor): оптимізація «естафетної палички». Потік-власник перед виходом призначає один потік із черги наступником (_succ) і розблоковує його. Цей потік пробуджується і відразу намагається захопити монітор, уникаючи масового пробудження («thundering herd problem»).

JIT-Оптимізації: Lock Elision та Lock Coarsening

Компілятор C2 здійснює агресивну оптимізацію моніторів:

1. Lock Elision (Усунення блокувань на основі Escape Analysis)

Якщо компілятор доводить, що об’єкт блокування не виходить за межі поточного потоку (NoEscape), операції monitorenter та monitorexit повністю вирізаються з машинного коду:

public String formatMessage(String message) {
    // StringBuffer синхронізований, але посилання sb локальне і не "втікає"
    StringBuffer sb = new StringBuffer();
    sb.append("Prefix: ").append(message);
    return sb.toString(); // JIT C2 повністю усуває всі блокування!
}

2. Lock Coarsening (Укрупнення блокувань)

Якщо в коді йдуть поспіль кілька синхронізацій за одним і тим самим монітором, JIT об’єднує їх в один спільний блок, скорочуючи оверхед на захоплення та скидання кешів:

// Вихідний байт-код:
synchronized(lock) { a(); }
synchronized(lock) { b(); }
synchronized(lock) { c(); }

// Оптимізований JIT-код:
synchronized(lock) {
    a();
    b();
    c();
}

🎯 Шпаргалка для інтерв’ю

1. 30-секундна квінтесенція (Elevator Pitch)

На рівні монітора synchronized забезпечує взаємне виключення та видимість змін (JMM Happens-Before). synchronized-блок компілюється в байткод-інструкції monitorenter/monitorexit з прихованим блоком try-finally для гарантованого звільнення при винятках, а synchronized-метод керується прапорцем ACC_SYNCHRONIZED. У JVM HotSpot блокування ескалується: від атомарного CAS на стеку (Lightweight Lock) до системного об’єкта ObjectMonitor (Heavyweight Lock) з паркуванням потоків у стані BLOCKED через системні виклики ОС. Застарілий Biased Locking видалено в Java 21.


2. 4 каверзних запитання з глибокими відповідями

Запитання 1: У чому ключова відмінність в обробці винятків між synchronized-блоком та synchronized-методом?

Відповідь:

  • У synchronized-блоці компілятор javac явно генерує таблицю винятків (Exception table) з таргетом any, що охоплює діапазон від першої інструкції всередині блоку до інструкції нормального monitorexit. В обробнику дублюється інструкція monitorexit, після чого йде athrow.
  • У synchronized-методі таблиця винятків для монітора не генерується! Захоплення і звільнення монітора виконує сам виконавчий механізм виклику методів JVM за наявності прапорця ACC_SYNCHRONIZED. Якщо з методу вилітає необроблений виняток, монітор звільняється JVM під час розкручування стека (stack unwinding) до повернення у викликаючий фрейм.

Запитання 2: Чому synchronized(new Integer(1)) або synchronized("admin") — це критична архітектурна помилка?

Відповідь:

  1. Інтернування рядків (String pool): рядкові літерали інтернуються JVM. Якщо різні компоненти застосунку або сторонні бібліотеки напишуть synchronized("admin"), вони несподівано почнуть блокуватися на одному й тому самому спільному глобальному об’єкті String, породжуючи непередбачувані паузи та взаємні блокування (Deadlock).
  2. Кешування обгорток примітивів (Integer Cache): значення Integer.valueOf() від -128 до 127 кешуються. Блокування за закешованим числом еквівалентне глобальному блокуванню.
  3. Value-based Classes (Project Valhalla): у сучасних Java класи-обгортки (Integer, Long тощо) позначені як @ValueBased. Синхронізація за ними оголошена deprecated for removal і викидає попередження, а в майбутніх версіях Java призведе до викиду IdentityException. Блокуватися слід тільки на приватних екземплярах new Object().

Запитання 3: Що станеться, якщо потік буде перервано (Thread.interrupt()), коли він перебуває у стані очікування захоплення монітора (BLOCKED)?

Відповідь: Потік не відреагує на переривання і не викине InterruptedException. Очікування входу в synchronized (на рівні monitorenter) є неперервним (uninterruptible). Потік продовжить перебувати в черзі _cxq/_EntryList у стані BLOCKED доти, доки монітор не звільниться. Прапорець переривання (isInterrupted() == true) збережеться, але потік зможе перевірити його лише після того, як фізично увійде в критичну секцію. Якщо потрібне переривне очікування блокування, необхідно використовувати Lock.lockInterruptibly() або Lock.tryLock(timeout) з пакета java.util.concurrent.locks.

Запитання 4: Чому synchronized(this) практично ніколи не піддається оптимізації Lock Elision з боку JIT-компілятора?

Відповідь: Lock Elision можливий лише в тому разі, якщо JIT на основі аналізу втечі (Escape Analysis) доведе статус об’єкта як NoEscape (об’єкт створено всередині методу і нікуди не передається). Посилання this вказує на поточний екземпляр, який був створений у викликаючому коді та доступний зовнішнім потокам (статус ArgEscape або GlobalEscape). Оскільки компілятор не може гарантувати, що інший потік паралельно не звернеться до того ж екземпляра, JIT зобов’язаний зберегти інструкції синхронізації. Lock Elision працює тільки для локально створених об’єктів-блокувань.


3. Типові помилки та Red Flags

❌ “Synchronized-метод швидший за synchronized-блок, тому що в ньому менше байт-коду.”
Правильно: Різниця в продуктивності між ACC_SYNCHRONIZED та monitorenter/monitorexit на сучасних JIT-компіляторах є зникаюче малою або відсутня. Синхронізований блок переважніший з архітектурної точки зору, оскільки дозволяє звузити критичну секцію до мінімуму та інкапсулювати об’єкт блокування через private final Object lock.

❌ “Потік, що очікує на вхід у synchronized, перебуває у стані WAITING.”
Правильно: Потік, що конкурує за монітор при вході в synchronized, перебуває у стані Thread.State.BLOCKED. У стан WAITING потік переходить лише після явного виклику wait().

❌ “Якщо всередині synchronized-блоку викликати sleep(), монітор звільниться.”
Правильно: Метод Thread.sleep() ніколи не звільняє утримувані монітори. Потік просто засинає (переходить у TIMED_WAITING), продовжуючи тримати монітор і блокуючи всіх інших конкурентів. Звільняє монітор тільки метод Object.wait().

❌ “При виході з synchronized за винятком монітор залишається заблокованим.”
Правильно: Байт-код гарантує виклик monitorexit через неявний блок перехоплення винятків (Exception table), запобігаючи зависанню монітора.


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