Вопрос 5 · Раздел 9

Как работает synchronized на уровне монитора?

synchronized — это ключевое слово в Java, которое обеспечивает потокобезопасность путём блокировки. Только один поток может выполнять код внутри synchronized блока одновременно.

Версии по языкам: English Russian Ukrainian

Junior уровень

Базовое понимание

synchronized — это ключевое слово в Java, которое обеспечивает потокобезопасность путём блокировки. Только один поток может выполнять код внутри synchronized блока одновременно.

Механизм: только один поток может войти в synchronized-блок одновременно. Остальные ждут (паркуется через ОС). Когда первый поток выходит — один из ждущих получает доступ.

Два способа использования

1. synchronized метод

public class Counter {
    private int count = 0;

    // Блокирует весь метод на объекте 'this'
    public synchronized void increment() {
        count++;
    }
}

2. synchronized блок

public class Counter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized(lock) { // Блокирует только этот блок
            count++;
        }
    }
}

Что блокируется?

Тип Объект блокировки
synchronized метод экземпляра this (текущий объект)
static synchronized метод ClassName.class (объект класса)
synchronized(this) блок this (текущий объект)
synchronized(lock) блок Объект lock

Простой пример: Bank Account

public class BankAccount {
    private double balance = 0;

    public synchronized void deposit(double amount) {
        balance += amount; // Только один поток одновременно
    }

    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }

    public synchronized double getBalance() {
        return balance;
    }
}

wait() и notify()

public class ProducerConsumer {
    private final List<Integer> queue = new ArrayList<>();
    private final int MAX_SIZE = 10;

    public synchronized void produce(int value) throws InterruptedException {
        while (queue.size() == MAX_SIZE) {
            wait(); // Ждём, пока появится место
        }
        queue.add(value);
        notifyAll(); // Уведомляем потребителей
    }

    public synchronized int consume() throws InterruptedException {
        while (queue.isEmpty()) {
            wait(); // Ждём, пока появятся данные
        }
        int value = queue.remove(0);
        notifyAll(); // Уведомляем производителей
        return value;
    }
}

Middle уровень

Эволюция состояний блокировки

JVM (HotSpot) не всегда использует тяжёлые блокировки. Существует 4 состояния:

1. No Lock (без блокировки)

Объект не заблокирован. Mark Word (заголовок объекта) содержит:

  • Identity hashcode
  • Age (для GC)

2. Biased Locking (смещённая блокировка) — УДАЛЕНО в Java 21

Если объект захватывается только одним потоком, JVM запоминает этот поток:

Mark Word: [Thread ID: 54 бита] [Epoch: 2] [Age: 4] [Bias: 1] [01]
  • Поток входит в synchronized без CAS-операций
  • Просто проверяет: “это мой ID в заголовке?” → да → входит
  • Проблема: при появлении второго потока нужно отменять (revoke) блокировку — дорого

3. Thin Lock (лёгкая блокировка)

При появлении конкуренции:

Mark Word: [Thread ID: 54 бита] [Lock Record: 6 бит] [00]
  • Используется CAS (Compare-And-Swap) для захвата

    CAS (Compare-And-Swap) — атомарная операция: «запиши новое, только если текущее = ожидаемое». Используется для thin lock захвата без блокировки ОС. Подробности — в файле [[9. Что такое CAS (Compare-And-Swap)]].

  • Поток не засыпает — делает спиннинг (крутится в цикле, ожидая)

    Спиннинг (spin-waiting) — поток крутится в пустом цикле, проверяя может ли захватить блокировку. Быстрее парковки (не передаёт управление ОС), но тратит CPU.

  • Быстро при низкой конкуренции

4. Fat Lock (тяжёлая блокировка)

При высокой конкуренции:

Mark Word: [Object Monitor Pointer: 62 бита] [10]
  • Создаётся ObjectMonitor в нативной памяти
  • Поток паркуется через ОС (futex в Linux)

    Паркировка потока — поток «засыпает» и не тратит CPU, пока ОС не разбудит его (когда блокировка освободится). Дешевле спиннинга при долгом ожидании, но дороже при коротком (context switch).

  • Переходит в состояние BLOCKED
  • Дорого (~1000+ ns из-за context switch)

Алгоритм захвата блокировки

Поток пытается войти в synchronized
         │
         ▼
    Это Biased Lock?
    │ да          │ нет
    ▼             ▼
Проверяем     CAS-попытка
Thread ID     захвата
    │             │
    ▼             ▼
Мой ID?      Успех?
│ да  │ нет   │ да   │ нет
▼     ▼       ▼      ▼
Вхожу  Revocation  Вхожу  Spin?
              │           │
              ▼           ▼
         Thin Lock   Успех спина?
                     │ да   │ нет
                     ▼      ▼
                   Вхожу   Паркуемся (Fat Lock)

Байт-код: monitorenter / monitorexit

synchronized(obj) {
    // критическая секция
}

Превращается в байт-код:

0: aload_1          // Загружаем obj
1: dup              // Дублируем для astore
2: astore_2         // Сохраняем для monitorexit
3: monitorenter     // === ЗАХВАТ МОНИТОРА ===
4: ...              // Критическая секция
N: aload_2
N+1: monitorexit    // === ОСВОБОЖДЕНИЕ (нормальный выход) ===
N+2: goto end
N+3: astore_3       // Exception handler
N+4: aload_2
N+5: monitorexit    // === ОСВОБОЖДЕНИЕ (при исключении) ===
N+6: aload_3
N+7: athrow
end: return

Важно: Для synchronized методов байт-код НЕ содержит monitorenter/monitorexit. Вместо этого в заголовке метода стоит флаг ACC_SYNCHRONIZED, который JVM обрабатывает автоматически.

Адаптивный спиннинг (Adaptive Spinning)

HotSpot JVM не просто крутится в цикле. Она запоминает историю:

  • Если в прошлый раз спиннинг на этом мониторе удался → крутится дольше
  • Если нет → сразу паркует поток

Это радикально улучшает производительность при кратковременных блокировках.


Senior уровень

Under the Hood: Реализация в HotSpot

ObjectMonitor структура (C++)

// Упрощённо из OpenJDK
class ObjectMonitor {
public:
    void* _owner;              // Поток-владелец (Thread*)
    int _Recursions;           // Счётчик реентерабельности
    ObjectWaiter* _EntryList;  // Очередь BLOCKED потоков
    ObjectWaiter* _WaitSet;    // Очередь WAITING потоков
    jint _WaitSetLock;         // Защита WaitSet
    int _contentions;          // Счётчик конкуренции
    // ...
};

Lock Record на стеке потока

При Thin Locking JVM создаёт Lock Record на стеке:

Stack Frame:
┌──────────────────────────┐
│  Локальные переменные    │
├──────────────────────────┤
│  Lock Record:            │
│    - Displaced Mark Word │  ← Копия оригинального Mark Word
│    - Owner reference     │  ← Ссылка на объект
├──────────────────────────┤
│  Frame pointer           │
├──────────────────────────┤
│  Return address          │
└──────────────────────────┘

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

Lock Coarsening (Укрупнение блокировки)

// Исходный код:
synchronized(lock) { sb.append("a"); }
synchronized(lock) { sb.append("b"); }
synchronized(lock) { sb.append("c"); }

// JIT объединяет в одну блокировку:
synchronized(lock) {
    sb.append("a");
    sb.append("b");
    sb.append("c");
}

Это уменьшает оверхед на захват/освобождение монитора.

Lock Elision (Удаление блокировки через Escape Analysis)

public String buildString() {
    Object lock = new Object(); // Объект не выходит за пределы метода
    synchronized(lock) {
        // JIT понимает: никто другой не может получить lock
        // → полностью удаляет блокировку из машинного кода!
        return "result";
    }
}

Escape Analysis определяет:

  • Объект не возвращается из метода
  • Объект не передаётся в другие методы
  • Объект не сохраняется в статические поля

Производительность и Highload

Бенчмарк (примерный)

Сценарий Время Примечание
No lock ~1 ns Базовая операция
Uncontended synchronized ~10-20 ns Thin lock, без конкуренции
Contended synchronized ~1000-5000 ns (зависит от JVM, CPU, ОС, нагрузки — ориентируйтесь на порядок величин, не на точные числа) Fat lock + context switch
Biased locking (deprecated) ~5 ns Только один поток

Факторы, влияющие на производительность

  1. Contention Level — сколько потоков конкурируют
  2. Lock Hold Time — как долго держится блокировка
  3. Lock Frequency — как часто захватывается
  4. Number of Locks — количество разных блокировок

Рекомендации для Highload

// ПЛОХО: Глобальная блокировка
private static final Object globalLock = new Object();
public void process(Request r) {
    synchronized(globalLock) { // Все запросы последовательно!
        handle(r);
    }
}

// ХОРОШО: Сегментированные блокировки
private final Object[] locks = new Object[16];
public void process(Request r) {
    int segment = r.userId() % 16;
    synchronized(locks[segment]) { // Параллелизм по сегментам
        handle(r);
    }
}

// ЛУЧШЕ: Lock-free
private final ConcurrentHashMap<Integer, Data> map = new ConcurrentHashMap<>();
public void process(Request r) {
    map.compute(r.userId(), (k, v) -> handle(r, v)); // Lock-free
}

Диагностика

Java Flight Recorder (JFR)

java -XX:StartFlightRecording=filename=recording.jfr MyApp

События:

  • jdk.JavaMonitorEnter — время ожидания блокировки
  • jdk.ThreadPark — время в park()

-XX:+PrintFlagsFinal

java -XX:+PrintFlagsFinal -version | grep -i lock

Ключевые флаги:

  • UseBiasedLocking (удалено в Java 21)
  • PreBlockSpin / SpinBackoffMultiplier

jstack

jstack <pid>

Ищите:

"thread-1" #10 BLOCKED (on object monitor)
  - waiting to lock <0x000000076af0c8d0>
  - locked <0x000000076af0c8e0>

Best Practices

  1. Минимизируйте synchronized блоки — только критическая секция
  2. Используйте private final Object lock — инкапсуляция
  3. Избегайте synchronized на уровне классаsynchronized(ClassName.class)
  4. Рассмотрите Lock Coarsening — не дробите блокировки без нужды
  5. Для Highload — используйте ConcurrentHashMap, LongAdder, Atomic*
  6. Мониторьте contention через JFR — растущее время = проблема
  7. Для Java < 15: отключайте biased locking в высоконагруженных системах (-XX:-UseBiasedLocking). Для Java 15+ он уже отключён по умолчанию.

🎯 Шпаргалка для интервью

Обязательно знать:

  • 4 состояния блокировки: No Lock → Biased (удалён в Java 21) → Thin Lock (CAS + спиннинг) → Fat Lock (ObjectMonitor)
  • Байт-код synchronized блока: monitorenter/monitorexit; synchronized метода: флаг ACC_SYNCHRONIZED
  • Два monitorexit генерируются — для нормального выхода и для exception (скрытый finally)
  • Адаптивный спиннинг: JVM запоминает историю — если спиннинг удался, крутится дольше
  • Lock Coarsening: JIT объединяет мелкие блокировки на одном объекте в одну большую
  • Lock Elision: JIT удаляет блокировку, если Escape Analysis показывает, что объект не «убегает»
  • Fat Lock = context switch через ОС (futex в Linux), ~1000-5000 ns

Частые уточняющие вопросы:

  • Почему synchronized метод НЕ содержит monitorenter? — JVM обрабатывает флаг ACC_SYNCHRONIZED автоматически при вызове метода
  • Что такое Lock Record? — Структура на стеке потока с копией Mark Word; используется при Thin Lock без создания ObjectMonitor
  • Как JVM решает, парковать поток или спиннить? — Adaptive Spinning: смотрит историю успешных спинов на этом мониторе
  • Когда JIT удалит synchronized? — Когда Escape Analysis покажет NoEscape: объект не возвращается, не передаётся, не сохраняется

Красные флаги (НЕ говорить):

  • “Synchronized всегда создаёт ObjectMonitor” — нет, только при высокой конкуренции (Fat Lock)
  • “Biased Locking включён в современных Java” — нет, удалён в Java 21
  • “Monitorexit один на synchronized блок” — нет, два: для нормального выхода и для exception
  • “Lock Elision работает для synchronized(this)” — редко, так как this почти всегда «убегает»

Связанные темы:

  • [[4. Что такое монитор (monitor) в Java]] — структура монитора и ObjectMonitor
  • [[6. В чём разница между synchronized методом и synchronized блоком]] — ACC_SYNCHRONIZED vs monitorenter
  • [[7. Что такое reentrant lock]] — реентерабельность монитора
  • [[9. Что такое CAS (Compare-And-Swap)]] — CAS используется для Thin Lock захвата