Питання 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