Bisher war ich immer der Meinung, dass Lazy-Initialization-Konstrukte in Multi-Threading-Umgebungen immer dann “kaputt” sind, wenn sie in Java das Double-Checked-Locking-Pattern einsetzen. Ein englischsprachiger Wikipediaartikel belehrte mich nun eines besseren.
In Java könnte man ein Feld wie folgt lazy initialisieren:
class foo { private Type lazy; public Type getLazy() { if (lazy == null) { lazy = new Type(); } return lazy; }
In Multi-Threading-Umgebungen ist das aber böse, da es beim konsekutiven Zugriff auf die Methode zu undefinierten Zuständen des Attributs lazy kommen kann. Das liegt am Memory-Modell von Java, welches Reordering erlaubt.
Die einfachste Lösung wäre nun, die Methode mit dem synchronized-Schlüsselwort zu kennzeichnen:
class Foo { Type lazy; public synchronized Type getLazy() { ... } }
Dies würde aber dazu führen, dass jeder Zugriff potentiell langsamer wird, denn eigentlich müsste die Methode nur einmal, und zwar beim ersten Aufruf durch einen Thread, synchronisiert werden, in allen anderen Fällen nicht. Das bedeutet, dass alle anderen Zugriffe nebenläufig erfolgen können, sofern die Instanzvariable korrekt initialisiert worden ist.
Double-Checked-Locking vor Java 5 funktioniert nicht!
Eine Lösung für dieses Problem ist nun das Double-Checked-Locking. Dieses synchronisiert nur denjenigen Codeblock innerhalb der Methode, in dem die Variable initialisiert wird:
class Foo { Type lazy; public Type getLazy() { if (lazy == null) { synchronized (this) { if (lazy == null) { lazy = new Type(); } } } return lazy; } }
Double-Checked bedeutet, dass zwei Mal geprüft wird, ob die Instanzvariable null ist. Einmal vor dem synchronized-Block, ein zweites Mal im Block. Der Grund ist die Mehrläufigkeit: Zwei Threads könnten gleichzeitig diese Methode durchlaufen, während lazy noch null ist. Genau dann kommen beide in den synchronized-Block. Ein Thread kann dann in Ruhe lazy initialisieren, während der andere wartet. Damit nun der zweite Thread nicht auch noch eine Initialisierung durchführt, wird im synchronized-Block erneut auf null geprüft.
Das alles sieht eigentlich recht schlüssig aus, wenn alle Operationen so atomar wären, wie sie auf den ersten Blick aussehen. Sind sie aber nicht. Insbesondere die Methoden zum Allozieren von Speicher und Initialisieren von Klassenexemplaren sind mehrschrittig. Es kann somit sein, Dass ein zweiter Thread genau dann die Methode aufruft, wenn Thread eins gerade im synchronized-Block die Variable lazy initialisiert. Wenn lazy nun noch nicht vollständig erzeugt worden ist, könnte Thread zwei trotzdem noch eine Referenz auf lazy erhalten, weil lazy nicht mehr null ist. lazy ist aber dann unvollständig initialisiert. Was bei einem Zugriff auf lazy passiert, ist folglich undefiniert.
Lösung in Java 5: volatile
Was tun? Seit Java 5 ist die Lösung einfach: Man deklariert die Variable lazy mit volatile. Dadurch garantiert Java, dass die Initialisierungsoperation auf jeden Fall vollständig durchgeführt wird, ohne dass die jvm durch Umordnung von Befehlen diese Atomizität durchbricht.
class foo { private volatile Type lazy; public Type getLazy() { if (lazy == null) { synchronized (this) { if (lazy == null) { lazy = new Type(); } } } return lazy; } }
Eine noch offene Frage ist, inwiefern die Lösung mit volatile perfomancetechnisch besser ist, als die gesamte Methode mit synchronized zu deklarieren. Für Singletons erübrigt sich diese Frage eigentlich, da man hier auf das Initialization-On-Demand-Holder-Muster zurückgreifen kann.