Ответ на вопрос.
История вопроса.
ReentrantReadWriteLock, введенная в Java 5, обеспечила значительное улучшение конкурентности по сравнению с одиночными мьютексами, позволяя нескольким параллельным читателям. Однако ее проект явно запрещает обновление блокировки — получение блокировки на запись, удерживая блокировку на чтение — потому что реализация отслеживает количество удержаний чтения для каждого потока. Когда поток, удерживающий блокировку на чтение, пытается получить блокировку на запись, он сам оказывается в состоянии взаимной блокировки: блокировка на запись требует исключительной собственности, которую нельзя предоставить, пока удерживаются какие-либо блокировки на чтение (включая собственную блокировку потока). StampedLock, введенная в Java 8 как не реентрантная альтернатива, решила эту проблему с помощью оптимистичных штампов чтения, которые не требуют владения блокировкой во время фазы чтения, в сочетании с механизмами атомной валидации и конверсии.
Проблема.
Основной риск возникает из-за асимметрии в семантике получения блокировок. В ReentrantReadWriteLock обновление требует освобождения блокировки на чтение перед получением блокировки на запись, создавая уязвимое окно, в котором другие потоки могут получить блокировку на запись или изменить состояние между освобождением и повторным получением. Это заставляет разработчиков реализовывать сложные шаблоны двойной проверки блокировки или циклы повторных попыток, увеличивая сложность кода и задержку. Более того, если разработчик по ошибке пытается выполнить прямое обновление (writeLock().lock(), держа readLock()), поток попадает в состояние взаимной блокировки, ожидая, пока сам освободит разрешение на чтение.
Решение.
StampedLock устраняет этот риск с помощью tryOptimisticRead(), который возвращает длинный штамп без получения какой-либо блокировки или увеличения счетчиков читателей. Поток выполняет свои операции чтения и затем вызывает validate(stamp); если штамп остается действительным (пока не произошло никаких записи), чтение было последовательным без блокировки. Если поток обнаруживает необходимость записать, он пытается выполнить tryConvertToWriteLock(stamp), который атомарно валидирует штамп и получает блокировку на запись только в том случае, если состояние не изменилось с тех пор, как началось оптимистичное чтение. Этот подход предотвращает взаимную блокировку, поскольку поток никогда не удерживает конфликтующую блокировку на чтение в переходный период, и избегает окна гонки стратегий освобождения и повторного получения, делая обновление условным в зависимости от согласованности состояния.
Пример кода.
import java.util.concurrent.locks.StampedLock; public class AtomicUpgradeCache { private final StampedLock lock = new StampedLock(); private int value = 0; public void conditionalUpdate(int threshold, int newValue) { long stamp = lock.tryOptimisticRead(); int current = value; // Validate before acting if (!lock.validate(stamp)) { stamp = lock.readLock(); try { current = value; } finally { lock.unlockRead(stamp); } } if (current < threshold) { // Attempt atomic upgrade stamp = lock.tryConvertToWriteLock(stamp); if (stamp == 0L) { // Conversion failed, acquire fresh write lock stamp = lock.writeLock(); } try { // Re-check condition under exclusive lock if (value < threshold) { value = newValue; } } finally { lock.unlock(stamp); } } } }
Ситуация из жизни
Описание проблемы.
Платформа высокочастотной торговли поддерживала кеш в памяти для книги заказов, представляющей текущую рыночную глубину, требуя примерно 50,000 чтений в секунду от сотен потоков, но лишь случайные обновления, когда поступали ценовые изменения. Начальная реализация использовала блоки synchronized, что вызывало катастрофические всплески задержек во время рыночной волатильности, когда потоки боролись за монитор, с задержкой чтения, превышающей 500 миллисекунд. Инженерная команда должна была устранить конкуренцию со стороны чтения полностью, обеспечив тем не менее атомарное подтверждение рыночных условий и изменение книги без взаимной блокировки при обновлении из наблюдения в мутацию.
Рассмотренные различные решения.
Решение 1: ReentrantReadWriteLock с освобождением и повторным получением.
Этот подход включал получение блокировки на чтение для проверки рыночных условий, освобождение ее, а затем немедленное получение блокировки на запись, если обновление было необходимо. Хоть это и предотвращало взаимную блокировку, это вводило значительное состояние гонки: между освобождением блокировки на чтение и получением блокировки на запись конкурирующие потоки могли наблюдать одно и то же устаревшее состояние и инициировать избыточные запросы к базе данных или обмены API, что приводило к поведению громовой стаи и потере вычислительных ресурсов. Кроме того, постоянный переключение контекстов между режимами чтения и записи добавляло заметную накладную нагрузку во время периодов высокой торговли.
Решение 2: Неподвижные снимки с переменными ссылками.
Это решение полностью отказалось от блокировок в пользу поддержания книги заказов в качестве неизменяемой структуры данных, на которую ссылается переменная. Читатели просто разыменовывали переменную, чтобы получить последовательный снимок, в то время как писатели создавали совершенно новые копии книги заказов и выполняли атомарные операции сравнения и установки на ссылку. Это полностью устраняло конкуренцию со стороны чтения и обеспечивало отличную производительность чтения. Однако это создавало огромное давление на выделение памяти — каждое незначительное изменение цены требовало копирования всей структуры книги заказов, вызывая частые паузы сборки мусора в молодом поколении, которые нарушали 10-миллисекундные пороги задержки приложения в условиях волатильного рынка.
Решение 3: StampedLock с оптимистичными чтениями и условной конверсией.
Выбранное решение использовало StampedLock для предоставления оптимистичного доступа к чтению для горячего пути: потоки оптимистично читали состояние книги заказов с помощью tryOptimisticRead(), валидировали штамп и продолжали только если не произошло параллельных записей. Для редких операций записи система пыталась преобразовать оптимистичный штамп непосредственно в блокировку на запись с помощью tryConvertToWriteLock(), тем самым атомарно валидируя, что наблюдаемое состояние осталось актуальным и получая исключительный доступ только при действительности. Если конверсия не удалась, система возвращалась к явному получению блокировки на запись с традиционной логикой повторных попыток. Этот подход обеспечивал почти нулевую накладную нагрузку для чтений (сравнимо с доступом к простой переменной) и при этом предотвращал риски взаимной блокировки, присущие обновлениям ReentrantReadWriteLock.
Какое решение было выбрано (и почему).
Команда выбрала Решение 3 из-за того, что оно уникально сочетало крайние требования к пропускной способности чтения (оптимистичные чтения масштабируются линейно с количеством потоков) с атомарными требованиями безопасности для условных обновлений. В отличие от Решения 1, оно устраняло окно гонки между освобождением чтения и получением записи через механизм валидации штампа. В отличие от Решения 2, оно избегало давления выделения памяти, позволяя изменения на месте под защитой конвертированной блокировки на запись, а не требуя полных структурных копий для каждого незначительного изменения цены. Способность валидировать и конвертировать атомарно обеспечивала, что обновления цен происходили только если состояние рынка соответствовало критериям принятия решения, предотвращая нарушения согласованности, которые преследовали более ранние прототипы.
Результат.
После реализации приложение поддерживало 50,000 параллельных чтений в секунду с p99.9 задержками ниже 15 микросекунд, что представляет собой улучшение в 30 раз по сравнению с предыдущим подходом synchronized. Во время смоделированной рыночной волатильности с 1,000 параллельными обновлениями цен в секунду система поддерживала нулевые инциденты взаимной блокировки, и паузы сборки мусора оставались ниже 2 миллисекунд. Реализация StampedLock успешно справлялась с шестью месяцами производственной торговли без единого инцидента, связанного с конкурентностью или гонками данных, что подтвердило архитектурное решение использовать оптимистичную блокировку для сценариев высокочастотного чтения.
Что кандидаты часто упускают
Почему StampedLock не поддерживает реентрантность, и какой катастрофический режим сбоя возникает, если поток пытается рекурсивно получить ту же блокировку?
StampedLock явно разработан как не реентрантная блокировка, чтобы минимизировать отслеживание внутреннего состояния и максимизировать пропускную способность. В отличие от ReentrantReadWriteLock, который поддерживает карту владеющих потоков и количество удержаний, StampedLock отслеживает только то, удерживает ли какой-либо поток доступ, а не какой конкретный поток владеет им. Следовательно, если поток, удерживающий блокировку на чтение, пытается получить другую блокировку на чтение (или блокировку на запись) на том же экземпляре StampedLock, он немедленно попадает в взаимную блокировку: вызов получения блокировки блокируется в ожидании освобождения всех существующих блокировок, но заблокированный поток сам удерживает одну из этих блокировок, создавая неразрешимую круговую зависимость. Разработчики должны изменить код, чтобы передавать текущий штамп в качестве параметра метода, а не пытаться выполнить вложенные операции получения блокировки, что часто требует значительных архитектурных изменений в внутренних API, которые ранее полагались на состояние блокировки, локальное для потока.
Как семантика видимости памяти в оптимистичном режиме чтения StampedLock отличается от его пессимистичной блокировки на чтение, и почему validate() недостаточно для обеспечения согласованности без соответствующих отношений happens-before?
Оптимистичное чтение через tryOptimisticRead() само по себе не предоставляет гарантии happens-before; оно просто захватывает версионный штамп, не выдавая барьеры памяти или предотвращая переупорядочивание инструкций. Данные, наблюдаемые в оптимистичной фазе, могут отражать устаревшие строки кэша процессора или частично сконструированные объекты, поскольку модель памяти JVM рассматривает оптимистичные чтения как обычные обращения к переменным без семантики синхронизации. Только когда validate(stamp) возвращает истинное значение, это устанавливает, что блокировка на запись не была получена с тех пор, как началось оптимистичное чтение, создавая тем самым необходимую дугу happens-before относительно последнего освобождения блокировки на запись. Однако кандидаты часто упускают из виду, что validate() обеспечивает только состояние блокировки, а не внутреннюю согласованность структуры данных: если защищенные данные содержат невидимые ссылки на изменяемые объекты, оптимистичное чтение может наблюдать ссылку на объект, поля которого все еще инициализируются другим потоком (небезопасная публикация). Следовательно, оптимистичные чтения требуют, чтобы защищенное состояние состояло полностью из ссылок с использованием volatile или неизменяемых объектов для обеспечения безопасной публикации независимо от семантики памяти блокировки.
Какое фундаментальное несовместимое свойство между StampedLock и Виртуальными потоками (Project Loom), и почему это требует избегать StampedLock в современных приложениях с высокой конкурентностью, использующими виртуальные потоки?
Реализации StampedLock зависят от операций LockSupport.park, которые фиксируют подлежащий Поток платформы (поток несущей) при блокировке виртуального потока, удерживая блокировку. Когда виртуальный поток пытается получить конкурентоспособный StampedLock (либо на чтение, либо на запись), JVM не может отвязать виртуальный поток от его несущего потока, поскольку внутренности блокировки используют примитивы синхронизации на уровне платформы, которые еще не адаптированы для приостановки виртуального потока. Это фиксация разрушает основное обещание масштабируемости виртуальных потоков, которые мультиплексируют тысячи виртуальных потоков на несколько потоков платформы. Если несколько виртуальных потоков одновременно блокируют конкурентоспособные запросы к StampedLock, они монополизируют весь пул несущих потоков, замораживая приложение, даже если миллионы виртуальных потоков теоретически остаются доступными. В отличие от этого, ReentrantLock и Semaphore были переработаны, чтобы избежать фиксации, используя неблокирующие алгоритмы или специализированные механизмы приостановки при вызове из виртуальных потоков. Таким образом, современные приложения, использующие VirtualThread исполнителей, должны заменить StampedLock на ReentrantLock или конкурентоспособные структуры данных, чтобы предотвратить истощение несущего потока.