Die Garantie leitet sich von der Java Memory Model (JMM) Happens-before-Regel ab, die mit der Klasseninitialisierung verbunden ist. Wenn die JVM zuerst auf ein statisches Feld oder eine Methode einer Klasse zugreift, muss sie zuerst die Initialisierungsphase der Klasse abschließen. In dieser Phase werden die statischen Initialisierungs-Blöcke und Feldzuweisungen unter einem internen Lock, der einzigartig für dieses Klassenobjekt ist, ausgeführt. Folglich bildet jede Schreiboperation innerhalb des statischen Initialisierers – wie die Konstruktion der Singleton-Instanz – eine Happens-before-Kante mit jedem nachfolgenden Lesevorgang dieses Feldes durch Threads, die auf die Klasse zugreifen, und gewährleistet volle Sichtbarkeit des konstruierten Zustands, ohne dass synchronized-Schlüsselwörter oder volatile-Deklarationen erforderlich sind.
public class ConnectionPool { private ConnectionPool() { // teurer TCP-Handshake und Thread-Erzeugung } private static class Holder { static final ConnectionPool INSTANCE = new ConnectionPool(); } public static ConnectionPool getInstance() { return Holder.INSTANCE; // Löst die Initialisierung der Holder-Klasse aus } }
Problem: Eine Finanzhandelsanwendung benötigte ein ConnectionPool-Singleton, das aufgrund der anfänglichen TCP-Handshakes und der Thread-Erzeugung teuer zu konstruieren war, aber möglicherweise in bestimmten leichten Diagnosetypen nicht benötigt wurde. Eager-Initialisierung würde Hunderte von Millisekunden während des Starts verschwenden, selbst wenn der Pool ungenutzt bliebe, während das Double-Checked Locking sorgfältige Handhabung von volatile-Semantiken und Anordnungsbarrieren erforderte, um eine Umordnung von Anweisungen zu verhindern.
Lösung 1: Eager-Initialisierung: Dieser Ansatz initialisiert das statische Feld beim Laden der Klasse, was trivial zu implementieren ist und vom JVM garantierte Thread-Sicherheit bietet. Es erfüllt jedoch nicht die Anforderung, die Konstruktion zu vermeiden, wenn der Pool niemals zugegriffen wird, und verschwenderische Ressourcen in Diagnosetypen verschwendet und die Startzeit ohne Grund verlängert.
Lösung 2: Synchronized Accessor: Das Einwickeln des Getters in synchronized sorgt für Sicherheit über alle Threads hinweg und ist unkompliziert zu codieren. Leider zwingt es jeden Aufrufer, einen Monitor zu erwerben, auch nachdem die Instanz existiert, was unter Hochfrequenzhandelsbelastungen, bei denen Mikrosekunden wichtig sind und Threads um dasselbe Lock konkurrieren, zu einem schweren Engpass führt.
Lösung 3: Initialisierung-auf-Anfrage Halter: Dies definiert eine private statische Klasse ConnectionPoolHolder, die eine statische finale ConnectionPool-Instanz enthält, wobei getInstance einfach ConnectionPoolHolder.INSTANCE zurückgibt. Es nutzt das latente Klassenladen der JVM: Die Halterklasse wird nur dann initialisiert, wenn getInstance aufgerufen wird, und das Klassenaustrocknungslock gewährleistet eine sichere Veröffentlichung ohne explizite Synchronisation oder volatile-Overhead.
Gewählte Lösung: Das Team wählte das Halter-Idiom aufgrund seiner Null-Überhead-Performance nach der Initialisierung und garantierten Sicherheit unter dem Java Memory Model, da es die latente Initialisierung perfekt mit der Effizienz zur Laufzeit in Einklang brachte.
Ergebnis: Die Anwendung erreichte eine Unter-Mikrosekunden-Zugriffsverzögerung für den Poolverweis unter gleichzeitiger Last, während die aufwendige Initialisierung bis zur ersten Nutzung verschoben wurde, wodurch die Startüberlastung in Diagnosetypen beseitigt und während hochvolumiger Handelssitzungen frei von Wettbewerbsbedingungen blieb.
Was passiert mit nachfolgenden Threads, wenn der Singleton-Konstruktor während der Initialisierung der Halterklasse eine Ausnahme auslöst?
Wenn der statische Initialisierer eine Ausnahme auslöst, kennzeichnet die JVM die Klasse als fehlgeschlagen in der Initialisierung und löst einen ExceptionInInitializerError aus (der die Ursache einwickelt). Entscheidend ist, dass jeder nachfolgende Thread, der versucht, auf ConnectionPoolHolder zuzugreifen, einen NoClassDefFoundError erhält, selbst wenn die zugrunde liegende Ursache vorübergehend war (wie vorübergehende Netzwerkuntüchtigkeit). Im Gegensatz zu Double-Checked Locking, das möglicherweise die Konstruktion innerhalb von Catch-Blöcken wiederholen könnte, erfordert das Halter-Idiom externe Wiederherstellungslogik, da die Klasse während der Lebensdauer des definierenden ClassLoader in einem fehlgeschlagenen Initialisierungszustand bleibt.
Kann das Initialisierung-auf-Anfrage Haltermuster für instanzbasierte Singletons innerhalb eines Multi-Tenant-Containers angepasst werden?
Nein. Das Muster basiert streng auf statischen Feldern und Klasse-Initialisierungslöchern. Für instanzbasierte oder pro-Mieter Singletons müsste der Halter eine innere Klasse im Mieterkontext sein, aber Klasseninitialisierungslöcher sind pro ClassLoader, nicht pro Containerinstanz. Dies führt entweder zu einer gemeinsamen Nutzung von Instanzen über Mieter hinweg (ein Sicherheits- und Isolationsrisiko) oder zu einer expliziten Synchronisation innerhalb der Mieterinstanz, was dem Zweck des Musters, einen lockfreien Zugriff zu ermöglichen, zuwiderläuft. Kandidaten verwechseln oft klassenbasiertes lazy-loading mit objektbasiertem lazy-loading.
Wie verhält sich dieses Idiom, wenn mehrere ClassLoader-Hierarchien in Anwendungsserverumgebungen beteiligt sind?
Jeder ClassLoader initialisiert seine eigene Kopie der Halterklasse unabhängig. In Tomcat oder WildFly, wenn die Singleton-Klasse sowohl in der Webanwendung als auch im gemeinsamen Eltern-Loader vorhanden ist, oder wenn die Webanwendung neu bereitgestellt wird (was einen neuen ClassLoader erstellt), werden unterschiedliche Instanzen existieren. Dies verletzt den Singleton-Vertrag über den JVM-Prozess hinweg. Das Muster gewährleistet die Thread-Sicherheit innerhalb eines einzigen Klassenzuordnungsnamensraums, bietet jedoch keine globalen JVM-Singleton-Semantiken, eine kritische Unterscheidung in modularen Umgebungen, in denen Klassenladerisolation durchgesetzt wird.