W3docs

Интерфейс Lock в Java

Интерфейс java.util.concurrent.locks.Lock — что он даёт сверх synchronized и правила безопасного использования.

synchronized — это маленький и острый инструмент. Он быстрый, автоматический и покрывает большинство потребностей во взаимном исключении. Но когда он становится тесен — когда нужен таймаут, возможность прерваться или более одной переменной условия — Java предлагает второй, более богатый API блокировок: интерфейс java.util.concurrent.locks.Lock и его реализации. В этой главе рассматривается сам интерфейс; следующие две главы посвящены двум реализациям (ReentrantLock, ReentrantReadWriteLock), которые вы будете использовать на практике.

Что даёт интерфейс

public interface Lock {
  void lock();
  void lockInterruptibly() throws InterruptedException;
  boolean tryLock();
  boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  void unlock();
  Condition newCondition();
}

Шесть методов. Пять из них связаны с захватом или освобождением блокировки; один возвращает Condition (ответ Lock на wait/notify).

Четыре способа захвата — то, чего не даёт synchronized:

  • lock() — блокировать до захвата. Ближайший аналог synchronized.
  • lockInterruptibly() — блокировать до захвата, но завершиться с InterruptedException при прерывании. Позволяет отменить поток, ожидающий блокировки.
  • tryLock() — попробовать один раз, немедленно вернуть true/false. Не блокирует.
  • tryLock(time, unit) — пробовать до истечения таймаута, затем отказаться. Инструмент предотвращения взаимоблокировок из позапрошлой главы.

У synchronized только один режим захвата — блокировать бесконечно до получения. Это подходит для большинства кода, но не подходит, когда нужен дедлайн или точка отмены.

Обязательный шаблон try/finally

synchronized автоматически освобождает монитор при выходе из блока — как при нормальном завершении, так и при исключении. Lock этого не делает. Если забыть вызвать unlock, блокировка удерживается вечно и всё дальнейшее зависает.

Правильный шаблон — всегда:

lock.lock();
try {
  // critical section
} finally {
  lock.unlock();
}

unlock обязательно должен находиться в finally, чтобы выполниться даже если тело бросит исключение. Для Lock нет прямой поддержки try-with-resources (он не реализует AutoCloseable), хотя встречаются паттерны-обёртки, имитирующие её. Стандартный шаблон выше используется почти во всём промышленном коде.

tryLock и таймаут

Две перегрузки tryLock — это то, как Lock позволяет обрабатывать ситуацию «что, если не удаётся захватить?»:

if (lock.tryLock()) {
  try {
    doWork();
  } finally {
    lock.unlock();
  }
} else {
  // didn't get the lock — do something else, maybe retry later
}
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {       // wait up to 500ms
  try {
    doWork();
  } finally {
    lock.unlock();
  }
} else {
  throw new TimeoutException("couldn't acquire " + name);
}

Вторая форма делает возможным восстановление после взаимоблокировки. При использовании synchronized поток, ожидающий монитор, застревает до тех пор, пока владелец не освободит его — выхода нет, кроме остановки JVM. С tryLock(timeout) вы сдаётесь после дедлайна и либо повторяете попытку, либо завершаете операцию с ошибкой, либо переходите к альтернативному пути.

lockInterruptibly — прерываемый захват блокировки

synchronized не реагирует на Thread.interrupt() во время ожидания. Поток в состоянии BLOCKED на мониторе остаётся заблокированным, даже если его прервать — JVM просто устанавливает флаг и забывает о нём.

lock.lockInterruptibly() реагирует. Если другой поток вызовет interrupt() на вас, пока вы ждёте блокировку, вызов немедленно бросает InterruptedException:

try {
  lock.lockInterruptibly();
} catch (InterruptedException e) {
  Thread.currentThread().interrupt();
  return;                                              // gave up on the work
}
try {
  doWork();
} finally {
  lock.unlock();
}

Это критически важно в серверном коде: приходит запрос, поток пытается захватить блокировку, запрос отменяется (клиент отключился, таймаут от балансировщика нагрузки), супервизор вызывает interrupt() на рабочем потоке. С synchronized рабочий поток продолжает ждать; с lockInterruptibly — отказывается от ожидания.

Condition — осведомлённый о Lock аналог wait/notify

Эквивалент внутреннего монитора — wait/notify в synchronized-блоке — даёт ровно один набор ожидания на объект. Один Lock может иметь несколько объектов Condition:

Lock lock = new ReentrantLock();
Condition notFull  = lock.newCondition();
Condition notEmpty = lock.newCondition();

Вы удерживаете блокировку, вызываете await() на условии (которое освобождает блокировку и переводит вас в режим паркования), и другой поток вызывает signal() на условии (что переводит вас в состояние BLOCKED в ожидании блокировки). Соответствие с wait/notify:

Lock + ConditionВнутренний монитор
lock.lock()вход в synchronized (obj)
condition.await()obj.wait()
condition.signal()obj.notify()
condition.signalAll()obj.notifyAll()
lock.unlock()выход из synchronized

Преимущество перед wait/notify: несколько условий на одну блокировку. Ограниченный буфер может иметь одно условие для «не полон» и одно для «не пуст» — производители вызывают signal(notEmpty) после добавления элемента; потребители вызывают signal(notFull) после извлечения. Пробуждается только нужная сторона. При подходе с единственным монитором и notifyAll приходится будить всех и надеяться на лучшее.

Переработанный пример с ограниченным буфером мы увидим в главе о ReentrantLock.

Когда использовать Lock, а когда оставаться с synchronized

Прагматичное правило принятия решения:

  • По умолчанию используйте synchronized для простого взаимного исключения. Он автоматический, не может утечь, и JVM активно его оптимизирует.
  • Переходите к Lock, когда нужно что-либо из: таймаут на захват, возможность отменить ожидающий поток через interrupt, несколько Condition на одну блокировку или разделение чтения/записи (ReentrantReadWriteLock).
  • Переходите к Lock при высокой конкуренции и необходимости справедливого порядка (new ReentrantLock(true) — справедливая версия; внутренние мониторы несправедливы). Справедливый порядок жертвует пропускной способностью ради предсказуемости.

Не следует «обновлять» synchronized до Lock без причины. Для базового случая они эквивалентны; остальная часть главы — о том, когда дополнительные возможности оправдывают себя.

От чего вы отказываетесь

У Lock есть издержки, которых нет у synchronized:

  • Нет автоматического освобождения. Забудьте finally — блокировка утечёт. JVM не сможет вам помочь.
  • Нет проверки структурированного вложения. С synchronized компилятор обеспечивает парность захвата/освобождения; с Lock можно вызвать unlock() из другого метода или пути, и компилятор этого не заметит.
  • Нет нативных оптимизаций среды выполнения. JVM имеет специальные оптимизации для внутренних мониторов (смещённая блокировка, укрупнение блокировок, исключение блокировок в некоторых случаях), которые не применяются к Lock. При очень низкой конкуренции synchronized может быть чуть быстрее.
  • Больше поверхности для злоупотреблений. tryLock и lockInterruptibly оба требуют проверки результата; пропуск проверки приводит к тихой ошибке «блокировка не захвачена».

Используйте Lock ради возможностей, а не ради синтаксиса.

Проработанный пример: Lock делает то, чего не может synchronized

Программа ниже использует ReentrantLock (стандартную реализацию Lock) для демонстрации трёх вещей, недоступных в synchronized: tryLock с таймаутом, lockInterruptibly и пользовательский Condition.

java— editable, runs on the server

Что следует вынести из запуска:

  • Шаблон try/finally из раздела 1 — это то, что нужно в каждом месте вызова Lock. Никакой синтаксической защиты нет — если удалить finally, код скомпилируется, и блокировка утечёт при первом же исключении в теле. Запомните форму: lock(), try { ... } finally { unlock(); }.
  • tryLock(100, MS) из раздела 2 вернул false примерно через 100 мс, потому что поток-владелец всё ещё находился в своём 500-миллисекундном сне. Таков контракт дедлайна — вызов возвращает false по истечении таймаута в любом случае. С synchronized этот поток блокировался бы до освобождения владельцем, без какого-либо выхода.
  • Ожидающий поток из раздела 3 был прерван во время ожидания блокировки, и lockInterruptibly бросил InterruptedException. Сравните с lock.lock() или synchronized — ни тот, ни другой не реагируют на interrupt() во время ожидания. Это разница между сервером, способным отменять запросы с истёкшим таймаутом, и сервером, который просто накапливает застрявшие потоки.
  • В разделе 4 использовались два Condition на одну блокировку — notFull для производителей, notEmpty для потребителей. Когда производитель добавлял элемент, он вызывал signal именно на notEmpty; пробуждался только потребитель. При использовании wait/notifyAll на внутреннем мониторе будится каждый ожидающий поток и перепроверяет условие; пара Condition отправляет пробуждение нужной стороне очереди и экономит цикл пробуждения/перепроверки.
  • signal() (единственное число) вместо signalAll() безопасен здесь, потому что все ожидающие на каждом условии взаимозаменяемы — любой производитель может заполнить только что освобождённый слот. Если ожидающие были бы не взаимозаменяемы (например, ждали разных конкретных ключей), signalAll по-прежнему был бы более безопасным выбором по умолчанию.

Что дальше

Следующая глава, Java ReentrantLock, подробно рассматривает стандартную реализацию Lock — её реентрантность, политику справедливости и диагностический API getHoldCount/isHeldByCurrentThread.

Практика

Практика
Вы пишете код, который должен захватить блокировку с дедлайном — отказаться через 200 мс, если блокировка недоступна, и выполнить альтернативное действие. Какой подход правильный?
Вы пишете код, который должен захватить блокировку с дедлайном — отказаться через 200 мс, если блокировка недоступна, и выполнить альтернативное действие. Какой подход правильный?
Was this page helpful?