JavaProgrammierungSenior Java Backend Entwickler

Warum erforderten spätere Überarbeitungen des Java-Speichermodells volatile Semantik, um das Muster der doppelt geprüften Sperrung zu sichern?

Bestehen Sie Vorstellungsgespräche mit dem Hintsage-KI-Assistenten

Antwort auf die Frage

Geschichte

Vor Java 5 litt das Java-Speichermodell (JMM) unter schwachen Garantien für die Sichtbarkeit von Speicher, die viele beliebte Nebenläufigkeitsidiome unsicher machten. Das Muster der doppelt geprüften Sperrung tauchte Ende der 1990er Jahre als vermeintliche Leistungsoptimierung für die verzögerte Initialisierung auf, enthielt jedoch einen fatalen Fehler bezüglich der Anweisungneuordnung. JSR-133 definierte 2004 die Semantik des volatile-Schlüsselworts neu, um eine Erwerb-Freigabe-Speicherordnung bereitzustellen, die speziell dazu dient, solche Sichtbarkeitsprobleme ohne die Kosten einer vollständigen Synchronisation zu lösen.

Problem

Ohne volatile ist die JVM und die zugrunde liegenden CPU-Architekturen berechtigt, Anweisungen so umzuschichten, dass die Zuweisung eines Verweises an eine Variable vor dem Abschluss der Ausführung des Konstruktors erfolgt. Dies schafft ein Fenster, in dem ein anderer Thread einen nicht-null Verweis auf ein Objekt beobachten kann, dessen Felder Standard- oder nicht initialisierte Werte enthalten, was zu unvorhersehbarem Verhalten oder NullPointerException führt. Das Nebeneinander ist besonders heimtückisch, da es nur unter bestimmten Timing-Bedingungen und Hardware-Speichermodellen auftritt, was es schwierig macht, während des Tests zu reproduzieren.

Lösung

Die Deklaration des Instanzfelds als volatile fügt eine Speicherbarriere ein, die eine happens-before Beziehung zwischen dem Schreiben im Konstruktor und etwaigen nachfolgenden Lesevorgängen von anderen Threads herstellt. Dies verhindert, dass der Compiler und der Prozessor das Schreiben in das volatile Feld mit den vorhergehenden Schreibvorgängen im Konstruktor umschichten, und stellt sicher, dass das Objekt vollständig konstruiert ist, bevor sein Verweis sichtbar wird. Das Muster ermöglicht es Threads, den Verweis nach der Initialisierung ohne Sperre zu überprüfen, was sowohl Thread-Sicherheit als auch hohe Leistung bietet.

public class ConnectionPool { private static volatile ConnectionPool instance; private ConnectionPool() { // Aufwendige Initialisierung } public static ConnectionPool getInstance() { if (instance == null) { synchronized (ConnectionPool.class) { if (instance == null) { instance = new ConnectionPool(); } } } return instance; } }

Lebenssituation

Ein hochgradig leistungsfähiger Mikrodienst, der die Zahlungsabwicklung handhabt, benötigte einen Singleton ConnectionPool, um JDBC-Verbindungen zu einem PostgreSQL-Cluster zu verwalten. Während der Spitzenzeiten riefen Tausende von Threads gleichzeitig getInstance() auf, als der Dienst zum ersten Mal gestartet wurde, was eine threadsichere Initialisierungsstrategie erforderte, die die Sperrenkonkurrenz minimierte. Die Initialisierungssequenz umfasste das Einrichten von TCP-Sockets, das Zuweisen von direkt steuerbaren Byte-Puffern und das Ausführen von Schema-Validierungsabfragen, wodurch eine begleitende Initialisierung für Autoskalierungs-Szenarien prohibitiv teuer wurde.

Eager Initialization

Die Eager Initialization erforderte die Erstellung des Pools in einem statischen Initialisierungsblock. Dieser Ansatz garantierte Thread-Sicherheit durch Klassenlade-Mechanismen und beseitigte die Notwendigkeit für synchronized Blöcke vollständig. Allerdings benötigte die Verbindungsherstellung drei Sekunden für TCP-Handshakes und den Austausch von Anmeldeinformationen, was die Anforderungen des Dienstleistungsniveaus für Kaltstartzeiten während Autoskalierungsereignissen verletzte.

Synchronisierte Methode

Die Synchronisierte Methode umhüllte die getInstance()-Methode mit dem synchronized Schlüsselwort. Während dies die Wettbewerbsbedingungen durch die Serialisierung aller Zugriffe korrigierte, führte es zu schwerwiegenden Leistungseinbußen unter Last. Profilierung ergab, dass Threads nach der Initialisierung unnötige Zyklen mit dem Erwerb des Monitorlocks verbrachten, trotz der unveränderlichen Natur des vollständig konstruierten Pools, was etwa 18 Millisekunden Latenz pro Aufruf hinzufügte.

Doppelt geprüfte Sperrung mit volatile

Die Doppelt geprüfte Sperrung mit volatile wurde als die optimale Herangehensweise ausgewählt. Diese Lösung nutzte einen unsynchronisierten Schnellzugang zum Überprüfen auf null, gefolgt von einem synchronized Block für den kritischen Abschnitt, mit einer zweiten Nullüberprüfung im Inneren, um mehrere Instantiierungen zu verhindern. Der volatile Modifier stellte sicher, dass der vollständig initialisierte Poolzustand für alle CPU-Kerner sofort nach der Veröffentlichung sichtbar war, wodurch eine verzögerte Initialisierung mit null Sperren nach dem Starten im Gleichgewicht war.

Die gewählte Lösung führte zu einer erfolgreichen verzögerten Initialisierung ohne Blockieren, sodass der Dienst 50.000 Anfragen pro Sekunde mit Antwortzeiten im Sub-Millisekundenbereich nach der ersten Poolerstellung abwickeln konnte. Die Implementierung beseitigte Wettbewerbsbedingungen während des Starts, während der sperrenfreie Zugriff während des Betriebs im stabilen Zustand aufrechterhalten wurde, was dazu beitrug, die beobachteten NullPointerException-Instanzen zu verhindern, die zuvor in Hochkonkurrenzszenarien aufgetreten waren. Das Monitoring bestätigte, dass die JVM die Sichtbarkeit des Speichers über alle 64 Kerne korrekt handhabte, ohne explizite Synchronisation, nachdem der Singleton etabliert war.

Was Kandidaten oft übersehen

Warum erfordert das Muster der doppelt geprüften Sperrung zwei unterschiedliche Nullüberprüfungen anstelle einer einzigen synchronisierten Überprüfung?

Die erste Überprüfung erfolgt außerhalb des synchronized-Blocks, um einen schnellen, sperrenfreien Pfad für den häufigen Fall bereitzustellen, in dem die Instanz bereits existiert. Die zweite Überprüfung im Inneren des synchronized-Blocks ist wesentlich, da mehrere Threads gleichzeitig die erste Nullüberprüfung passieren können, während die Instanz noch uninitialisiert ist. Ohne diese zweite Bestätigung würde jeder Thread sequenziell die Sperre erwerben und separate Instanzen erstellen, was die Singleton-Eigenschaft verletzt. Die innere Überprüfung stellt sicher, dass nur der erste Thread, der in den kritischen Abschnitt eintritt, die Konstruktion durchführt, während nachfolgende Threads die Instanz bereits initialisiert entdecken und die Erstellung überspringen.

Wie unterscheidet das Java-Speichermodell zwischen den Sichtbarkeitsgarantien eines volatile Schreibvorgangs und einem Ausstieg aus einem synchronisierten Block?

Beide Konstrukte stellen happens-before Beziehungen her, aber sie operieren auf unterschiedlichen Granularitäten und Leistungsmerkmalen. Ein Ausstieg aus einem synchronized Block leert alle modifizierten Variablen im Arbeitspeicher des Threads in den Hauptspeicher und fungiert als globale Speicherbarriere. Im Gegensatz dazu verhindert ein volatile Schreibvorgang speziell die Umordnung dieser bestimmten Variablen mit umgebenden Anweisungen und sorgt dafür, dass der Schreibvorgang sofort sichtbar ist. Vor Java 5 fehlten volatile diese Garantien, wodurch es unzureichend für eine sichere Veröffentlichung war; das moderne JMM behandelt volatile-Schreibvorgänge ähnlich wie C++ Freigabeoperationen und Lesevorgänge als Erwerbsoperationen und bietet gezielte Sichtbarkeit ohne die vollen Kosten des Monitorlocks.

Können unveränderliche Objekte die Notwendigkeit von volatile im Muster der doppelt geprüften Sperrung beseitigen?

Nein, denn final-Felder garantieren Unveränderlichkeit erst nachdem der Konstruktor abgeschlossen ist, nicht während der Veröffentlichung des Verweises selbst. Ohne volatile kann eine Anweisungneuordnung dazu führen, dass der Verweis vor dem Abschluss der Ausführung des Konstruktors in den Hauptspeicher geschrieben wird, sodass ein anderer Thread einen nicht-null Verweis auf ein teilweise konstruiertes Objekt beobachten kann. Während final-Felder sicherstellen, dass Werte nach der Konstruktion nicht mehr geändert werden können, verhindern sie nicht die Sichtbarkeit der Standard- oder nicht initialisierten Werte, wenn der Verweis vorzeitig entkommt. Eine sichere Veröffentlichung erfordert entweder volatile oder synchronized, um die happens-before Beziehung zwischen Konstruktion und Sichtbarkeit sicherzustellen, unabhängig von der internen Unveränderlichkeit des Objekts.