Prima della specifica JSR 133 (Java 5), il Java Memory Model mancava di regole formali happens-before, rendendo pericolose le corse di dati innocue. String è sempre stata una classe immutabile critica per le prestazioni, utilizzata pesantemente nelle operazioni HashMap. Le prime versioni di JDK hanno introdotto la memorizzazione degli hash pigra per evitare di ricalcolare ripetutamente l'hash per stringhe lunghe. La decisione di omettere volatile sul campo hash era un'ottimizzazione deliberata precedente ai moderni primitivi di concorrenza, basata sulla natura idempotente del calcolo e sulle specifiche garanzie di atomicità aggiunte al JLS in Java 5.
Quando più thread invocano hashCode() contemporaneamente su una nuova String, possono tutti osservare il valore predefinito di 0 nel campo hash. Senza sincronizzazione, questo crea una corsa di dati in cui diversi thread potrebbero calcolare simultaneamente il valore dell'hash e tentare di scriverlo nuovamente. La sfida è garantire che nessun thread osservi mai un valore hash parzialmente scritto (lacerato) o uno stato incoerente, evitando nel contempo il costo proibitivo delle barriere di memoria associate alle letture e scritture volatile ad ogni invocazione di hashCode().
La soluzione si basa su due proprietà fondamentali del JMM. In primo luogo, il Java Language Specification (§17.7) garantisce che le scritture su valori primitivi a 32 bit (int) siano atomiche, prevenendo la lacerazione delle parole. In secondo luogo, il costruttore di String stabilisce una relazione happens-before attraverso il suo campo final value, garantendo che l'array di supporto sia completamente visibile a qualsiasi thread che riceve il riferimento. Poiché il calcolo dell'hash è una funzione pura di questi dati immutabili e pubblicati in modo sicuro, la corsa per popolare la cache è innocua. Se un thread legge un 0 obsoleto, ricalcola semplicemente lo stesso valore; se legge il valore memorizzato, lo utilizza. La scrittura atomica garantisce che il valore sia completamente osservato o meno, mai corrotto.
public int hashCode() { int h = hash; // Lettura non volatile: può vedere 0 o valore memorizzato if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // Scrittura atomica: l'assegnazione a 32 bit è indivisibile } return h; }
Stavamo progettando un servizio di ingestion ad alta velocità che elaborava milioni di record CSV al secondo. Ogni record generava più chiavi String per una cache ConcurrentHashMap. Il profiling ha rivelato che i calcoli di hashCode() consumavano il 15% del tempo CPU a causa delle chiavi di stringhe lunghe.
Soluzione A: campo hash volatile. Abbiamo considerato di aggiungere volatile al campo hash in un wrapper personalizzato di String. I pro includevano la visibilità immediata tra tutti i core e la coerenza sequenziale rigorosa. Tuttavia, i contro erano gravi: i benchmark JMH mostravano un degrado delle prestazioni del 400% a causa del traffico di coerenza della cache e dei costi delle barriere di memoria su ogni operazione di mappa.
Soluzione B: synchronized hashCode(). Abbiamo testato la sincronizzazione del calcolo. I pro erano semplicità e correttezza assoluta. I contro erano la contesa catastrofica; con 32 thread, la latenza è aumentata da 2 nanosecondi a 800 nanosecondi per operazione mentre i thread erano in attesa per il monitor.
Soluzione C: corsa innocua (implementazione attuale). Abbiamo mantenuto la memorizzazione degli hash idempotente non sincronizzata. I pro erano zero sovraccarico di sincronizzazione e perfetta scalabilità con il numero di core. I contro erano teorici: calcolo ridondante occasionale se i thread si affrettavano durante il primo accesso. Abbiamo scelto Soluzione C perché il costo di ricalcolare un hash (miss nella cache) era trascurabile rispetto al costo dei protocolli di coerenza della cache (volatile) o della contesa (synchronized).
Risultato: Il sistema ha sostenuto 2,5 milioni di operazioni al secondo per core senza che hashCode() apparisse nei 100 metodi più caldi, convalidando che la corsa di dati innocua fosse il corretto compromesso architettonico per questa struttura dati immutabile.
Perché la mancanza di volatile non viola la relazione happens-before tra il thread che crea la String e il thread che calcola il suo hash?
La relazione happens-before è in realtà stabilita dalla pubblicazione sicura dell'oggetto String stesso, non dal campo hash. Quando una String viene costruita, il suo campo final value garantisce che i contenuti dell'array di supporto siano visibili a qualsiasi thread riceva il riferimento. Il campo hash è semplicemente una cache; osservare il suo valore predefinito di 0 è uno stato valido del programma che innesca semplicemente il calcolo. Il JMM garantisce che l'array value immutabile sia coerente, e poiché l'hash è derivato puramente da questi dati visibili, il calcolo produce lo stesso risultato indipendentemente da quale thread lo esegua.
Questa stessa ottimizzazione potrebbe essere applicata a un valore hash lungo a 64 bit senza utilizzare volatile?
No. Il JMM garantisce solo l'atomicità per i primitivi a 32 bit (int, float) su tutte le architetture. Per i primitivi a 64 bit (long, double), la specifica consente la lacerazione delle parole su JVM a 32 bit o su alcune architetture senza volatile o sincronizzazione. Un thread potrebbe teoricamente osservare i 32 bit alti di un hash calcolato e i 32 bit bassi di un altro, risultando in un valore hash completamente errato e non zero che corromperebbe il posizionamento dei bucket di HashMap. Pertanto, la memorizzazione nella cache degli hash a 64 bit richiede volatile o AtomicLong.
In che modo questo è diverso dal difettoso "Double-Checked Locking" per l'inizializzazione del singleton?
La distinzione critica sta nella pubblicazione sicura e nell'idempotenza. Nel Double-Checked Locking difettoso, il problema è osservare un riferimento non nullo a un oggetto il cui costruttore non è stato completato (riordino dell'assegnazione del riferimento rispetto all'esecuzione del costruttore). In String.hashCode(), l'oggetto String è già pubblicato in modo sicuro e completamente costruito; il campo hash è semplicemente una cache inizializzata pigra di dati puri. Vedere 0 (non inizializzato) non è una costruzione parziale ma uno stato iniziale valido. Inoltre, l'operazione è idempotente: più thread che scrivono lo stesso valore calcolato producono lo stesso risultato di un thread, mentre DCL richiede la creazione esatta di un'istanza.