Il Java Memory Model (JMM) garantisce che una volta che un costruttore è completato, le scritture ai campi final diventino visibili a qualsiasi thread che legge il riferimento dell'oggetto, a condizione che quel riferimento non sia sfuggito durante la costruzione. Se il riferimento this sfugge prematuramente—passando a un altro thread o memorizzato in una collezione statica prima che il costruttore restituisca—il bordo happens-before tra la scrittura del costruttore al campo final e la lettura dell'altro thread viene interrotto. Di conseguenza, il thread osservatore può vedere il valore predefinito (zero, false o null) invece del valore costruito, infrangendo l'apparente immodificabilità. La pubblicazione sicura richiede che nessun riferimento all'oggetto in costruzione sfugga fino alla terminazione della costruzione, garantendo che l'operazione di freeze sui campi final avvenga prima che qualsiasi thread possa caricare il riferimento.
Ci siamo imbattuti in questo in un sistema di trading ad alta frequenza dove le istanze di Service si registravano in una ConcurrentHashMap globale durante i loro costruttori per facilitare la ricerca. La classe definiva un final long instrumentId, inizializzato da un parametro del costruttore, eppure i thread di monitoraggio leggeva sporadicamente zero quando interrogavano il registro immediatamente dopo la creazione.
Una soluzione proposta era dichiarare instrumentId come volatile invece che final, sperando di forzare la visibilità immediata tra i core. Questo approccio garantiva atomicità e visibilità, ma rinunciava al contratto di immutabilità e comportava un costo di barriera di memoria completa su ogni lettura, degradando inutilmente il throughput per un valore che non cambiava mai dopo la costruzione e complicando il ragionamento sulla stato dell'oggetto.
Un'altra proposta riguardava la sincronizzazione di tutti gli accessi al registro con blocchi synchronized che racchiudevano la logica del costruttore, teorizzando che il locking avrebbe svuotato le cache di memoria. Anche se questo ha prevenuto le condizioni di gara, ha introdotto una forte contesa sul lock del registro globale, trasformando una struttura concorrente in un collo di bottiglia seriale e violando i rigidi requisiti di latenza per l'acquisizione dei dati di mercato.
Abbiamo scelto un pattern di fabbrica che ha disaccoppiato l'istanza dalla registrazione. Il costruttore è rimasto privato, il metodo di fabbrica ha invocato new Service(id) completamente, e solo dopo ha pubblicato il riferimento completamente formato nella ConcurrentHashMap. Questo ha sfruttato la semantica di freeze dei campi final del JMM senza sovraccarico di sincronizzazione, assicurando che l'instrumentId fosse visibile immediatamente al momento del recupero.
La modifica ha eliminato le anomalie di visibilità zero e ha ripristinato la latenza attesa in microsecondi per la ricerca del servizio, mantenendo l'intento di design immutabile.
Perché final non garantisce la visibilità se pubblico semplicemente il riferimento attraverso una collezione thread-safe come ConcurrentHashMap?
La relazione happens-before fornita dalle operazioni put e get di ConcurrentHashMap stabilisce un ordinamento tra i cambiamenti di stato interni della mappa, non tra le scritture del costruttore e la pubblicazione nella mappa. Se this sfugge durante la costruzione, la scrittura al campo final avviene in un thread mentre la pubblicazione nella mappa avviene contemporaneamente, mancando il bordo happens-before necessario per prevenire il riordino delle istruzioni. Pertanto, il thread di lettura può osservare il riferimento attraverso la mappa prima che le scritture del costruttore siano svuotate nella memoria principale, vedendo il valore predefinito.
Posso risolvere questo rendendo il campo del registro volatile invece dei campi dell'oggetto?
Contrassegnare il riferimento del registro come volatile garantisce solo che le modifiche alla variabile del registro siano visibili, non lo stato interno degli oggetti che contiene. Poiché il problema riguarda il tempismo delle scritture ai campi dell'oggetto rispetto alla visibilità del riferimento, volatile sul contenitore non stabilisce l'ordinamento necessario tra il costruttore e il consumatore dell'oggetto. Continuerebbe a osservare istanze parzialmente costruite.
L'uso di synchronized all'interno del costruttore previene la pubblicazione non sicura?
Inserire synchronized sul costruttore o usarlo per proteggere la registrazione impedisce ad altri thread di entrare nella sezione critica contemporaneamente, ma non impedisce al riferimento this di sfuggire se il metodo di registrazione fa sfuggire il riferimento a un thread che opera al di fuori di quel lock. Il JMM richiede specificamente che nessun riferimento all'oggetto sfugga prima che il costruttore finisca affinché le semantiche dei campi final siano valide; la sincronizzazione senza un corretto ordinamento di pubblicazione non può ripristinare quella garanzia.