Що таке reentrant lock?
Уявіть двері з кодовим замком:
Junior рівень
Базове розуміння
Reentrant Lock (повторно вхідне блокування) — це механізм, який дозволяє потоку, що вже володіє блокуванням, повторно захопити його без самоблокування.
Чому це важливо: без reentrancy рекурсивний виклик synchronized-методу призвів би до deadlock — потік чекав би сам себе. З reentrancy JVM рахує скільки разів потік захопив блокування (лічильник) і звільняє тільки коли лічильник = 0.
Проста аналогія
Уявіть двері з кодовим замком:
- Звичайний lock: навіть якщо ви вже всередині, щоб увійти знову — потрібно чекати
- Reentrant lock: якщо ви вже всередині, можете виходити та заходити скільки завгодно разів
Приклад в Java
synchronized (реентерабельний за замовчуванням)
public class ReentrantDemo {
public synchronized void methodA() {
System.out.println("Method A");
methodB(); // Викликаємо інший synchronized метод — НЕ блокується!
}
public synchronized void methodB() {
System.out.println("Method B");
}
}
ReentrantLock
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void methodA() {
lock.lock();
try {
System.out.println("Method A");
methodB(); // Той самий потік може захопити lock повторно
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
System.out.println("Method B");
} finally {
lock.unlock();
}
}
}
Як працює лічильник реентерабельності
Потік 1: lock.lock() → лічильник = 1, входить
Потік 1: lock.lock() → лічильник = 2, входить (той самий потік!)
Потік 1: lock.unlock() → лічильник = 1, все ще власник
Потік 1: lock.unlock() → лічильник = 0, звільняє
Потік 2: lock.lock() → лічильник = 1, тепер Потік 2 власник
Коли використовувати ReentrantLock замість synchronized
| Ситуація | synchronized | ReentrantLock |
|---|---|---|
| Проста синхронізація | Так | Надмірно |
| Таймаут очікування | Ні | Так (tryLock(timeout)) |
| Перериване очікування | Ні | Так (lockInterruptibly()) |
| Кілька умов (Condition) | Ні (тільки wait/notify) | Так (кілька Condition) |
| Fairness (чесна черга) | Ні | Так |
Middle рівень
Внутрішній устрій (AQS)
ReentrantLock реалізований на базі AbstractQueuedSynchronizer (AQS) — фреймворку з java.util.concurrent:
AQS — абстрактний клас-фреймворк з java.util.concurrent, на якому побудовані ВСІ concurrent утиліти Java: ReentrantLock, Semaphore, CountDownLatch, ReentrantReadWriteLock. Якщо ви зрозумієте AQS — ви зрозумієте всю багатопоточність Java.
// Спрощена структура AQS
abstract class AbstractQueuedSynchronizer {
private volatile int state; // Лічильник блокування (реентерабельність)
private transient Thread exclusiveOwnerThread; // Потік-власник
// Черга очікування (двунаправлений linked list)
static final class Node {
volatile Thread thread;
volatile int waitStatus;
Node prev, next;
}
private transient volatile Node head;
private transient volatile Node tail;
}
Алгоритм захоплення
// Псевдокод lock()
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// Блокування вільне — намагаємося захопити
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
// Той самий потік — реентерабельність
int nextc = c + acquires;
if (nextc < 0) throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
Fairness (Чесність)
// Нечесний режим (за замовчуванням) — швидше
ReentrantLock nonFair = new ReentrantLock(); // fair = false
// Non-fair (default) — дозволяє «barge» (ворватися): якщо блокування щойно звільнилося і новий потік намагається його захопити, він може отримати його одразу, не встаючи в чергу. Це уникає context switch (паркування/пробудження) і дає вищий throughput.
// Чесний режим — строго FIFO
ReentrantLock fair = new ReentrantLock(true); // fair = true
| Режим | Поведінка | Продуктивність |
|---|---|---|
| Non-fair (default) | Потік може «ворватися» та захопити лок, навіть якщо в черзі є ті, що чекають | Вище throughput |
| Fair | Потоки отримують доступ строго в порядку черги (FIFO) | Нижче throughput, але немає starvation |
Розширені можливості ReentrantLock
1. tryLock() — спроба без очікування
if (lock.tryLock()) {
try {
// Вдалося захопити — виконуємо
} finally {
lock.unlock();
}
} else {
// Не вдалося — робимо щось інше
System.out.println("Lock зайнятий, пропускаємо");
}
2. tryLock(timeout) — очікування з таймаутом
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// Виконуємо
} finally {
lock.unlock();
}
} else {
// Таймаут — не вдалося захопити за 100ms
}
3. lockInterruptibly() — перериване очікування
try {
lock.lockInterruptibly();
try {
// Виконуємо
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
// Потік був перерваний під час очікування
Thread.currentThread().interrupt();
}
Condition — заміна wait/notify
ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();
public void produce(int item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == MAX_SIZE) {
// while (не готово) — ОБОВ'ЯЗКОВИЙ! Spurious wakeups:
// потік може прокинутися БЕЗ signal(). while повторно перевірить умову.
// Якщо використовувати if — потік продовжить роботу без реального сигналу.
notFull.await(); // Чекаємо, звільняється місце
}
queue.add(item);
notEmpty.signal(); // Сповіщаємо споживачів
} finally {
lock.unlock();
}
}
public int consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
// while (не готово) — ОБОВ'ЯЗКОВИЙ! Spurious wakeups:
// потік може прокинутися БЕЗ signal(). while повторно перевірить умову.
// Якщо використовувати if — потік продовжить роботу без реального сигналу.
notEmpty.await(); // Чекаємо, з'являються дані
}
int item = queue.remove(0);
notFull.signal(); // Сповіщаємо виробників
return item;
} finally {
lock.unlock();
}
}
Перевага перед wait/notify: можна мати різні Condition для різних подій.
Коли ReentrantLock НЕ потрібен
- Проста синхронізація — одного synchronized достатньо, код простіший та читабельніший
- Немає конкуренції — synchronized оптимізується JVM (thin lock, elision)
- Не потрібні фічі Lock — tryLock, fairness, Condition не потрібні
Головна відмінність: synchronized — мовне ключове слово (JVM-рівень), ReentrantLock — бібліотечний клас. Це визначає всі інші відмінності.
Senior рівень
Under the Hood: NonfairSync та FairSync
ReentrantLock використовує два внутрішніх класи:
public class ReentrantLock implements Lock {
private final Sync sync;
public ReentrantLock() {
sync = new NonfairSync(); // За замовчуванням
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
}
NonfairSync (нечесний)
static final class NonfairSync extends Sync {
protected final boolean tryAcquire(int acquires) {
// Потік може захопити лок, навіть якщо в черзі є інші
// Це називається "barge" — «ворватися»
// «barge» (ворватися) — новий потік намагається захопити блокування, не встаючи в чергу очікування. Якщо пощастило — блокування вільне, потік отримує його миттєво без паркування.
return nonfairTryAcquire(acquires);
}
}
FairSync (чесний)
static final class FairSync extends Sync {
protected final boolean tryAcquire(int acquires) {
// Спочатку перевіряємо: чи є ті, що чекають у черзі?
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
}
Продуктивність: Fair vs Non-fair
Non-fair: throughput ~100K ops/sec
Fair: throughput ~50K ops/sec (на 50% повільніше; приблизні значення, залежать від CPU та JVM)
Чому fair повільніший:
- Потрібно перевіряти чергу щоразу
- Потоки паркуються/бужуться частіше (context switching)
- Немає оптимізації “barge”
Коли використовувати fair:
- Коли важливо уникнути starvation
- Коли порядок обробки критичний
AQS: Як працює черга очікування
┌─────────────────────────────────────┐
│ AQS Queue │
└─────────────────────────────────────┘
head ←→ [Node T1] ←→ [Node T2] ←→ [Node T3] ←→ tail
(WAITING) (WAITING) (WAITING)
Коли T1 отримує сигнал:
head ←→ [Node T2] ←→ [Node T3] ←→ tail
(ACTIVE) (WAITING)
ReentrantLock vs synchronized в Java 8+
| Характеристика | synchronized | ReentrantLock |
|---|---|---|
| Продуктивність (uncontended) | ~10-20 ns | ~15-25 ns |
| Продуктивність (contended) | ~1000+ ns | ~500-1000 ns |
| Реентерабельність | Так (автоматично) | Так (лічильник state) |
| Fairness | Ні | Так (опціонально) |
| tryLock | Ні | Так |
| lockInterruptibly | Ні | Так |
| Мultiple Conditions | Ні (один wait set) | Так |
| Читабельність | Вище | Нижче (потрібен try/finally) |
Висновок: В Java 8+ продуктивність synchronized сильно оптимізована. Використовуйте ReentrantLock тільки заради розширених функцій (таймаути, fairness, кілька умов).
Діагностика
jstack
jstack <pid>
"worker-1" #10 RUNNABLE
locked <0x000000076af0c8d0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
"worker-2" #11 WAITING
parking to wait for <0x000000076af0c8d0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
Програмна діагностика
ReentrantLock lock = new ReentrantLock();
// Перевірка: скільки разів поточний потік захопив блокування
int holdCount = lock.getHoldCount();
// Кількість потоків в черзі
int queueLength = lock.getQueueLength();
// Чи є ті, що чекають?
boolean hasQueued = lock.hasQueuedThreads();
// Який потік володіє?
Thread owner = lock.getOwner();
Best Practices
- Завжди використовуйте try/finally:
lock.lock(); try { // code } finally { lock.unlock(); // ОБОВ'ЯЗКОВО інакше вічне блокування } -
Віддавайте перевагу synchronized — якщо не потрібні розширені функції ReentrantLock
-
Уникайте fairness — якщо немає строгої необхідності (продуктивність!)
-
Використовуйте tryLock(timeout) — для уникнення deadlock
-
Кілька Condition — для гранулярного керування wait/notify
- Моніторьте queueLength — зростаюча черга = contention проблема
🎯 Шпаргалка для співбесіди
Обов’язково знати:
- Reentrancy = потік, що володіє блокуванням, може захопити його повторно без deadlock (лічильник increment/decrement)
- synchronized реентерабельний автоматично; ReentrantLock — бібліотечна альтернатива на базі AQS
- AQS (AbstractQueuedSynchronizer): volatile state (лічильник) + черга очікування (двунаправлений linked list)
- Non-fair (default) дозволяє «barge» — ворватися без черги; Fair — строгий FIFO
- Fair mode ~50% повільніший за non-fair через перевірки черги та часті context switch
- ReentrantLock дає: tryLock(), tryLock(timeout), lockInterruptibly(), кілька Condition
- Condition — заміна wait/notify з перевагою: можна мати різні умови (notEmpty, notFull)
Часті уточнюючі запитання:
- Чому non-fair швидший за fair? — Новий потік може захопити вільне блокування без паркування, уникаючи context switch
- Що таке «barge»? — Коли потік намагається захопити блокування, не встаючи в чергу; якщо пощастило — отримує його миттєво
- Навіщо потрібен try/finally з ReentrantLock? — Якщо exception до unlock(), блокування залишиться захоплена назавжди → deadlock
- Коли обирати fair режим? — Коли важливий порядок обробки і потрібно уникнути starvation (рідко)
Червоні прапори (НЕ говорити):
- “ReentrantLock завжди швидший за synchronized” — ні, в Java 8+ synchronized сильно оптимізований
- “Fairness потрібен завжди для коректності” — ні, тільки коли starvation критичний
- “Condition — це те саме, що wait/notify” — ні, Condition дозволяє мати кілька wait sets
- “AQS — це просто лічильник” — ні, це повноцінний фреймворк з чергою очікування та CAS-операціями
Пов’язані теми:
- [[4. Що таке монітор (monitor) в Java]] — вбудований монітор vs ReentrantLock
- [[5. Як працює synchronized на рівні монітора]] — ескалація блокувань vs AQS
- [[8. Що таке Atomic класи]] — AQS використовує CAS для захоплення state
- [[9. Що таке CAS (Compare-And-Swap)]] — CAS — основа AQS