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