Программирование JavaМногопоточностьJava-разработчик серверных приложений

В системе производители и потребители должны ожидать разные условия одной блокировки. Чем Condition при Ree...

В системе производители и потребители должны ожидать разные условия одной блокировки. Чем Condition при ReentrantLock принципиально отличается от wait/notify на одном мониторе?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Condition позволяет создать несколько независимых очередей ожидающих потоков для одной блокировки: например, отдельно для условия «буфер не пуст» и для условия «буфер не заполнен». У встроенного монитора объекта существует одна общая очередь ожидания, поэтому wait() и notify() не разделяют потоки по условиям.

В обоих случаях поток обязан владеть соответствующей блокировкой перед ожиданием или сигнализацией. После пробуждения условие нужно проверять заново в цикле, потому что пробуждение не означает, что условие уже истинно.

Исторический контекст

Механизм wait()/notify() встроен в модель мониторной синхронизации Java: объект имеет монитор и единую очередь потоков, вызвавших wait(). Такой подход достаточен для простых протоколов, но плохо подходит, когда на одном общем состоянии существуют несколько разных условий ожидания.

Пакет java.util.concurrent.locks добавил явные блокировки, включая ReentrantLock, и связанные с ними объекты Condition. Они сохранили ключевую семантику ожидания с временным освобождением блокировки, но позволили организовать несколько именованных очередей ожидания.

Постановка проблемы

Рассмотрим ограниченный буфер. Производитель должен ждать, пока появится свободное место, а потребитель — пока появится элемент. Если все потоки ожидают на одном мониторе, вызов notify() может разбудить поток неподходящего типа. Он снова обнаружит ложное условие и заснёт, тогда как подходящий поток останется ждать.

Это не обязательно приводит к ошибке при корректном цикле проверки: поток не продолжит работу при ложном условии. Однако возникают лишние пробуждения, конкуренция за блокировку и риск неэффективности. Неправильное использование if вместо while уже может привести к чтению неготового состояния или нарушению инварианта буфера.

Подробное решение

У Condition есть собственная очередь ожидания, связанная с конкретным Lock. Обычно создают отдельное условие для каждого логического состояния:

import java.util.concurrent.locks.*; class Buffer { private final Lock lock = new ReentrantLock(); private final Condition notEmpty = lock.newCondition(); private final Condition notFull = lock.newCondition(); private int size; void awaitItem() throws InterruptedException { lock.lock(); try { while (size == 0) notEmpty.await(); size--; notFull.signal(); } finally { lock.unlock(); } } }

Вызов await() атомарно освобождает связанную блокировку и переводит поток в очередь данного условия. После signal() поток не получает блокировку немедленно: он становится кандидатом на повторное получение Lock, а продолжает выполнение только после успешного захвата блокировки.

signal() обычно будит один ожидающий поток, а signalAll() — всех ожидающих на конкретном Condition. Выбор между ними зависит от протокола: signal() снижает количество пробуждений, но требует уверенности, что разбуженный поток сможет продвинуть состояние; signalAll() проще и безопаснее для сложных или изменяющихся условий, но может вызвать массовую конкуренцию за блокировку.

Ожидание всегда оформляют циклом while. Причины — ложные пробуждения, конкуренция с другими потоками и возможность того, что другой поток изменит состояние до повторного получения блокировки. Condition не заменяет проверку состояния и не делает произвольное условие автоматически потокобезопасным.

У Condition есть дополнительные возможности: прерываемое ожидание, ожидание с тайм-аутом и ожидание до заданного момента. При этом нужно корректно обрабатывать InterruptedException, а при тайм-ауте учитывать, что истечение времени не гарантирует сохранения ожидаемого состояния.

Ситуация из практики

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

Вариант с одним Condition и signalAll() уменьшает риск пропустить подходящее пробуждение, но сохраняет массовые пробуждения. Вариант с двумя условиями — notEmpty и notFull — точнее адресует сигнал нужной группе потоков.

Практический выбор — два Condition при одном ReentrantLock, если буфер является узким местом и важна эффективность. Если нет измеримой нагрузки, готовая BlockingQueue обычно предпочтительнее: она скрывает протокол ожидания, уменьшает объём собственного кода и снижает риск ошибок в управлении блокировками.

Что кандидаты часто упускают

  1. Можно ли вызвать await() без предварительного захвата ReentrantLock?

Нет. Поток должен владеть связанным Lock; иначе обычно возникает IllegalMonitorStateException. Это необходимо, чтобы атомарно проверить состояние, перейти к ожиданию и освободить блокировку без окна гонки.

После пробуждения await() не просто возвращает поток к выполнению. Он сначала снова захватывает связанную блокировку, и только затем возвращает управление вызывающему коду. Поэтому проверку условия продолжают выполнять под тем же Lock.

  1. Чем signal() отличается от гарантии передачи блокировки разбуженному потоку?

signal() лишь переводит один поток из очереди условия в состояние, в котором он может конкурировать за блокировку. Текущий владелец Lock продолжает выполнять код до unlock(), а затем разбуженный поток может проиграть блокировку другому конкуренту.

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

  1. Почему разделение условий не устраняет необходимость защищать само состояние?

Condition хранит очередь ожидающих потоков, но не знает, что означает пользовательское условие. Например, notEmpty не становится истинным автоматически: код должен изменить размер буфера под блокировкой, а затем подать сигнал.

Если состояние читать или менять без той же блокировки, проверка условия и переход к ожиданию могут рассинхронизироваться. В результате возможны потерянные уведомления, нарушение инвариантов или бесконечное ожидание. Поэтому условие, изменение состояния и соответствующий signal проектируют как единый протокол под одной блокировкой.