🧵 Раздел 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. Связанные вопросы