Питання 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 генеруються — для нормального виходу та для виключення (прихований 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 блок” — ні, два: для нормального виходу та для виключення
  • “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 захоплення