JavaprogramowanieStarszy programista Java

Jakie konkretne gwarancje atomiczności pozwalają **String.hashCode()** bezpiecznie pominąć **volatile** na wewnętrznej pamięci podręcznej hasha pomimo równoczesnego wypełnienia przez wiele wątków?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia pytania

Przed specyfikacją JSR 133 (Java 5) model pamięci Java nie miał formalnych reguł zachodzenia przed, co sprawiało, że łagodne wyścigi danych były niebezpieczne. String od zawsze był klasą o krytycznym znaczeniu dla wydajności, intensywnie wykorzystywaną w operacjach HashMap. Wczesne wersje JDK wprowadziły leniwe buforowanie hashy, aby uniknąć wielokrotnego obliczania hasha dla dużych ciągów. Decyzja o pominięciu volatile w polu hash była celową optymalizacją, poprzedzającą nowoczesne prymitywy współbieżności, polegającą na idempotentnej naturze obliczenia i konkretnych gwarancjach atomiczności dodanych do JLS w Java 5.

Problem

Kiedy wiele wątków równocześnie wywołuje hashCode() na nowo utworzonym String, mogą one wszystkie zaobserwować domyślną wartość 0 w polu hash. Bez synchronizacji, tworzy to wyścig danych, w którym kilka wątków może jednocześnie obliczać wartość hasha i próbować ją zapisać. Wyzwanie polega na zapewnieniu, że żaden wątek nigdy nie zaobserwuje częściowo zapisanej (poodrywanej) wartości hasha lub niespójnego stanu, unikając jednocześnie prohibicyjnych kosztów barier pamięci związanych z odczytami i zapisami volatile przy każdym wywołaniu hashCode().

Rozwiązanie

Rozwiązanie opiera się na dwóch fundamentalnych właściwościach JMM. Po pierwsze, specyfikacja języka Java (§17.7) gwarantuje, że zapisy do 32-bitowych wartości prymitywnych (int) są atomiczne, co zapobiega rozdzielaniu słów. Po drugie, konstruktor String ustanawia relację zachodzenia przed poprzez swoje pole final value, zapewniając, że tablica zaplecza jest w pełni widoczna dla każdego wątku otrzymującego referencję. Ponieważ obliczenie hasha jest czystą funkcją tych niezmiennych, bezpiecznie opublikowanych danych, wyścig o wypełnienie pamięci podręcznej jest łagodny. Jeśli wątek odczytuje przestarzałe 0, po prostu oblicza identyczną wartość; jeśli odczytuje wartość z pamięci podręcznej, używa jej. Atomiczny zapis zapewnia, że wartość jest albo w pełni widoczna, albo nie, nigdy uszkodzona.

public int hashCode() { int h = hash; // Odczyt nie-volatile: może widzieć 0 lub wartość z pamięci podręcznej if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // Atomiczny zapis: przypisanie 32-bitowe jest niepodzielne } return h; }

Sytuacja z życia

Projektowaliśmy usługę o wysokiej przepustowości przetwarzającą miliony rekordów CSV na sekundę. Każdy rekord generował wiele kluczy String dla pamięci podręcznej ConcurrentHashMap. Profilowanie ujawniło, że obliczenia hashCode() pochłaniały 15% czasu CPU z powodu dużych kluczy ciągów.

Rozwiązanie A: pole hash volatile. Rozważaliśmy dodanie volatile do pola hash w niestandardowym opakowaniu String. Plusy obejmowały natychmiastową widoczność w różnych rdzeniach i ścisłą spójność sekwencyjną. Jednak minusy były poważne: benchmarki JMH wykazały 400% spadek przepustowości z powodu ruchu spójności pamięci i kosztów barier pamięci przy każdej operacji mapowania.

Rozwiązanie B: synchronized hashCode(). Testowaliśmy synchronizację obliczeń. Plusy to prostota i absolutna poprawność. Minusami były katastrofalne kolizje; przy 32 wątkach opóźnienie wzrosło z 2 nanosekund do 800 nanosekund na operację, gdy wątki czekały na monitor.

Rozwiązanie C: łagodny wyścig (aktualna implementacja). Zachowaliśmy niesynchronizowane buforowanie idempotentne. Plusy to zerowy narzut synchronizacji i doskonała skalowalność z liczbą rdzeni. Minusy były teoretyczne: okazjonalne zbędne obliczenie, jeśli wątki ścigały się podczas pierwszego dostępu. Wybraliśmy Rozwiązanie C, ponieważ koszt ponownego obliczenia hasha (pudło pamięci) był znikomy w porównaniu z kosztami protokołów spójności pamięci (volatile) lub kolizji (synchronized).

Wynik: System utrzymał 2,5 miliona operacji na sekundę na rdzeń, bez hashCode() pojawiającego się w pierwszej setce gorących metod, co potwierdziło, że łagodny wyścig danych był odpowiednim kompromisem architektonicznym dla tej niezmiennej struktury danych.

Co często umyka kandydatom

Dlaczego brak volatile nie narusza relacji zachodzenia przed między wątkiem, który tworzy String, a wątkiem, który oblicza jego hash?

Relacja zachodzenia przed jest w rzeczywistości ustalana przez bezpieczną publikację samego obiektu String, a nie pole hasha. Kiedy String jest konstrukcyjny, jego pole final value gwarantuje, że zawartość tablicy zaplecza jest widoczna dla każdego wątku otrzymującego referencję. Pole hash jest jedynie pamięcią podręczną; zaobserwowanie jego domyślnej wartości 0 jest ważnym stanem programu, który po prostu uruchamia obliczenia. JMM zapewnia, że niezmienna tablica value jest spójna, a ponieważ hash jest pochodną wyłącznie z tych widocznych danych, obliczenie daje ten sam wynik niezależnie od tego, który wątek go wykonuje.

Czy tę samą optymalizację można zastosować do 64-bitowej wartości hasha bez użycia volatile?

Nie. JMM gwarantuje atomiczność tylko dla 32-bitowych prymitywów (int, float) na wszystkich architekturach. Dla prymitywów 64-bitowych (long, double) specyfikacja pozwala na rozdzielanie słów na 32-bitowych JVM-ach lub w niektórych architekturach bez volatile lub synchronizacji. Teoretycznie wątek mógłby zaobserwować wysokie 32 bity jednego obliczonego hasha i niskie 32 bity innego, co skutkowałoby całkowicie niepoprawną, niezerową wartością hasha, która uszkodziłaby umieszczanie bucketów w HashMap. Dlatego buforowanie 64-bitowych hashy wymaga volatile lub AtomicLong.

Jak to różni się od zepsutego idiomu "Double-Checked Locking" dla inicjalizacji singletonów?

Krytyczna różnica leży w bezpiecznej publikacji i idempotencji. W zepsutym Double-Checked Locking problemem jest zaobserwowanie nie-null referencji do obiektu, którego konstruktor nie został zakończony (przestawienie przypisania referencji a wykonanie konstruktora). W String.hashCode(), obiekt String jest już bezpiecznie opublikowany i w pełni skonstruowany; pole hash jest jedynie leniwie inicjalizowaną pamięcią podręczną czystych danych. Widzenie 0 (niezainicjowane) nie jest częściową konstrukcją, ale ważnym początkowym stanem. Ponadto operacja jest idempotentna — wiele wątków zapisujących tę samą wartość obliczoną daje ten sam wynik, co jeden wątek, podczas gdy DCL wymaga dokładnie jednej instancji stworzenia.