Межпотоковое взаимодействие в Java
Координируйте потоки Java с помощью wait, notify и notifyAll на общих мониторах — и когда лучше использовать высокоуровневые примитивы.
Взаимное исключение обеспечивает безопасное совместное состояние, но не позволяет одному потоку сигнализировать другому об изменении состояния. Именно для этого предназначено трио wait, notify и notifyAll из java.lang.Object. Это низкоуровневый примитив координации, предоставляемый Java — все механизмы более высокого уровня (блокирующие очереди, защёлки, семафоры, Condition) построены на той же идее: поток ожидает внутри монитора, пока другой поток не скажет ему проснуться.
Современный код редко вызывает wait/notify напрямую. Вместо них используются BlockingQueue, CountDownLatch или Condition. Но нужно знать базовый механизм, потому что (а) именно его используют эти классы под капотом, (б) он применяется в каждой читаемой вами библиотеке, и (в) когда что-то идёт не так с высокоуровневым кодом, диагностика нередко ведёт вплоть до пропущенного notify.
Трио методов
Определены в java.lang.Object, поэтому они есть у каждого объекта:
void wait() throws InterruptedException;
void wait(long timeoutMillis) throws InterruptedException;
void notify();
void notifyAll();Жёсткое правило: вызывать эти методы можно только при владении монитором объекта, на котором они вызываются. То есть вы должны находиться внутри блока synchronized (obj) { ... } (или synchronized-метода, блокирующего тот же obj). Вызов obj.wait() без владения монитором obj немедленно выбрасывает IllegalMonitorStateException.
synchronized (lock) {
lock.wait(); // ok — we hold lock
lock.notify(); // ok — same
}
lock.wait(); // IllegalMonitorStateExceptionИменно это правило делает API работоспособным: ожидание и уведомление гарантированно происходят при удержании блокировки, поэтому состояние, о котором они говорят, остаётся согласованным.
Что на самом деле делает wait()
wait() — это не «сон». Он атомарно выполняет три действия:
- Освобождает монитор объекта, на котором был вызван.
- Паркует текущий поток в множестве ожидания этого монитора.
- При пробуждении (через
notify/notifyAll/interrupt/таймаут) повторно захватывает монитор перед возвратом.
Часть «атомарно освобождает и паркует» делает wait безопасным: notify, пришедший в промежутке между «мы решили ждать» и «мы фактически начали ждать», иначе был бы потерян. С wait этого промежутка не существует.
После возврата из wait() вы снова находитесь внутри блока synchronized с удержанной блокировкой — именно поэтому код после wait() может безопасно читать общее состояние.
Что делают notify() и notifyAll()
notify() выбирает один поток (какой именно — определяет JVM, как правило не FIFO) из множества ожидания и переводит его из состояния WAITING/TIMED_WAITING в BLOCKED. Уведомлённый поток всё ещё ожидает монитора; уведомитель по-прежнему его удерживает. Уведомлённый поток может повторно захватить монитор только после того, как уведомитель выйдет из блока synchronized.
notifyAll() пробуждает каждый поток из множества ожидания таким же образом. Все переходят в состояние BLOCKED; все выстраиваются в очередь за блокировкой; повторно захватывают её по одному по мере высвобождения.
notify быстрее (просыпается один поток), но опасен: если пробудить не тот поток (тот, чьё условие фактически не выполнено), он возвращается к wait() и ничего полезного не происходит. notifyAll безопаснее (хоть кто-то из ожидающих сможет продвинуться), но дороже. По умолчанию используйте notifyAll; переключайтесь на notify только когда можете доказать, что все ожидающие взаимозаменяемы.
Обязательный паттерн цикла while
Самое важное правило при использовании wait:
Всегда вызывайте
wait()внутри циклаwhile, повторно проверяющего условие.
synchronized (lock) {
while (!conditionHolds()) {
lock.wait();
}
// now condition holds AND we own the lock
}Три причины использовать цикл, а не if:
- Ложные пробуждения. JVM разрешено будить
waitбез какой-либо причины. Цикл их перехватывает. notifyAllбудит более одного потока. Когда все они борются за блокировку, победитель может не иметь ничего полезного делать — кто-то другой уже потребил ресурс. Цикл отправляет его обратно вwait.- Другое состояние может измениться. Между
notifyи моментом повторного захвата блокировки кто-то другой с этой блокировкой мог отменить то, чего вы ждали. Цикл перепроверяет.
if (!condition) wait() — самая распространённая ошибка в коде wait/notify. В тестах работает; в продакшне ломается в три часа ночи.
Классический сценарий производитель–потребитель
Канонический вариант использования wait/notify — ограниченный буфер:
class Buffer<T> {
private final Object lock = new Object();
private final Object[] data;
private int count, head, tail;
Buffer(int capacity) { data = new Object[capacity]; }
void put(T item) throws InterruptedException {
synchronized (lock) {
while (count == data.length) lock.wait(); // wait for room
data[tail] = item;
tail = (tail + 1) % data.length;
count++;
lock.notifyAll(); // wake any consumer
}
}
@SuppressWarnings("unchecked")
T take() throws InterruptedException {
synchronized (lock) {
while (count == 0) lock.wait(); // wait for an item
T item = (T) data[head];
data[head] = null;
head = (head + 1) % data.length;
count--;
lock.notifyAll(); // wake any producer
return item;
}
}
}Несколько вещей, которые здесь сделаны правильно:
- Одна блокировка для обоих методов (
lock). Один монитор защищает всё состояние. - Оба ожидания находятся внутри циклов
while. notifyAllна обеих сторонах — потому что и производители, и потребители ждут на одном мониторе, а пробуждение лишь одного может оказаться ошибочным.- Блокировка удерживается во время
wait(самwaitосвобождает её внутри, а затем повторно захватывает перед возвратом).
В продакшне вместо написания этого вручную вы бы использовали BlockingQueue. Но этот паттерн именно то, что BlockingQueue делает внутри.
Почему notifyAll — более безопасный вариант по умолчанию
Если заменить notifyAll на notify в буфере выше, возникнет тонкая ошибка. Два потребителя и один производитель ждут на одном мониторе. Производитель вызывает notify; JVM выбирает поток; если выбирается потребитель, когда пробуждение предназначалось для «в очереди есть место» (для потребителей неактуально), потребитель перепроверяет своё условие (очередь всё ещё возможно пуста), возвращается к wait, а производитель, которого нужно было разбудить, так и не просыпается. Очередь зависает, исключений нет.
Чтобы безопасно использовать notify, нужно: все ожидающие ждут одного условия, все взаимозаменяемы, и протокол гарантирует прогресс. Это строгое требование. По умолчанию используйте notifyAll; переключайтесь на notify только когда выигрыш в производительности важен и вы можете доказать инвариант.
Устаревшие альтернативы
В старом коде встречаются Thread.suspend() и Thread.resume(). Не используйте их. Они были помечены устаревшими ещё в Java 1.2, поскольку оставляют блокировки удержанными и нарушают инварианты. Механизм wait/notify — единственный безопасный способ заставить один поток ждать другого, используя только методы Object.
Есть также Thread.sleep — но sleep не освобождает блокировки. Поток, спящий внутри блока synchronized, блокирует все остальные потоки, желающие той же блокировки, до своего пробуждения. Используйте wait (который освобождает блокировку) для любого сценария «ждать пока что-то произойдёт»; оставьте sleep для «ждать фиксированное время, не удерживая ничего важного».
Что использовать вместо этого в продакшне
wait/notify корректны, но склонны к ошибкам. Современный код предпочитает высокоуровневые строительные блоки:
| Задача | Использовать |
|---|---|
| Ограниченный производитель–потребитель | ArrayBlockingQueue, LinkedBlockingQueue |
| Ожидание завершения N вещей | CountDownLatch |
| Ожидание встречи всех N участников | CyclicBarrier, Phaser |
| Несколько условных переменных на одной блокировке | Condition (из ReentrantLock.newCondition()) |
| Разрешения на ресурс | Semaphore |
| Результат будущего одноразового вычисления | CompletableFuture |
В каждом из них правильный цикл while, правильная семантика notifyAll/signalAll и правильная обработка прерываний встроены изначально. Мы познакомимся со всеми ними в этой части книги.
Разобранный пример: производитель–потребитель с wait и notifyAll
Программа ниже запускает двух производителей и трёх потребителей, работающих с ограниченным буфером выше. Производители добавляют по 1000 элементов каждый; потребители работают до тех пор, пока вместе не возьмут 2000 элементов.
Что стоит вынести из результата выполнения:
- Суммы совпали. Каждый элемент, добавленный производителем, был взят ровно одним потребителем; ничего не было продублировано, ничего не потеряно. Это свойство корректности производитель–потребитель, достигнутое всего одним монитором и парой
wait/notifyAll. - Буфер был всего 4 слота, поэтому производители постоянно заполняли его, а потребители постоянно опустошали. Циклы
whileпозволяли им парковаться и снова парковаться по мере цикличного использования очереди. Безwaitпроизводители крутились бы наcount == capacity, сжигая CPU; с ним — они спят до тех пор, пока потребитель не подаст сигнал. notifyAllвызывался на той же блокировке, которую удерживали и производители, и потребители. Это весь механизм координации: один монитор, взаимное исключение и сигнализация, с цикломwhile, перехватывающим любое нерелевантное пробуждение.- Последний
waitвне блокаsynchronizedнемедленно выбросилIllegalMonitorStateException. Это исполнение JVM правила: ждать/уведомлять можно только на мониторе, которым вы сейчас владеете. Если вы видите это исключение, путь кода дошёл доwaitбез предшествующегоsynchronized. - Та же форма — ограниченный буфер, взаимное исключение, сигнал при каждом изменении состояния — это то, что
ArrayBlockingQueueделает внутри, только использует два объектаCondition(один для «не полон», другой для «не пуст») вместо одного большогоnotifyAll. Это правильный способ написать такое в продакшне; версия сwait/notifyAll— базовый механизм, на котором построены все высокоуровневые классы.
Что дальше
Следующая глава, Java Deadlock, рассматривает режим отказа, делающий блокировку нетривиальной — два потока, каждый из которых удерживает то, что нужно другому, — и стратегии его предотвращения.