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

Що таке монітор (monitor) в Java

Кожен Java-об'єкт «з коробки» володіє власним вбудованим монітором (intrinsic lock або monitor lock). Монітор вирішує два ключові завдання:


🟢 Junior Level

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

Монітор (Monitor) у Java — це високорівневий механізм синхронізації та взаємного виключення (mutual exclusion), запропонований Ч. Гоаром і П. Брінчем Гансеном та вбудований у платформу Java на рівні мови та віртуальної машини JVM.

Кожен Java-об’єкт «з коробки» володіє власним вбудованим монітором (intrinsic lock або monitor lock). Монітор вирішує два ключові завдання:

  1. Взаємне виключення (Mutual Exclusion): гарантує, що в будь-який момент часу лише один потік може виконувати критичну секцію коду, захищену цим монітором (ключове слово synchronized).
  2. Координація та сигналізація (Cooperation): дозволяє потокам координувати спільну роботу, добровільно призупиняючи виконання до настання певної умови за допомогою методів wait(), notify() та notifyAll().

Архітектурні компоненти монітора

Концептуально монітор у Java складається з трьох складових:

  • Lock (Замок / Власник): посилання на потік, який наразі успішно захопив монітор і виконує критичну секцію.
  • Entry Set (Черга входу / EntryList): множина потоків, які намагаються увійти в synchronized-блок, але монітор уже зайнятий іншим потоком. Ці потоки переводяться у стан BLOCKED.
  • Wait Set (Множина очікування / WaitSet): множина потоків, які викликали метод wait() усередині synchronized-блоку. Вони добровільно звільнили монітор і чекають на сигнал (notify()/notifyAll()). Їхній стан — WAITING або TIMED_WAITING.
                    +------------------------------------+
                    |  Потік викликає monitorenter       |
                    +-----------------+------------------+
                                      |
                                      v
                             [ Entry Set ]  <--------------------+
                          (Стан BLOCKED)                         |
                                      |                          | notify() /
                    Монітор вільний   | захоплення               | notifyAll()
                                      v                          |
                             +-----------------+                 |
                             | Власник (Owner) |                 |
                             |   (RUNNABLE)    |                 |
                             +--------+--------+                 |
                                      |                          |
                     lock.wait()      |                          |
                (звільняє монітор)    v                          |
                             [  Wait Set  ] ---------------------+
                          (WAITING / TIMED_WAITING)

Базовий приклад на Java 21

public class BoundedBlockingQueue<T> {
    private final Object lock = new Object(); // Виділений об'єкт для монітора
    private final int capacity;
    private final java.util.Queue<T> queue = new java.util.LinkedList<>();

    public BoundedBlockingQueue(int capacity) {
        this.capacity = capacity;
    }

    // Потік захоплює монітор об'єкта 'lock'
    public void put(T item) throws InterruptedException {
        synchronized (lock) {
            // Очікування в циклі while для захисту від хибних пробуджень (spurious wakeups)
            while (queue.size() == capacity) {
                lock.wait(); // Звільняє монітор і переходить у WaitSet (WAITING)
            }
            queue.add(item);
            lock.notifyAll(); // Будить потоки з WaitSet, переводячи їх в EntrySet
        } // Монітор автоматично звільняється при виході з блоку
    }

    public T take() throws InterruptedException {
        synchronized (lock) {
            while (queue.isEmpty()) {
                lock.wait();
            }
            T item = queue.poll();
            lock.notifyAll();
            return item;
        }
    }
}

🟡 Middle Level

Внутрішня будова: Object Header та Mark Word

У HotSpot JVM кожен Java-об’єкт у купі (Heap) має заголовок (Object Header), що складається з:

  1. Klass Word: вказівник на метадані класу в Metaspace (4 байти при увімкненому UseCompressedClassPointers, інакше 8 байтів).
  2. Mark Word: 64-бітне (на 64-бітних архітектурах) машинне слово, що відображає поточний стан об’єкта: хеш-код, вік збирання сміття (GC age) та стан блокування.

Стани Mark Word (HotSpot 64-bit, Java 21)

Молодші 2 біти Mark Word визначають стан монітора:

Стан блокування Біт зміщення (Bias) Біти стану (Tag) Опис вмісту Mark Word
Unlocked (Без блокування) 0 01 Hashcode (31 біт), GC Age (4 біти), unused
Lightweight Locked (Тонкий лок) 0 00 Вказівник на Lock Record на стеку потоку
Heavyweight Locked (Товстий лок) 0 10 Вказівник на системний C++ об’єкт ObjectMonitor
Marked for GC 0 11 Використовується збирачем сміття під час маркування

[!NOTE] Починаючи з Java 15 механізм Biased Locking (зміщене блокування) було вимкнено за замовчуванням (JEP 374), а в Java 21 повністю видалено з JVM HotSpot (JEP 416). Сучасна ескалація блокувань у Java 21 починається відразу з Lightweight Locking.


Анатомія C++ структури ObjectMonitor

Коли монітор ескалується у важке системне блокування (Heavyweight Lock), JVM виділяє системну C++ структуру ObjectMonitor.

Спрощене представлення з вихідного коду OpenJDK (src/hotspot/share/runtime/objectMonitor.hpp):

class ObjectMonitor {
    void*             _header;       // Початковий Mark Word об'єкта (displaced mark word)
    void*             _object;       // Вказівник на сам Java-об'єкт
    Thread* volatile  _owner;        // Вказівник на потік-власник
    volatile intx     _recursions;   // Лічильник реентерабельних входів (reentrant depth)
    
    // Черги очікування потоків:
    ObjectWaiter* volatile _cxq;      // Contention Queue (LIFO lock-free стек конкуруючих потоків)
    ObjectWaiter* volatile _EntryList; // Черга потоків, готових змагатися за володіння
    ObjectWaiter* volatile _WaitSet;   // Однозв'язний кільцевий список потоків, що викликали wait()
    
    volatile intx     _WaitSetLock;  // Захист структури WaitSet
    Thread* volatile  _succ;         // Спадкоємець (Successor) — потік, призначений стати наступним власником
};

Ролі черг _cxq та _EntryList

  • _cxq (Contention Queue): однозв’язний неблокуючий стек (LIFO), куди новоприбулі потоки атомарно додаються за допомогою інструкції CAS (Compare-And-Swap), коли монітор зайнятий.
  • _EntryList: черга потоків, сформована потоком-власником при виході. При звільненні монітора потік-власник за певною політикою переносить потоки з _cxq в _EntryList і будить потік _succ.

Байт-код: інструкції monitorenter та monitorexit

Синхронізований блок на рівні байт-коду явно компілюється в інструкції monitorenter та monitorexit.

Розглянемо декомпіляцію через javap -c:

public void execute(Object lock) {
    synchronized (lock) {
        doWork();
    }
}
public void execute(java.lang.Object);
  Code:
     0: aload_1              // Завантаження посилання lock на стек операндів
     1: dup
     2: astore_2             // Збереження посилання у тимчасову змінну
     3: monitorenter         // Захоплення монітора об'єкта
     4: aload_0
     5: invokevirtual #7     // Method doWork:()V
     8: aload_2
     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

[!IMPORTANT] Компілятор javac автоматично огортає тіло synchronized-блоку в неявну секцію try-finally через таблицю винятків (Exception table). Це гарантує, що навіть при виникненні будь-якого необробленого винятку (RuntimeException, Error), інструкція monitorexit виконається і монітор не залишиться заблокованим назавжди.


🔴 Senior Level

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

У сучасній JVM (Java 21) перехід станів блокування є суворо односпрямованим при конкуренції:

[ Unlocked (01) ]
       │
       │ Перший потік входить у synchronized
       v
[ Lightweight Lock (00) ]  <--- Lock Record на стеку потоку (CAS у Mark Word)
       │
       │ Конкуренція (CAS зазнає невдачі) + Адаптивний спінінг вичерпано
       v
[ Heavyweight Lock (10) ]  <--- Виділення ObjectMonitor, паркування потоку в ОС (futex/mutex)

1. Lightweight Locking (Тонке блокування)

  • Коли потік входить у synchronized, на його стеку виділяється структура BasicObjectLock (Lock Record).
  • Початковий незаблокований Mark Word об’єкта копіюється в Lock Record (називається Displaced Mark Word).
  • JVM виконує атомарну процесорну інструкцію lock cmpxchg (CAS), намагаючись записати вказівник на Lock Record у Mark Word об’єкта.
  • Якщо CAS успішний: потік став власником тонкого лока без звернення до системних викликів ядра ОС. Витрати становлять усього кілька тактів процесора!
  • При реентерабельному вході (повторний вхід тим самим потоком) у новий Lock Record записується null замість Displaced Mark Word, що слугує ознакою вкладеного входу.

2. Inflation до Heavyweight Lock (Роздування монітора)

Якщо другий потік намагається захопити монітор, поки перший утримує Lightweight Lock:

  1. CAS завершується невдачею.
  2. Потік виконує Adaptive Spinning (адаптивне обертання): крутиться в короткому циклі процесора, розраховуючи, що потік, який утримує лок, швидко його звільнить.
  3. Якщо за час спінінгу лок не звільнився, відбувається Lock Inflation:
    • JVM виділяє структуру ObjectMonitor.
    • У поле _header структури ObjectMonitor поміщається Displaced Mark Word.
    • У Mark Word об’єкта атомарно прописується вказівник на ObjectMonitor і встановлюються біти 10.
    • Конкуруючий потік поміщає себе в стек _cxq і засинає (паркується) через системний виклик ядра ОС (futex у Linux або WaitForSingleObject у Windows).
    • Потік переходить у статус Thread.State.BLOCKED.

Дефляція моніторів (Async Monitor Deflation)

Історично важкий ObjectMonitor міг бути здутий (deflated) назад до стану Unlocked тільки під час повної паузи Stop-The-World (STW) збирача сміття. Це викликало тривалі затримки пауз GC при великій кількості роздутих моніторів.

Починаючи з Java 15/17+ у HotSpot впроваджено механізм Asynchronous Monitor Deflation (JEP 350 / JEP 374):

  • Фоновий сервісний потік JVM (MonitorDeflationThread) періодично асинхронно сканує невикористовувані ObjectMonitor без зупинки робочих потоків застосунку (in safepoint-free manner).
  • Якщо у монітора _owner == null, _cxq == null, _EntryList == null та _WaitSet == null, JVM відновлює Displaced Mark Word назад в об’єкт і звільняє пам’ять ObjectMonitor.

Вплив Object.hashCode() на монітори

Один із найбільш неочевидних механізмів JVM:

  • Якщо об’єкт перебуває у стані Lightweight Lock, у його Mark Word зберігається вказівник на стек. Справжній хеш-код об’єкта зберігається в Lock Record.
  • Якщо на об’єкті, що утримується в Thin Lock, викликається метод System.identityHashCode(obj) або дефолтний obj.hashCode(), JVM не може зберегти 31-бітний identity hashcode в Lock Record без ризику розсинхронізації.
  • Наслідок: виклик identity hashcode на заблокованому об’єкті негайно форсує інфляцію тонкого блокування до Heavyweight Lock (ObjectMonitor), оскільки тільки в ObjectMonitor._header гарантовано є постійне місце для зберігання обчисленого identity hashcode на весь час життя об’єкта!

Оптимізації JIT-компілятора C2

JIT-компілятор аналізує використання моніторів і застосовує потужні оптимізації:

  1. Lock Elision (Видалення блокування): На основі Escape Analysis (аналізу втечі), якщо JIT визначає, що об’єкт, за яким відбувається синхронізація, не виходить за межі поточного методу/потоку (thread-local), виклики monitorenter та monitorexit повністю вирізаються з машинного коду:
    public String concatBuffers(String a, String b) {
        // StringBuffer потокобезпечний і використовує synchronized
        // JIT бачить, що sb не тікає з методу, і видаляє всі блокування!
        StringBuffer sb = new StringBuffer();
        sb.append(a).append(b);
        return sb.toString();
    }
    
  2. Lock Coarsening (Укрупнення блокування): Якщо компілятор бачить ланцюжок послідовних синхронізацій за одним і тим самим монітором, він об’єднує їх в один великий блок, усуваючи накладні витрати на постійний вхід і вихід:
    // До оптимізації:
    for (int i = 0; i < 1000; i++) {
        synchronized (lock) { process(i); }
    }
    // Після Lock Coarsening:
    synchronized (lock) {
        for (int i = 0; i < 1000; i++) {
            process(i);
        }
    }
    

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

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

Монітор у Java — це вбудована мовна абстракція взаємного виключення та координації потоків. Кожен об’єкт Java має вбудований монітор (intrinsic lock). На рівні HotSpot JVM синхронізація оптимізована через ескалацію: за відсутності конкуренції працює легковажний Lightweight Lock через CAS у Mark Word на стеку; при виникненні конкуренції відбувається Lock Inflation у системний Heavyweight Lock (ObjectMonitor), що переводить потоки у стан BLOCKED через системні виклики ядра ОС. Застарілий Biased Locking повністю видалено в Java 21.


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

Запитання 1: Чому методи wait(), notify() та notifyAll() оголошені в java.lang.Object, а не в java.lang.Thread?

Відповідь:

  1. Принцип інкапсуляції та адресації: потоки чекають настання певного стану даних (умови), а не іншого потоку. Дані інкапсульовані всередині Java-об’єктів.
  2. Зв’язок із монітором: wait() та notify() працюють безпосередньо з чергою WaitSet конкретного монітора об’єкта. Якби метод належав класу Thread, було б неможливо визначити, який саме монітор потік повинен звільнити при переході в очікування, особливо якщо потік утримує кілька вкладених моніторів одночасно.
  3. Метод wait() звільняє тільки монітор того об’єкта, на якому він викликаний, зберігаючи блокування за всіма іншими вкладеними моніторами!

Запитання 2: Що відбувається з монітором, якщо всередині synchronized-блоку викидається необроблений OutOfMemoryError або потік аварійно завершується?

Відповідь: Монітор гарантовано звільняється. Компілятор javac завжди генерує неявну таблицю обробки винятків (Exception table) з типом перехоплення any, що огортає всю критичну ділянку. Будь-який Throwable призводить до виконання інструкції monitorexit перед вильотом винятку назовні. Жодного зависання монітора в заблокованому стані не станеться. (Виняток — жорстке падіння всієї JVM за сигналом SIGKILL або зависання в нативному JNI-коді).

Запитання 3: Куди саме переміщується потік після виклику notify(): одразу на CPU, в EntryList чи залишається у WaitSet?

Відповідь: Потік не починає виконуватися негайно і навіть не змагається відразу за процесор:

  1. Виклик notify() вибирає один потік із _WaitSet і переносить його або в _EntryList, або в _cxq (залежно від внутрішньої HotSpot-політики Knob_MoveToWaitSet).
  2. Потік переходить зі стану WAITING у стан BLOCKED.
  3. Потік, що викликав notify(), продовжує утримувати монітор до виходу зі свого synchronized-блоку! Лише після виклику monitorexit звільняється блокування, і розбуджений потік отримує можливість поборотися за володіння монітором поряд із новими вхідними потоками.

Запитання 4: Як виклик дефолтного методу hashCode() впливає на продуктивність синхронізації об’єкта?

Відповідь: Виклик дефолтного hashCode() (identity hashcode) на об’єкті, захопленому тонким блокуванням (Lightweight Lock), призводить до негайного примусового роздування (Lock Inflation) блокування до важковагового ObjectMonitor (Heavyweight Lock). Причина в тому, що в тонкому локу Mark Word зайнятий вказівником на Lock Record на стеку. Щоб зберегти обчислений identity hashcode назавжди без ризику перезапису, JVM змушена створити ObjectMonitor і зберегти хеш-код у його полі _header. Це призводить до деградації продуктивності через накладні витрати на створення C++ об’єкта і неможливість повернутися до легковажного локу.


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

❌ “Монітор — це окремий екземпляр класу java.lang.Object в Heap, який створюється при запуску JVM.”
Правильно: монітор — це концептуальний механізм. У HotSpot заголовок будь-якого об’єкта (Mark Word) містить біти стану блокування. Системний об’єкт ObjectMonitor створюється в нативній пам’яті C++ лише при ескалації до важкого блокування (Heavyweight Lock).

❌ “У Java 21 synchronized спочатку перевіряє Biased Locking.”
Правильно: Biased Locking було вимкнено за замовчуванням у Java 15 і повністю видалено з JVM у Java 21 (JEP 416). Ескалація відразу починається з Lightweight Locking.

❌ “Метод wait() звільняє всі блокування, які тримає поточний потік.”
Правильно: wait() звільняє тільки той монітор, на об’єкті якого він був безпосередньо викликаний. Усі інші утримувані потоком монітори залишаються заблокованими, що часто є прихованою причиною важковловимих Deadlock.

❌ “Перевірка умови перед wait() через if допустима, якщо в коді викликається notify(), а не notifyAll().”
Правильно: Перевірка умови ПОВИННА бути виключно в циклі while (condition). Це фундаментальна вимога для захисту від хибних пробуджень (Spurious Wakeups), дозволених специфікацією POSIX threads та Java, а також для захисту від ситуацій, коли інший розбуджений потік встиг змінити стан ресурсу раніше.


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