Що таке монітор (monitor) в Java
Кожен Java-об'єкт «з коробки» володіє власним вбудованим монітором (intrinsic lock або monitor lock). Монітор вирішує два ключові завдання:
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
Монітор (Monitor) у Java — це високорівневий механізм синхронізації та взаємного виключення (mutual exclusion), запропонований Ч. Гоаром і П. Брінчем Гансеном та вбудований у платформу Java на рівні мови та віртуальної машини JVM.
Кожен Java-об’єкт «з коробки» володіє власним вбудованим монітором (intrinsic lock або monitor lock). Монітор вирішує два ключові завдання:
- Взаємне виключення (Mutual Exclusion): гарантує, що в будь-який момент часу лише один потік може виконувати критичну секцію коду, захищену цим монітором (ключове слово
synchronized). - Координація та сигналізація (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), що складається з:
- Klass Word: вказівник на метадані класу в Metaspace (4 байти при увімкненому
UseCompressedClassPointers, інакше 8 байтів). - 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:
- CAS завершується невдачею.
- Потік виконує Adaptive Spinning (адаптивне обертання): крутиться в короткому циклі процесора, розраховуючи, що потік, який утримує лок, швидко його звільнить.
- Якщо за час спінінгу лок не звільнився, відбувається Lock Inflation:
- JVM виділяє структуру
ObjectMonitor. - У поле
_headerструктуриObjectMonitorпоміщається Displaced Mark Word. - У
Mark Wordоб’єкта атомарно прописується вказівник наObjectMonitorі встановлюються біти10. - Конкуруючий потік поміщає себе в стек
_cxqі засинає (паркується) через системний виклик ядра ОС (futexу Linux абоWaitForSingleObjectу Windows). - Потік переходить у статус
Thread.State.BLOCKED.
- JVM виділяє структуру
Дефляція моніторів (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-компілятор аналізує використання моніторів і застосовує потужні оптимізації:
- 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(); } - 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?
Відповідь:
- Принцип інкапсуляції та адресації: потоки чекають настання певного стану даних (умови), а не іншого потоку. Дані інкапсульовані всередині Java-об’єктів.
- Зв’язок із монітором:
wait()таnotify()працюють безпосередньо з чергоюWaitSetконкретного монітора об’єкта. Якби метод належав класуThread, було б неможливо визначити, який саме монітор потік повинен звільнити при переході в очікування, особливо якщо потік утримує кілька вкладених моніторів одночасно. - Метод
wait()звільняє тільки монітор того об’єкта, на якому він викликаний, зберігаючи блокування за всіма іншими вкладеними моніторами!
Запитання 2: Що відбувається з монітором, якщо всередині synchronized-блоку викидається необроблений OutOfMemoryError або потік аварійно завершується?
Відповідь:
Монітор гарантовано звільняється. Компілятор javac завжди генерує неявну таблицю обробки винятків (Exception table) з типом перехоплення any, що огортає всю критичну ділянку. Будь-який Throwable призводить до виконання інструкції monitorexit перед вильотом винятку назовні. Жодного зависання монітора в заблокованому стані не станеться. (Виняток — жорстке падіння всієї JVM за сигналом SIGKILL або зависання в нативному JNI-коді).
Запитання 3: Куди саме переміщується потік після виклику notify(): одразу на CPU, в EntryList чи залишається у WaitSet?
Відповідь: Потік не починає виконуватися негайно і навіть не змагається відразу за процесор:
- Виклик
notify()вибирає один потік із_WaitSetі переносить його або в_EntryList, або в_cxq(залежно від внутрішньої HotSpot-політикиKnob_MoveToWaitSet). - Потік переходить зі стану
WAITINGу станBLOCKED. - Потік, що викликав
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, а також для захисту від ситуацій, коли інший розбуджений потік встиг змінити стан ресурсу раніше.