Як працює synchronized на рівні монітора?
synchronized — це ключове слово в Java, яке забезпечує потокобезпечність шляхом блокування. Тільки один потік може виконувати код всередині synchronized блока одночасно.
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 | Тільки один потік |
Фактори, що впливають на продуктивність
- Contention Level — скільки потоків конкурують
- Lock Hold Time — як довго тримається блокування
- Lock Frequency — як часто захоплюється
- 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
- Мінімізуйте synchronized блоки — тільки критична секція
- Використовуйте private final Object lock — інкапсуляція
- Уникайте synchronized на рівні класу —
synchronized(ClassName.class) - Розгляньте Lock Coarsening — не дробіть блокування без потреби
- Для Highload — використовуйте ConcurrentHashMap, LongAdder, Atomic*
- Моніторьте contention через JFR — зростаючий час = проблема
- Для 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 захоплення