Что такое 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 vs 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% ответственность за ручное освобождение.