Еще один из вопросов, который мучил меня во время знакомства с java threading, и так и остался, в то время, не отвеченным. Зачем сановские инженеры спроектировали wait/notify так, что они обязательно требуют на входе захваченной блокировки? Причем на входе они ее требуют, но внутри себя они ее отпускают -- что-то очень хитрое стояло за таким решением, что-то, что я не мог понять.
Ответ оказался довольно прост. Другое поведение не имеет смысла, потому что не дает возможность реализовать то, для чего нужен wait/notify.
Зачем нужен wait? Точнее -- каков сценарий его использования? Если мы хотим просто приостановить поток -- есть Thread.sleep(). А wait нужен тогда, когда мы ждем какого-то
события. Еще точнее, мы ждем
выполнения какого-то условия, за которое отвечает какой-то другой поток. Но это означает, что у нашего потока с этим другим потоком есть общее,
разделяемое состояние (shared state). Второй поток это состояние меняет, и в какой-то момент оно становится "подходящим" для нас, и мы хотим об этом узнать. И wait/notify это всего лишь инструмент, который дает нам такую возможность. Но если у нас есть разделяемое состояние -- нам просто необходима блокировка в обоих потоках, чтобы избежать проблем с data race и memory visibility. Сами-то по себе методы wait/notify можно организовать без требования блокировки -- но они будут бесполезны
На конкретном примере: вот как выглядит типичный код с wait/notify (представим, что секции synchronized не обязательны)
//общие переменные
boolean condition = false;
final Object event = new Object();
...
//первый поток
while(!condition){
event.wait();
}
...
//второй поток
condition = true;
event.notify();
Что помешает второму потоку вклиниться между строчками 7 и 8 -- когда первый уже решил, что он должен ждать, но еще не вызвал метод wait? В этом случае notify() вызванный вторым уйдет в пустоту (пока еще никто ничего не ждет), а wait(), вызванный первым никогда не пробудится -- некому больше будить (забудем пока про внезапные пробуждения). Другой вопрос -- кто гарантирует, что обновленное вторым потоком значение condition=true будет увидено первым? Никаких memory barrier-ов здесь нет, первый поток спокойно может закэшировать condition хоть в регистрах процессора, и быть свято уверенным, что оно все еще false. Еще можно вообразить разнообразные insruction reordering, в ходе которых компилятор может решить переставить condition = true после event.notify(), например.
Ок, договорились: синхронизация необходима. Но зачем так плотно привязывать wait/notify к synchronized? Спроектировали бы wait/notify независимо от синхронизации, и просто указывали бы в recommended practices что правильно писать так-то. Были бы очевидные бенефиты -- сейчас, например, старый-добрый Object.wait/notify работает только со старым-добрым synchronized, а новый Lock.lock()/unlock() только с новым же Condition.await/signal -- а между собой они не работают, что, в общем-то, странно. Казалось бы -- какая разница, каким методом обеспечивать синхронизацию разделяемого состояния между потоками?
Ан нет, без плотного связывания не получится. wait обязан знать многое о мониторе синхронизации, с помощью которого согласовывается разделяемое состояние -- потому что wait должен уметь этот монитор сначала отпустить на входе, а потом захватить заново -- на выходе. Как минимум, wait должен иметь доступ к этому монитору.
Вот мы и приходим к той реализации, что имеем. Сначала нужно захватить монитор какого-нибудь объекта, чтобы обеспечить согласованность состояния condition. Потом можно вызвать wait(), и он должен захваченный монитор отпустить -- иначе как другой поток сможет изменять условие? Но wait нужно вызвать на том же объекте -- иначе как wait() узнает, какой именно монитор из 10 захваченных текущим потоком выше по стеку ему надо освободить? Перед notify нужно захватить тот же самый монитор, чтобы синхронизировать изменения в condition между потоками. Ну и очевидно, что notify() нужно вызывать на том же объекте, чтобы он знал, какой wait ему нужно будить. Такой вот расклад