Что такое 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