|
||||||||||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | ||||||||||||||||||||||||||||||||
|
volatile und das Double-Check-Idiom
|
|||||||||||||||||||||||||||||||
Wir haben in den letzten Beiträgen diskutiert, dass Synchronisation mit Hilfe von Locks die Skalierbarkeit bei Mulit-Core- und Multiprozessor-Architekturen einschränkt (/ JMM3 /). Um dies zu vermeiden, kann die Daten-Synchronisation durch die Verwendung von volatile-Variablen erreicht werden. Dazu haben wir uns im letzten Beitrag im Detail angesehen, welche Speichereffekte der lesende bzw. schreibende Zugriff auf volatile Variablen hat(/ JMM4 /). Die dort verwendeten Beispiele hatten aber eher didaktischen Charakter. Was uns also bis heute noch fehlt, ist ein überzeugendes Beispiel aus der Praxis, das zeigt, wie die Verwendung von volatile die Skalierbarkeit verbessert. Das liefern wir in diesem Artikel nach. AusgangssituationWir hatten in unserem letzen Beitrag schon erwähnt, dass volatile und Synchronisation mit Locks keine völlig gleichwertigen Konzepte sind (/ JMM4 /). Deshalb gibt es auch kein einfaches Kochrezept’, das man anwenden kann, um Locks durch volatile-Variablen zu ersetzen, um so die parallele Ausführbarkeit des Programms zu verbessern. Im Allgemeinen wird man nach Einführung der volatile-Variablen auch nicht immer vollständig auf das Locking verzichten können, sondern nur bei einem Teil der Zugriffe. Sind dies aber die Zugriffe, die in der Praxis am häufigsten vorkommen, so hat man das Wesentliche (nämlich eine Performanceverbesserung) schon erreicht.Kommen wir zu unserem Beispiel. Es geht darum, ein privates Feld in einer Klasse nicht bei der Konstruktion, sondern beim ersten Zugriff zu initialisieren (lazy initialization): public class MyClass {Um fehlerhafte Mehrfach-Initialisierung auszuschließen, ist die getMyField-Methode synchronized, d.h. wir verwenden das mit this assoziierte Lock, um die Initialisierung zu schützen. Erst einmal ist nichts gegen diese Implementierung einzuwenden: sie tut das, was sie soll, fehlerfrei. Zweifel bezüglich der Performance kommen einem aber, wenn man sich überlegt, dass das Lock im wesentlich nur für die Initialisierung benötigt wird. Nur beim allerersten Aufruf, wenn das MyField-Objekt erzeugt und seine Adresse im Feld lazyField abgelegt wird, kann eine Race Condition auftreten, die man per Synchronisation auflöst. Bei allen weiteren Aufrufen wird nur noch die Adresse abgefragt und zurückgegeben, wofür keine Synchronisation gebraucht wird. Trotzdem wird das Lock bei jedem Aufruf von getMyField() wieder benutzt.
Welche Optimierungsmöglichkeiten haben wir? Die kritische Region
können wir noch unwesentlich verkleinern und das return herausziehen.
Um die Benutzung des Locks selbst kommen wir so einfach aber nicht herum.
Wir erinnern uns noch mal daran, was beim Lesen einer volatile Variable passiert (wir hatten das im letzten Artikel detailliert diskutiert / JMM4 /): der Wert der Variable kann nicht einfach aus dem lokalen Arbeitsspeicher eines Thread genommen werden, sondern muss neu aus dem Hauptspeicher gelesen werden, um sicher zu sein, dass Updates, die andere Threads möglicherweise gemacht haben, sichtbar werden. In der ersten Implementierung des Double-Check-Idioms wird die volatile Variable lazyField im Nicht-Initialisierungsfall zweimal gelesen: einmal beim Vergleich (Zeile 2) und einmal beim return (Zeile 8). In beiden Fällen wird ein Refresh’ vom Hauptspeicher gemacht. Der zweite Refresh’ ist aber im Nicht-Initialisierungsfall überflüssig, da wir wissen, dass der Wert von lazyField sich nach der Initialisierung nicht mehr ändert. Das heißt, das Feld lazyField ist sowas wie semi final’. Es wird genau einmal gesetzt. Da es sich um eine lazy initialization handelt, erfolgt das Setzen aber nicht im Konstruktor sondern in getMyField(). Deshalb kann das Feld nicht wirklich als final deklariert werden. Trotzdem ändert es sich im weiteren Ablauf des Programms nicht mehr. Da Java-Compiler und Java-Laufzeitsystem nichts von dieser semi final’ Eigenschaft wissen, können sie keine Optimierung vornehmen und auf den zweiten Refresh’ vom Hauptspeicher nicht verzichten. Das bedeutet, wir müssen die Optimierung selbst machen. So kommt dann die Variante von Joshua Bloch heraus. Dort wird im Nicht-Initialisierungsfall nur einmal lesend auf lazyField zugegriffen, nämlich bei der Zuweisung an die lokale Variable tmp. Die Performance-Verbesserung auf Grund dieser Optimierung beträgt laut Joshua Bloch rund 25%. Noch zwei Kommentare zum Double-Check-Idiom: Wenn man sich die drei verschiedenen Implementierungen ansieht:
Vollständigkeitshalber sei noch erwähnt, dass das Double-Check-Idiom nicht die beste und einzige Lösung ist, wenn es darum geht, ein static Feld lazy zu initialisieren. Solche verzögerten Initialisierungen von statischen Feldern treten zum Beispiel im Zusammenhang mit Singletons auf. Hier eignet sich das Holder-Class-Idiom besser. Es basiert im wesentlichen darauf, das Problem der einmaligen Initialisierung an das Laufzeitsystem der JVM zu delegieren. Wir wollen diese Lösung hier aber nicht diskutieren, weil sie überhaupt gar nichts mit dem Thema volatile und dem Java Memory Model zu tun hat. Details zum HolderClass-Idiom finden sich aber auch im Item 71 von Joshua Blochs Buch. Single-Check-IdiomeStattdessen wollen wir uns noch zwei Varianten des Double-Check-Idioms ansehen, an denen man Effekte des Memory Models diskutieren kann: Single-Check-Idiom und Racy-Single-Check-Idiom (beide sind auch in Joshua Bochs Buch erwähnt). Es geht dabei darum, ob und unter welchen Umständen man die Datensynchronisation verringern kann, d.h. den synchronized-Block und das volatile weglassen, um so vielleicht die Performance zu verbessern.Das Single-Check-Idiom sieht so aus, wobei wir hier die Version mit nur einem Refresh’ unter Verwendung einer temporären Variablen betrachten: public class MyClass {Da der synchronized-Block fehlt, kann es hierbei natürlich zu mehrfachen Initialisierungen kommen. Das heißt, für mehrere Threads könnte tmp == null sein und jeder von ihnen würde dann das Feld lazyField initialisieren. Das Objekt, dessen Referenz als letztes an lazyField zugewiesen wird, ist dann das Objekt, welches anschließend allen Threads sichtbar ist, da das Feld lazyField als volatile deklariert ist. Diese Mehrfachinitialisierung muss aber kein Problem sein. Es gibt Situationen, wo dies toleriert werden kann. Zum Beispiel, wenn in jedem Thead das gleiche Objekt erzeugt wird und MyField ein nicht veränderbarer (immutable) Typ ist. Von der Performance her ist das Single-Check-Idiom dem Double-Check-Idiom nicht wirklich überlegen. Da sich beide nur während der Initialisierung unterscheiden, dürften die Performanceunterschiede für den gesamten Programmablauf nicht signifikant sein. Für den Fall von Thread-Kollisionen bei der Initialisierung haben beide Lösungen ihre spezifischen Nachteile, die sich schlecht gegeneinander aufrechnen lassen:
Bei der zweiten Variante des des Double-Check-Idioms, dem Racy-Single-Check-Idiom, fällt nicht nur der synchronized-Block weg, sondern auch noch das volatile. Ein Beispiel für das Racy-Single-Check-Idiom (mit int) sieht dann so aus: public class MyClass {Da das lazyField nicht mehr volatile ist, braucht man auch keine temporäre Variable mehr zur Optimierung. Von der Performance her ist diese Implementierung optimal, da hier gar keine expliziten Speichereffekte mehr getriggert werden. Sie ist aber nur sehr eingeschränkt verwendbar. Hier ist nämlich nicht mehr gesichert, dass ein Thread die Initialisierung sieht, die von einem anderen Thread zuvor durchgeführt wurde. Ausgeschlossen ist es aber auch nicht, da die Sichtbarkeit durch andere Effekte hergestellt werden kann, zum Beispiel durch benutzerseitige Synchronisation. Das wäre der Fall, wenn der Aufruf der Methode getMyField() in einem synchronized-Block erfolgt, der von beiden Threads durchlaufen wird. Dann passiert die Synchronisation von Außen und nicht in der Klasse MyClass selbst. In so einer Situation ist das Racy-Single-Check-Idiom durchaus sinnvoll. Natürlich kann es auch beim Racy-Single-Check-Idiom (wie beim Single-Check-Idiom) passieren, dass das Feld mehrmals initialisiert wird, weil es keine Synchronisation mehr gibt. Beim Racy-Single-Check-Idiom gibt es aber noch zwei andere Probleme. Wenn das Feld, das lazy initialisiert werden soll, kein int-Wert ist, sondern vom Typ long oder double, dann ist der Zugriff darauf nicht atomar. Es könnte also passieren, dass das Lesen des lazy-Felds einen sinnlosen Wert liefert. Andere Probleme gibt es, wenn das Feld eine Referenz auf ein Objekt ist. Dann ist zwar der Zugriff auf die Adresse atomar, aber es gibt keine Garantie, dass der lesende Thread das referenzierte Objekt in einem konsistenten Zustand sieht. Schließlich ist ohne Synchronisation oder volatile nicht gewährleistet, dass die Felder des Objekts jemals sichtbar gemacht werden. Kann man bei all den Einschränkungen mit dem Racy-Single-Check-Idiom überhaupt etwas anfangen? In der Theorie macht es Sinn, sich das Racy-Single-Check-Idiom zur Abgrenzung vom Single-Check-Idiom einmal vor Augen zu führen. In der Praxis sind Anwendungsfälle, bei denen das Racy-Single-Check-Idiom sinnvoll zum Einsatz kommt, eher selten. Es gibt aber Anwendungsfälle, zum Beispiel den HashCode im String. Da wird das int-Feld in der Klasse java.lang.String, das den Hashcode des String cacht, mit dem Racy-Single-Check-Idiom in der Methode hashCode() lazy-initialisiert - ohne Synchronisation und ohne volatile. Hier ist der relevante Auszug aus der Implementierung der Klasse java.lang.String: public final class StringIn diesem Anwendungsfall macht es keine Probleme, dass der eine Thread, der hashCode() aufruft, u.U. nicht sehen kann, was ein anderer Thread zuvor in dem hash-Feld abgelegt hat. Da die HashCode-Berechnung sowieso immer dasselbe Ergebnis liefert, ist es egal, ob ein Thread den Wert sieht, den er gerade selber ausgerechnet hat, oder den Wert, den zuvor ein anderer Thread berechnet hat. Es macht auch nichts, dass das Feld eventuell zweimal initialisiert wird, weil sich der HashCode eines Strings nicht ändern kann. Also kann es nicht passieren, dass die zweite Initialisierung einen vom Default- und Initialwert abweichenden "aktuellen" Wert des hash-Feldes überschreibt. Das Racy-Single-Check-Idiom funktioniert hier natürlich nur, weil java.lang.String ein unveränderlicher (immutable) Typ ist; sonst wäre der HashCode nämlich nicht immer gleich und dann wäre eine Lösung ohne Synchronisation und ohne volatile falsch. In einem der nächsten Beitrag wollen wir uns dann einen weiteren Anwendungsfall für das Racy-Single-Check-Idiom ansehen. Wie wir oben schon erwähnt habe, ist Racy-Single-Check-Idiom problematisch, wenn das lazy-initialisierte Feld eine Referenz auf ein Objekt ist. Das gibt aber nur, wenn das referenzierte Objekt veränderlich ist. Bei der Verwendung eines unveränderlichen (immutable) Typs ist die Initialisierung ohne Synchronisation und ohne volatile durchaus sinnvoll. Dazu muss der unveränderliche Typ aber korrekt implementiert sein und dazu werden wiederum die Speichergarantien für final-Felder gebraucht - und das wollen wir uns in den nächsten Beiträgen genauer ansehen. ZusammenfassungIn diesem Beitrag haben wir die Speichereffekte von volatile Variablen am konkreten Beispiel des Double-Check-Idioms und einiger seiner Varianten diskutiert. In unserem nächsten Beitrag wollen wir uns noch einmal typische Anwendungsidiome für volatile ansehen.Literaturverweise und weitere Informationsquellen
Die gesamte Serie über das Java Memory Model:
|
||||||||||||||||||||||||||||||||
© Copyright 1995-2015 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/41.JMM-DoubleCheck/41.JMM-DoubleCheck.html> last update: 22 Mar 2015 |