Що таке reentrant lock
У Java термін використовується у двох контекстах:
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
Reentrant Lock (повторно входжуване блокування) — це механізм синхронізації, який дозволяє потоку, що вже утримує це блокування, повторно входити в захищені ним блоки коду без ризику викликати взаємне блокування самого себе (self-deadlock).
У Java термін використовується у двох контекстах:
- Концептуальна властивість: усі стандартні блокування в Java (як вбудований
synchronized, так і явнийReentrantLock) є реентерабельними. - Конкретний клас
java.util.concurrent.locks.ReentrantLock: гнучка альтернативаsynchronizedіз пакетаjava.util.concurrent, що надає розширені можливості: спробу захоплення з таймаутом (tryLock), переривне захоплення (lockInterruptibly), налаштовувану чесність (Fairness) та множинні умови очікування (Condition).
Кожне повторне захоплення збільшує внутрішній лічильник утримань (hold count), а кожен виклик звільнення зменшує його. Блокування повністю звільняється для інших потоків лише тоді, коли лічильник стає рівним нулю.
Навіщо потрібна реентерабельність?
Без властивості реентерабельності потік заблокував би сам себе при будь-якому вкладеному виклику:
public class SelfDeadlockPrevention {
// Якби synchronized не був реентерабельним:
public synchronized void outer() {
// Потік захопив монітор
inner(); // ПОТІК ЗАВИС БИ НАЗАВЖДИ, намагаючись захопити свій же монітор!
}
public synchronized void inner() {
// Працює безпечно завдяки реентерабельності
}
}
Ідіоматичний приклад використання ReentrantLock у Java 21
import java.util.concurrent.locks.ReentrantLock;
public class BankAccount {
private final ReentrantLock lock = new ReentrantLock();
private int balance = 1000;
public void deposit(int amount) {
lock.lock(); // Захоплення блокування
try {
// Критична секція
balance += amount;
} finally {
// КРИТИЧНО ВАЖЛИВО: звільнення ЗАВЖДИ виконується в блоці finally!
lock.unlock();
}
}
public int getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}
[!WARNING] Виклик
lock.unlock()зобов’язаний розташовуватися в блоціfinally. Якщо в критичній секції виникне виняток, аunlock()не буде викликано, блокування залишиться назавжди зайнятим, паралізуючи всі інші потоки застосунку.
🟡 Middle Level
Внутрішня будова: AbstractQueuedSynchronizer (AQS)
Клас ReentrantLock не керується безпосередньо JVM HotSpot як ключове слово synchronized, а реалізований на рівні стандартної бібліотеки Java на базі AQS (AbstractQueuedSynchronizer).
Ключові елементи архітектури AQS:
volatile int state: статус синхронізатора. УReentrantLockзначення0означає, що блокування вільне. Значення $\ge 1$ відображає глибину рекурсивного захоплення (hold count).Thread exclusiveOwnerThread: посилання на потік, який є поточним власником блокування.- Двозв’язна FIFO-черга (варіант CLH-черги): черга вузлів (
Node), у якій заблоковані потоки очікують своєї черги.
+-------------------------------------------------------+
| ReentrantLock (AQS) |
| exclusiveOwnerThread = Thread-1 |
| state = 2 (повторне захоплення) |
+-------------------------------------------------------+
|
Черга очікуючих потоків (FIFO CLH Queue)
+--------+ prev +--------+ prev +--------+
head | Node 1 | <------------- | Node 2 | <------------- | tail |
| Thread-2 | ------------> | Thread-3 | ------------> | |
+--------+ next +--------+ next +--------+
Чесний (Fair) проти Нечесного (Non-fair) режиму
ReentrantLock підтримує два режими справедливості:
ReentrantLock nonFairLock = new ReentrantLock(); // За замовчуванням: fair = false
ReentrantLock fairLock = new ReentrantLock(true); // Чесний режим: fair = true
1. Нечесний режим (Non-fair, за замовчуванням):
- Реалізує політику Barging (увірватися без черги): коли новий вхідний потік викликає
lock(), він спочатку робить негайну спробу захоплення через CAS (compareAndSetState(0, 1)). - Якщо блокування звільнилося буквально мить тому, новий потік забирає його відразу, оминаючи чергу очікуючих потоків.
- Плюс: у 2–10 разів вища пропускна здатність (throughput), оскільки виключаються затримки на пробудження сплячого потоку з черги (паркувальний оверхед ОС).
- Мінус: ризик голодування потоків (Thread Starvation) у хвості черги при постійному потоці нових конкурентів.
2. Чесний режим (Fair):
- Потік перевіряє метод
hasQueuedPredecessors(): якщо перед ним у черзі AQS уже є очікуючі потоки, він не намагається захопити блокування, а відразу додає себе в хвіст черги й засинає. - Плюс: суворий порядок FIFO, гарантія відсутності голодування.
- Мінус: значна деградація продуктивності через безперервні перемикання контексту ОС.
Розширені можливості ReentrantLock
1. Спроба захоплення без блокування та з таймаутом
// Спроба захоплення без очікування
if (lock.tryLock()) {
try {
processTask();
} finally {
lock.unlock();
}
} else {
// Блокування зайняте: потік не блокується, а виконує альтернативну роботу
handleBusy();
}
// Захоплення з обмеженням за часом (захист від Deadlock)
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
processTask();
} finally {
lock.unlock();
}
}
2. Переривне захоплення (lockInterruptibly)
Потік може перервати очікування блокування за сигналом Thread.interrupt(), викинувши InterruptedException, на відміну від нескінченного блокування в synchronized.
3. Множинні змінні умов (Condition)
На відміну від одного неявного WaitSet у монітора synchronized, один ReentrantLock дозволяє створювати кілька незалежних черг очікування:
public class BlockingBuffer<E> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(E item) throws InterruptedException {
lock.lock();
try {
while (isFull()) {
notFull.await(); // Чекає на умові звільнення місця
}
addItem(item);
notEmpty.signal(); // Будить тільки споживачів!
} finally {
lock.unlock();
}
}
}
🔴 Senior Level
Глибокий аналіз захоплення в AQS: Fast Path проти Slow Path
У вихідному коді OpenJDK захоплення розділене на оптимізований Fast Path та повільний Slow Path:
// Вихідний код NonfairSync:
final void lock() {
// Fast path: атомарне перехоплення вільного блокування
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // Slow path через AQS
}
Slow Path та алгоритм паркування:
- Викликається
tryAcquire(1). Якщо власник — поточний потік, полеstateпросто інкрементується (state + 1). - Якщо власником є інший потік, створюється вузол
Node.EXCLUSIVE, який атомарно через CAS додається в кінець двозв’язного списку (enq()). - Потік виконує короткий спінінг у циклі
acquireQueued, перевіряючи, чи став його вузол головою черги. - Якщо блокування все ще зайняте, вузол попередника позначається прапорцем
waitStatus = Node.SIGNAL(сигналізуючи: «розбуди мене при виході»). - Потік блокується через нативний виклик
LockSupport.park(this), який у ядрі Linux транслюється в системний викликfutex(FUTEX_WAIT).
ReentrantLock та Virtual Threads (Project Loom, Java 21)
Введення віртуальних потоків (Virtual Threads / JEP 444) у Java 21 зробило вибір між synchronized та ReentrantLock ключовим архітектурним рішенням:
| Критерій | synchronized (Java 21) |
ReentrantLock (Java 21) |
|---|---|---|
| Реакція на блокування | Призводить до Pinning (прив’язування) носійного потоку ОС (Carrier Thread) | Розмонтовує (Unmount) віртуальний потік від Carrier Thread |
| Масштабованість | Блокує реальний потік пулу ForkJoinPool |
Звільняє Carrier Thread для виконання інших завдань |
| Рекомендація в Java 21 | Уникати тривалих блокувань та I/O | Рекомендується для всіх блокуючих критичних секцій |
[!NOTE] У Java 21 очікування всередині
synchronized«прив’язує» (pins) віртуальний потік до його системного потоку-носія (Carrier Thread), через що весь Carrier Thread блокується.ReentrantLockвикористовуєLockSupport.park(), який коректно підтримується рантаймом Loom: віртуальний потік паркується в купі, а Carrier Thread негайно перемикається на інші завдання.
Порівняльний аналіз: synchronized проти ReentrantLock
| Характеристика | synchronized |
ReentrantLock |
|---|---|---|
| Природа механізму | Ключове слово мови, керується JVM | Клас стандартної бібліотеки Java (AQS) |
| Звільнення | Автоматичне (гарантовано байт-кодом) | Ручне (ризик витоку без try-finally) |
Таймаути (tryLock) |
❌ Ні (тільки нескінченне очікування) | ✅ Так (tryLock(), tryLock(t, u)) |
| Переривність | ❌ Ні (ігнорує interrupt) |
✅ Так (lockInterruptibly()) |
| Чесність (Fairness) | ❌ Ні | ✅ Так (опціонально) |
| Черги умов | 1 (wait/notify на об’єкті) |
Необмежено (lock.newCondition()) |
| JIT Escape Analysis | ✅ Підтримує Lock Elision / Coarsening | ❌ Складніше для оптимізації JIT C2 |
🎯 Шпаргалка для інтерв’ю
1. 30-секундна квінтесенція (Elevator Pitch)
ReentrantLock — це реентерабельне взаємне блокування з пакета java.util.concurrent.locks, побудоване поверх фреймворку AQS. На відміну від synchronized, воно надає розширений контроль над конкуренцією: неблокуюче захоплення (tryLock), переривне захоплення (lockInterruptibly), вибір між чесною (FIFO) та нечесною (Barging) політикою, а також безліч ізольованих змінних умов (Condition). У Java 21 ReentrantLock критично важливий при роботі з віртуальними потоками (Virtual Threads), оскільки не призводить до pinning носійного потоку ОС.
2. 4 каверзних запитання з глибокими відповідями
Запитання 1: Що станеться, якщо викликати lock.unlock() більшу кількість разів, ніж було викликано lock.lock()? А якщо меншу?
Відповідь:
- Якщо викликати
unlock()зайвий раз (коли лічильник утриманьstate == 0або поточний потік не володіє локом), буде негайно викинуто винятокIllegalMonitorStateException. - Якщо викликати
unlock()меншу кількість разів, ніжlock(), внутрішній лічильник утриманьstateзменшиться, але залишиться суворо більшим за нуль. Блокування не буде звільнено, поточний потік залишиться його єдиним власником, а всі інші потоки зависнуть в очікуванні нескінченно.
Запитання 2: Чому виклик lock.tryLock() без аргументів порушує чесність (Fairness) навіть на екземплярі new ReentrantLock(true)?
Відповідь:
Це задокументована проектна поведінка («barging tryLock»). Метод tryLock() без параметрів завжди використовує нечесну перевірку:
// Реалізація tryLock() у ReentrantLock:
public boolean tryLock() {
return sync.nonfairTryAcquire(1); // ЗАВЖДИ non-fair!
}
Він перевіряє стан state через CAS прямо зараз і захоплює блокування, якщо воно вільне, повністю ігноруючи чергу очікуючих потоків. Якщо вам необхідний суворо чесний tryLock, специфікація рекомендує використовувати виклик із нульовим таймаутом: lock.tryLock(0, TimeUnit.SECONDS).
Запитання 3: У чому фундаментальна перевага Condition перед зв’язкою wait()/notify()?
Відповідь:
З вбудованим монітором synchronized пов’язаний рівно один список очікування (WaitSet). При виклику notifyAll() пробуджуються абсолютно всі очікуючі потоки (і читачі, і письменники), що породжує проблему масової конкуренції (Thundering Herd Problem).
ReentrantLock дозволяє створити кілька незалежних умов Condition (наприклад, notEmpty та notFull). Це дозволяє цілеспрямовано розбудити рівно один потрібний потік відповідної ролі викликом signal(), кардинально знижуючи перемикання контексту процесора.
Запитання 4: Чому в Java 21 при використанні Virtual Threads наполегливо рекомендується замінювати synchronized на ReentrantLock?
Відповідь:
У Java 21 віртуальний потік, заблокований усередині блоку synchronized або виконуючий wait(), прив’язується (pins) до свого базового платформеного потоку-носія (Carrier Thread). У результаті потік операційної системи не може виконувати інші завдання і блокується цілком, зводячи нанівець переваги віртуальних потоків. ReentrantLock використовує механізми AQS та LockSupport.park(), які підтримують віртуальні потоки «з коробки»: віртуальний потік розмонтовується (unmount), зберігає стек у купі, а Carrier Thread продовжує обслуговувати інші віртуальні потоки.
3. Типові помилки та Red Flags
❌ “Захоплення lock.lock() усередині блоку try.”
Правильно: Код вигляду try { lock.lock(); ... } finally { lock.unlock(); } є грубою помилкою. Якщо при захопленні блокування вилетить Error або потік буде перервано, виконається блок finally і викличе unlock() на незахопленому локу, породивши IllegalMonitorStateException і замаскувавши початкову проблему. Виклик lock.lock() повинен іти суворо ДО блоку try.
❌ “Використання ReentrantLock(true) всюди для надійності.”
Правильно: Чесний режим знижує пропускну здатність у рази через постійне паркування потоків і примусове відключення Barging. Чесний режим виправданий лише у рідкісних випадках, коли доведена проблема критичного голодування потоків.
❌ “Забутий finally блок.”
Правильно: На відміну від synchronized, де компілятор гарантує виклик monitorexit таблицею винятків, у ReentrantLock розробник несе 100% відповідальність за ручне звільнення.