Интерфейс 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.
Что следует вынести из запуска:
- Шаблон
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.