Вопрос 7 · Раздел 9

Что такое reentrant lock?

Представьте дверь с кодовым замком:

Версии по языкам: English Russian Ukrainian

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 НЕ нужен

  1. Простая синхронизация — одного synchronized достаточно, код проще и читаемее
  2. Нет конкуренции — synchronized оптимизируется JVM (thin lock, elision)
  3. Не нужны фичи 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

  1. Всегда используйте try/finally:
    lock.lock();
    try {
        // code
    } finally {
        lock.unlock(); // ОБЯЗАТЕЛЬНО иначе вечная блокировка
    }
    
  2. Предпочитайте synchronized — если не нужны расширенные функции ReentrantLock

  3. Избегайте fairness — если нет строгой необходимости (производительность!)

  4. Используйте tryLock(timeout) — для избежания deadlock

  5. Несколько Condition — для гранулярного управления wait/notify

  6. Мониторьте 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