Voor de JSR 133 specificatie (Java 5) ontbraken er formele happens-before regels in het Java Memory Model, waardoor onschuldige data races gevaarlijk werden. String is altijd een prestatiekritische onveranderlijke klasse geweest, die veel wordt gebruikt in HashMap-operaties. Vroeg in de JDK versies werd lazy hash caching geïntroduceerd om te voorkomen dat de hash voor grote strings herhaaldelijk werd herberekend. De beslissing om volatile op het hash-veld weg te laten was een opzettelijke optimalisatie die voorafging aan moderne gelijktijdigheidsprimitieven, en vertrouwt op de idempotente aard van de berekening en specifieke atomiciteitsgaranties die aan de JLS werden toegevoegd in Java 5.
Wanneer meerdere threads hashCode() gelijktijdig aanroepen op een nieuw aangemaakte String, kunnen ze allemaal de standaardwaarde van 0 in het hash-veld waarnemen. Zonder synchronisatie ontstaat er een data race waarbij verschillende threads tegelijkertijd de hashwaarde kunnen berekenen en deze proberen terug te schrijven. De uitdaging is om ervoor te zorgen dat geen enkele thread ooit een gedeeltelijk geschreven (gescheurde) hashwaarde of een inconsistente toestand waarneemt, terwijl de prohibitieve kosten van geheugenbarriers die geassocieerd zijn met volatile-lezingen en -schrijvingen bij elke hashCode()-aanroep worden vermeden.
De oplossing is gebaseerd op twee fundamentele JMM-eigenschappen. Ten eerste garandeert de Java Taal Specificatie (§17.7) dat schrijfinstructies naar 32-bits primitieve waarden (int) atomair zijn, waardoor word tearing wordt voorkomen. Ten tweede stelt de String-constructor een happens-before relatie vast via zijn final value-veld, wat ervoor zorgt dat de achterliggende array volledig zichtbaar is voor elke thread die de referentie ontvangt. Aangezien de hashberekening een pure functie van deze onveranderlijke, veilig gepubliceerde gegevens is, is de race om de cache te vullen onschuldig. Als een thread een verouderde 0 leest, berekent deze eenvoudig de identieke waarde opnieuw; als deze de gecachede waarde leest, gebruikt deze deze. De atomische schrijfoperatie zorgt ervoor dat de waarde of volledig wordt waargenomen of niet, en nooit corrupt raakt.
public int hashCode() { int h = hash; // Niet-volatiel lezen: kan 0 of gecachede waarde zien if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // Atomische schrijfoperatie: 32-bits toewijzing is ondeelbaar } return h; }
We waren bezig met het ontwerpen van een high-throughput ingestieservice die miljoenen CSV-records per seconde verwerkt. Elk record genereerde meerdere String-sleutels voor een ConcurrentHashMap-cache. Profilering toonde aan dat hashCode()-berekeningen 15% van de CPU-tijd in beslag namen door grote string-sleutels.
Oplossing A: volatiel hashveld. We overwoegen om volatile aan het hash-veld toe te voegen in een aangepaste String-wrapper. De voordelen omvatten onmiddellijke zichtbaarheid over alle kernen en strikte sequentiële consistentie. De nadelen waren echter ernstig: JMH-benchmarks toonden een vertraging van 400% in throughput door cache coherency-verkeer en geheugenhekkenkosten bij elke mapbewerking.
Oplossing B: gesynchroniseerde hashCode(). We hebben getest met het synchroniseren van de berekening. De voordelen waren eenvoud en absolute juistheid. De nadelen waren catastrofale concurrentie; onder 32 threads steeg de latentie van 2 nanoseconden naar 800 nanoseconden per bewerking terwijl threads wachtten op de monitor.
Oplossing C: Onschuldige race (huidige implementatie). We behielden de niet-gesynchroniseerde idempotente caching. De voordelen waren nul synchronisatieoverhead en perfecte schaalbaarheid met het aantal kernen. De nadelen waren theoretisch: af en toe redundante berekening als threads raceten tijdens de eerste toegang. We kozen voor Oplossing C omdat de kosten van het opnieuw berekenen van een hash (cache mis) verwaarloosbaar waren in vergelijking met de kosten van cache coherency-protocollen (volatile) of concurrentie (gesynchroniseerd).
Resultaat: Het systeem hield 2,5 miljoen operaties per seconde per kern vol zonder dat hashCode() in de top 100 hete methoden verscheen, wat bevestigde dat de onschuldige data race de juiste architectonische afweging was voor deze onveranderlijke datastructuur.
Waarom schendt de afwezigheid van volatile de happens-before relatie tussen de thread die de String aanmaakt en de thread die de hash berekent niet?
De happens-before relatie wordt feitelijk vastgesteld door de veilige publicatie van het String-object zelf, niet het hash-veld. Wanneer een String wordt geconstrueerd, garandeert zijn final value-veld dat de inhoud van de achterliggende array zichtbaar is voor elke thread die de referentie ontvangt. Het hash-veld is slechts een cache; het waarnemen van de standaardwaarde van 0 is een geldige programmastatus die eenvoudig de berekening activeert. De JMM garandeert dat de onveranderlijke value-array consistent is, en aangezien de hash puur is afgeleid van deze zichtbare gegevens, levert de berekening dezelfde uitkomst op ongeacht welke thread deze uitvoert.
Kunnen dezelfde optimalisaties worden toegepast op een 64-bits lange hashwaarde zonder volatile?
Nee. De JMM garandeert alleen atomiciteit voor 32-bits primitieve waarden (int, float) op alle architecturen. Voor 64-bits primitieve waarden (long, double) staat de specificatie word tearing toe op 32-bits JVM's of bepaalde architecturen zonder volatile of synchronisatie. Een thread zou theorieel de hoge 32 bits van de ene berekende hash en de lage 32 bits van een andere kunnen waarnemen, wat zou resulteren in een volledig onjuiste, niet-nul hashwaarde die de plaatsing van de HashMap-bakken zou kunnen corrumperen. Daarom vereist het cachen van 64-bits hashes volatile of AtomicLong.
Hoe verschilt dit van de defecte "Double-Checked Locking" idiom voor singleton-initialisatie?
Het cruciale onderscheid ligt in veilige publicatie en idempotentie. In defecte Double-Checked Locking is het probleem het waarnemen van een niet-nul referentie naar een object waarvan de constructor niet is voltooid (herordening van referentietoewijzing versus uitvoer van de constructor). In String.hashCode() is het String-object zelf al veilig gepubliceerd en volledig geconstrueerd; het hash-veld is slechts een lui geïnitialiseerde cache van pure gegevens. Het waarnemen van 0 (niet-geïnitialiseerd) is geen gedeeltelijke constructie, maar een geldige initiële toestand. Bovendien is de bewerking idempotent—meerdere threads die dezelfde berekende waarde schrijven levert hetzelfde resultaat op als één thread, terwijl DCL precies één instantiecreatie vereist.