До спецификации JSR 133 (Java 5) Java Memory Model не имела формальных правил happens-before, что делало безобидные гонки данных опасными. String всегда была критически важным для производительности неизменяемым классом, широко используемым в операциях HashMap. Ранние версии JDK ввели ленивое кеширование хешей, чтобы избежать повторного вычисления хеша для больших строк. Решение не использовать volatile для поля hash было преднамеренной оптимизацией, предшествующей современным конкурентным примитивам, полагаясь на идемпотентный характер вычисления и специфические гарантии атомарности, добавленные в JLS в Java 5.
Когда несколько потоков одновременно вызывают hashCode() для вновь созданной String, они могут все наблюдать значение по умолчанию, равное 0, в поле hash. Без синхронизации это создает гонку данных, когда несколько потоков могут одновременно вычислять значение хеша и пытаться записать его обратно. Задача состоит в том, чтобы гарантировать, что ни один поток никогда не увидит частично записанное (порванное) значение хеша или несогласованное состояние, одновременно избегая запретительных затрат на барьеры памяти, связанные с чтениями и записями volatile при каждом вызове hashCode().
Решение основывается на двух основных свойствах JMM. Во-первых, Спецификация языка Java (§17.7) гарантирует, что записи 32-битных примитивных значений (int) являются атомарными, что предотвращает разрывы слова. Во-вторых, конструктор String устанавливает отношение happens-before через свое final поле value, гарантируя, что массив, на который ссылается это поле, полностью виден любому потоку, получающему ссылку. Поскольку вычисление хеша является чистой функцией этих неизменяемых, безопасно опубликованных данных, гонка на заполнение кэша безобидна. Если поток читает устаревшее значение 0, он просто пере computes такое же значение; если он читает кэшированное значение, он использует его. Атомарная запись гарантирует, что значение либо полностью наблюдается, либо нет, и никогда не повреждается.
public int hashCode() { int h = hash; // Неволатильное чтение: может увидеть 0 или кэшированное значение if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // Атомарная запись: 32-битное присвоение неделимо } return h; }
Мы разрабатывали сервис для высокопроизводительной загрузки, обрабатывающий миллионы записей CSV в секунду. Каждая запись генерировала несколько ключей String для кэша ConcurrentHashMap. Профилирование показало, что вычисления hashCode() потребляли 15% времени ЦП из-за больших строковых ключей.
Решение А: поле hash с volatile. Мы рассмотрели возможность добавления volatile к полю hash в пользовательской обертке String. Плюсы включали немедимую видимость на всех ядрах и строгую последовательную согласованность. Однако минусы были серьезными: бенчмарки JMH показали снижение пропускной способности на 400% из-за трафика кохерентности кэша и затрат на барьеры памяти при каждой операции отображения.
Решение Б: synchronized hashCode(). Мы протестировали синхронизацию вычисления. Плюсы заключались в простоте и абсолютной правильности. Минусы заключались в катастрофической конкуренции; при 32 потоках задержка возросла с 2 наносекунд до 800 наносекунд на операцию, поскольку потоки ждали монитора.
Решение В: Безобидная гонка (текущая реализация). Мы сохранили несинхронизированное идемпотентное кеширование. Плюсы заключались в отсутствии затрат на синхронизацию и отличной масштабируемости с количеством ядер. Минусы были теоретическими: случайное избыточное вычисление, если потоки бегали во время первого доступа. Мы выбрали Решение В, потому что стоимость повторного вычисления хеша (промах в кэше) была незначительна по сравнению со стоимостью протоколов кохерентности кэша (volatile) или конкуренции (synchronized).
Результат: Система выдерживала 2,5 миллиона операций в секунду на ядро, при этом hashCode() не входил в 100 самых горячих методов, что подтверждало, что безобидная гонка данных была правильной архитектурной компромисс для этой неизменяемой структуры данных.
Почему отсутствие volatile не нарушает отношение happens-before между потоком, создающим строку, и потоком, вычисляющим ее хеш?
Отношение happens-before на самом деле устанавливается безопасной публикацией самого объекта String, а не поля hash. Когда String создается, его final поле value гарантирует, что содержимое базового массива видно любому потоку, получающему ссылку. Поле hash является всего лишь кэшем; наблюдение за его значением по умолчанию 0 является допустимым состоянием программы, которое просто вызывает вычисление. JMM обеспечивает согласованность неизменяемого массива value, и поскольку хеш извлекается исключительно из этих видимых данных, вычисление дает тот же результат независимо от того, какой поток его выполняет.
Может ли это же оптимизация быть применена к 64-битному длинному хеш-значению без использования volatile?
Нет. JMM гарантирует атомарность только для 32-битных примитивов (int, float) на всех архитектурах. Для 64-битных примитивов (long, double) спецификация позволяет разрывам слов на 32-битных JVM или определенных архитектурах без volatile или синхронизации. Теоретически поток мог бы наблюдать высокие 32 бита одного вычисленного хеша и низкие 32 бита другого, что привело бы к полностью неправильному, ненулевому хеш-значению, которое испортило бы размещение корзин в HashMap. Поэтому кэширование 64-битных хешей требует volatile или AtomicLong.
Как это отличается от сломанного идиома "Двойная проверка блокировки" для инициализации одиночки?
Критическое различие заключается в безопасной публикации и идемпотентности. В сломанной двойной проверке блокировки проблема заключается в том, что наблюдается ненулевое указание на объект, чей конструктор еще не завершен (переупорядочение присвоения ссылки против выполнения конструктора). В String.hashCode() объект String уже безопасно опубликован и полностью сконструирован; поле hash является всего лишь лениво инициализированным кэшем чистых данных. Наблюдение за 0 (неинициализированным) не является частичной конструкцией, а допустимым начальны