W3docs

Java ReentrantLock

Используйте ReentrantLock для гибкой блокировки в Java — tryLock, lockInterruptibly, политика справедливости и диагностический API.

ReentrantLock — стандартная реализация интерфейса Lock. «Реентрантный» означает, что один и тот же поток может захватывать блокировку несколько раз, не блокируя сам себя — то же свойство есть и у synchronized. Всё, что описано в главе о LocktryLock, lockInterruptibly, Condition — реализовано именно в этом классе.

Данная глава — подробное погружение: два конструктора, параметр справедливости, диагностические методы, связка с Condition для паттерна производитель/потребитель, и конкретные случаи, когда ReentrantLock оправдывает дополнительный шаблон try/finally по сравнению с обычным synchronized.

Что рассматривается на этой странице

Два конструктора

Lock lock = new ReentrantLock();        // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true);    // fair — FIFO wait queue

Несправедливый (по умолчанию) режим означает, что когда блокировка освобождается, её получает тот поток, которому планировщик даст управление первым. Новые потоки также могут «вклиниться» — захватить блокировку без ожидания в очереди, если в момент вызова она свободна. Это быстро: нет манипуляций с очередью, нет подсказок планировщику. Недостаток — возможность голодания: поток может долго стоять в очереди, пока вклинивающиеся постоянно перехватывают блокировку.

Справедливый режим означает, что блокировка предоставляется потоку, ожидающему дольше всех. Очередь ожидания — настоящий FIFO. Это исключает голодание. Цена: заметно меньшая пропускная способность, поскольку каждый захват требует решения планировщика и JVM не может использовать быстрые пути.

По умолчанию правильный выбор — несправедливый режим. Справедливый используйте только тогда, когда вы обнаружили реальную проблему голодания (обычно это выявляется запросом getWaitQueueLength, значение которого постоянно растёт) или когда корректность приложения зависит от порядка обработки.

Реентрантность и getHoldCount

Как и synchronized, ReentrantLock позволяет потоку, уже владеющему блокировкой, захватить её повторно:

ReentrantLock lock = new ReentrantLock();
lock.lock();
lock.lock();                          // same thread re-enters — fine
try {
  doStuff();
} finally {
  lock.unlock();
  lock.unlock();                      // must unlock as many times as locked
}

Каждый вызов lock() увеличивает внутренний счётчик захватов; каждый unlock() уменьшает его. Блокировка фактически освобождается — становится видимой другим потокам — только когда счётчик достигает нуля. Диагностический вызов:

int n = lock.getHoldCount();          // how many times THIS thread has acquired without unlocking

getHoldCount полезен для утверждений («этот метод должен вызываться при удержании блокировки») и проверки инвариантов в тестах.

Правило симметрии unlock строгое. Если вы вызвали lock дважды, а unlock — один раз, блокировка остаётся удержанной — тихая утечка. Если вы вызовете unlock больше раз, чем lock, немедленно будет выброшено IllegalMonitorStateException. По возможности всегда объединяйте захват и освобождение в одном методе; распределяя их по разным методам, вы быстро делаете учёт хрупким.

Другие диагностические методы

ReentrantLock предоставляет значительно больше возможностей для интроспекции, чем synchronized:

lock.isLocked();                       // is anybody holding it?
lock.isHeldByCurrentThread();          // do I hold it?
lock.getQueueLength();                 // how many threads are waiting?
lock.hasQueuedThreads();               // are any waiting?
lock.hasQueuedThread(t);               // is thread t waiting?
lock.getHoldCount();                   // how many times have I re-entered?

Эти методы предназначены главным образом для мониторинга и тестирования — производственная логика не должна зависеть от ответа на вопрос «кто-нибудь ожидает?», поскольку к моменту проверки ответ может уже устареть. Однако для метрик «доля конкурентных захватов» — это полезный сигнал о неверной гранулярности блокировки.

Когда ReentrantLock лучше synchronized

Четыре причины выбрать ReentrantLock:

  1. Захват с соблюдением дедлайна. Вам нужно быстро отказаться или отступить, если блокировка недоступна в течение ограниченного времени. tryLock(timeout) это обеспечивает; synchronized — нет.
  2. Отмена. Вам нужно прервать поток, ожидающий блокировку. lockInterruptibly это делает; synchronized игнорирует прерывания при входе в монитор.
  3. Несколько переменных условия. Вам нужно отдельно сигнализировать разным группам ожидающих потоков. Lock.newCondition() это позволяет; у встроенного монитора ровно одно множество ожидающих.
  4. Справедливость. Вам нужен порядок FIFO для ожидающих. new ReentrantLock(true) — единственный встроенный способ.

Всё, что не подпадает под эти четыре случая — чистый код без особых требований — должно оставаться на synchronized. Оптимизации JVM для встроенных мониторов реальны, а дисциплина try/finally, которой требует Lock, — то, о чём легко забыть. Не тянитесь к Lock только потому, что он «современнее».

Связка с Condition, подробно

Паттерн производитель/потребитель с ReentrantLock и двумя условиями — классический пример. Приведён здесь в автономной форме, поскольку именно такую структуру использует ArrayBlockingQueue внутри:

class BoundedBuffer<T> {
  private final ReentrantLock lock = new ReentrantLock();
  private final Condition notFull  = lock.newCondition();
  private final Condition notEmpty = lock.newCondition();
  private final Object[] items;
  private int count, head, tail;

  BoundedBuffer(int cap) { items = new Object[cap]; }

  public void put(T x) throws InterruptedException {
    lock.lock();
    try {
      while (count == items.length) notFull.await();          // release lock, park, re-acquire on wake
      items[tail] = x;
      tail = (tail + 1) % items.length;
      count++;
      notEmpty.signal();                                       // wake exactly one consumer
    } finally { lock.unlock(); }
  }

  @SuppressWarnings("unchecked")
  public T take() throws InterruptedException {
    lock.lock();
    try {
      while (count == 0) notEmpty.await();
      T x = (T) items[head];
      items[head] = null;
      head = (head + 1) % items.length;
      count--;
      notFull.signal();                                        // wake exactly one producer
      return x;
    } finally { lock.unlock(); }
  }
}

Преимущество перед wait/notifyAll: производитель будит ровно одного потребителя, а не все ожидающие потоки. При высокой конкуренции это разница между штормами notifyAll (все ожидающие просыпаются, соревнуются за блокировку, все кроме одного возвращаются в сон) и чистой передачей управления.

Правило signal vs signalAll совпадает с notify vs notifyAll: предпочитайте signalAll, если не можете доказать, что все ожидающие данного условия взаимозаменяемы. В этом буфере каждый ожидающий notEmpty — потребитель, которому нужен слот; они взаимозаменяемы, и signal безопасен.

Компромисс CAS-цикл / монитор

Распространённый вопрос: когда атомарная переменная вроде AtomicInteger выигрывает у ReentrantLock? Примерно так:

  • Для одного поля с простым обновлением атомики выигрывают — это инструкции CAS, без системных вызовов, без парковки. AtomicInteger.incrementAndGet быстрее, чем ReentrantLock.lock + int++ + unlock.
  • Для нескольких полей, обновляемых вместе, или при блокирующей семантике (ожидание непустой очереди) выигрывает блокировка — можно группировать работу и сигнализировать через неё.

Проверка «действителен ли кэш?» — это volatile; инкремент — атомик; «поменять один элемент на другой в очереди» — блокировка. Используйте наилегчайший инструмент, которого требует задача.

tryLock для композиции без дедлоков

Простейший паттерн объединения двух блокировок без фиксированного порядка:

boolean done = false;
while (!done) {
  lockA.lock();
  try {
    if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {           // bounded wait for the second lock
      try {
        doCriticalWork();
        done = true;
      } finally { lockB.unlock(); }
    }
    // else: couldn't get lockB in time — fall through, release lockA, retry
  } finally {
    lockA.unlock();                                           // always release lockA before looping
  }
}

Если tryLock для lockB истекает по таймауту, finally освобождает lockA и цикл начинается заново с нуля. Поскольку ни один поток никогда не удерживает одну блокировку, бесконечно ожидая другую, классическое условие взаимоблокировки «держи и жди» нарушается.

Это позволяет избежать ловушки дедлока без необходимости глобального правила порядка захвата. Компромисс — больше кода, потенциальный лайвлок при высокой конкуренции и ухудшение поведения кэша. Используйте для перекрёстных блокировок (блокировок на объектах, которые вы не писали); когда контролируете обе стороны — предпочтите фиксированный порядок захвата.

Готовый пример: конкуренция, справедливость и реентрантность

Программа ниже сравнивает справедливый и несправедливый ReentrantLock при высокой конкуренции, а затем демонстрирует реентрантность и учёт счётчика захватов.

java— editable, runs on the server

Что можно извлечь из запуска:

  • Несправедливая блокировка распределила общий пул захватов неравномерно — отображаемое spread = max-min было большим (обычно несколько тысяч из 50 000 на поток). Это быстрый путь в действии: JVM не обеспечивает порядок, поэтому поток, только что освободивший блокировку, может немедленно вклиниться снова и выиграть прежде, чем будет запланирован поток из очереди.
  • Справедливая блокировка распределила захваты почти равномерно — её разброс составил небольшую долю от несправедливого, поскольку очередь FIFO даёт каждому потоку свою очередь. Общее время выполнения заметно больше. Справедливый порядок жертвует пропускной способностью ради предсказуемого прогресса. Не платите эту цену, если не измерили проблему голодания. (Точные числа варьируются от запуска к запуску и от машины к машине; стабильно то, что несправедливый разброс намного больше справедливого.)
  • Секция реентрантности показала, как счётчик захватов растёт и падает с каждым lock/unlock. Блокировка фактически освобождается только когда счётчик падает до нуля; до этого другие потоки, ожидающие её, остаются в состоянии BLOCKED. Это идентично семантике synchronized; разница в том, что ReentrantLock открывает счётчик для инспекции.
  • Лишний unlock() после того, как счётчик достиг нуля, немедленно выбросил IllegalMonitorStateException — тихого «двойного unlock», который бы успешно выполнился, нет. JVM обеспечивает инвариант блокировки: только держатель может освободить её, и лишь столько раз, сколько захватил.
  • Значение getQueueLength равное 3 подтвердило, что три ожидающих потока действительно стоят в очереди за нами. В продакшене этот метод полезен для оповещений «не растёт ли конкуренция?» — очередь, которая со временем увеличивается, сигнализирует о том, что работа внутри блокировки слишком медленная.

Что дальше

В следующей главе, Java ReadWriteLock, рассматривается ReentrantReadWriteLock — блокировка, разделяющая захват на «множество читателей ИЛИ один писатель» для нагрузок с преобладанием чтения, где исключительные блокировки избыточны.

Практика

Практика
Вы вызвали `lock.lock()` дважды из одного потока на `ReentrantLock`, а затем вызвали `lock.unlock()` один раз. Освобождена ли блокировка для других потоков?
Вы вызвали `lock.lock()` дважды из одного потока на `ReentrantLock`, а затем вызвали `lock.unlock()` один раз. Освобождена ли блокировка для других потоков?
Was this page helpful?