Разбор последствий: к чему приводит смена объекта, используемого как монитор, во время работы потоков?
Синхронизация защищает не имя переменной, а конкретный объект-монитор. Если ссылка на монитор меняется, разные потоки могут войти в критическую секцию, синхронизируясь на разных объектах, поэтому взаимное исключение будет нарушено.
Надёжное решение — использовать стабильный монитор: обычно private final Object lock или неизменяемый объект, на котором выполняется synchronized.
В Java встроенная синхронизация основана на мониторах объектов. Такой подход связывает блокировку с идентичностью объекта, что позволяет языку предоставить простой механизм взаимного исключения через synchronized.
Проблема возникает, когда изменяемая ссылка используется как средство координации. Переменная содержит ссылку на объект, но сама переменная не становится монитором: монитором является объект, на который она указывает в момент входа в synchronized.
Рассмотрим поле, содержащее объект блокировки. Один поток может получить старое значение поля и начать ожидать или работать под старым монитором, пока другой поток заменяет ссылку на новый объект.
После замены новые потоки будут синхронизироваться на новом объекте. Старый и новый мониторы независимы, поэтому критические секции, которые должны быть взаимоисключающими, фактически могут выполняться одновременно.
Даже объявление ссылки как volatile не устраняет проблему. Оно обеспечивает видимость самой ссылки, но не объединяет старый и новый мониторы в одну блокировку.
При вычислении выражения synchronized (lock) поток сначала получает текущее значение lock, а затем пытается захватить монитор соответствующего объекта. Если другой поток позже присвоит lock новый объект, уже захваченный монитор не изменится: поток продолжит владеть старым объектом.
Минимальный пример ошибки:
После replaceLock() один вызов increment() может работать со старым монитором, а другой — с новым. Поле value тогда изменяется без общего взаимного исключения, несмотря на наличие synchronized.
Обычно монитор объявляют как private final Object lock = new Object();. private предотвращает захват этого объекта внешним кодом, а final не позволяет заменить ссылку после создания объекта. Если защищается состояние экземпляра целиком, допустима синхронизация на this, но внешний код также может захватить этот монитор, создав риск неожиданных блокировок.
Для более сложных сценариев можно использовать private final ReentrantLock. Он также должен оставаться стабильным объектом: замена ссылки на ReentrantLock создаёт ту же логическую ошибку. Преимущество ReentrantLock — дополнительные возможности вроде прерываемого или ограниченного по времени захвата, а не автоматическое решение проблемы изменяемого монитора.
В сервисе обновление конфигурации заменяло объект блокировки, который использовался несколькими методами для защиты кэша. Во время обновления часть запросов входила под старым монитором, а новые запросы — под новым. Результатом стали редкие повреждения согласованности кэша, которые не воспроизводились при обычной нагрузке.
Рассматривались три варианта. Синхронизация на публичном объекте конфигурации была простой, но позволяла внешнему коду случайно блокировать сервис. Использование ReentrantLock давало больше управления, но без устранения замены ссылки проблема сохранялась. Сохранение изменяемого монитора с volatile улучшало видимость ссылки, но не взаимное исключение.
Выбрали отдельный private final Object cacheLock и синхронизацию всех операций над кэшем только на нём. Обновление конфигурации больше не заменяло монитор, поэтому все операции использовали один объект блокировки, а согласованность кэша восстановилась.
Вопрос: Достаточно ли объявить ссылку на монитор как final, если сам объект доступен внешнему коду?
Нет. final предотвращает замену ссылки, но не ограничивает доступ к объекту. Внешний код, получивший этот объект, может синхронизироваться на нём, искусственно увеличивать конкуренцию или создать риск взаимной блокировки. Поэтому монитор обычно делают одновременно private и final.
Вопрос: На каком объекте синхронизируется нестатический метод, объявленный как synchronized?
На текущем экземпляре, то есть на this. Все такие методы одного объекта используют общий монитор, но методы разных экземпляров блокируют разные объекты и не мешают друг другу. Для статического synchronized-метода монитором служит объект Class, связанный с классом.
Вопрос: Почему создание нового монитора внутри каждого вызова метода не защищает общее состояние?
Каждый вызов получает отдельный объект. Потоки не видят общий монитор и поэтому не конкурируют за одну блокировку: каждый успешно захватывает собственный объект. Чтобы синхронизация работала, все операции, защищающие одно состояние, должны использовать одну стабильную блокировку.