|
|||||||||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||||||||||||
|
Über die Gefahren allzu aggressiver Optimierungen
|
||||||||||||||||||||||||||||||
Wir haben in den letzten beiden Beiträge [
JJM7
/
JMM8
] die Initialisation-Safety-Garantie für
final-Felder am Beispiel des Racy-Single-Check-Idioms besprochen. Das Racy-Single-Check-Idiom
ist eine Variante des Double-Check-Idioms [
JMM5
] und
verzichtet aus Performance-Gründen bewußt auf jegliche Form
von Synchronisation oder die Verwendung von volatile. Es handelt sich also
um eine (relativ aggressive) Optimierung, die unter gewissen Randbedingungen
manchmal sinnvoll einsetzbar ist. Um solche Optimierungen ins rechte
Licht zu rücken und zu zeigen, wie fragwürdig der Verzicht auf
Synchronisation und volatile im Allgemeinen ist, wollen wir in diesem Beitrag
einige typische Missverständnisse und Fehlerfälle diskutieren.
Racy-Single-Check und unveränderlichen TypenSehen wir uns das Racy-Single-Check-Beispiel noch einmal an. Beim Double- oder Single-Check-Idiom geht es um die verzögerte Initialisierung (lazy initialisation) eines Feldes. Beim Racy-Single-Check-Idiom muss das Feld, das "lazy" initialisiert wird, unveränderlich sein. Wenn es eine Referenz ist, dann muss die Referenz selbst unverändert bleiben und muss auf ein Objekt von einem unveränderlichen Typ verweisen. Nehmen wir mal den Typ java.lang.Integer. Dann sieht das Idiom so aus:public class MyClass {In der Klasse MyClass wird im Zusammenhang mit der Initialisierung des Feldes lazyField vom Typ java.lang.Integer weder Synchronisation noch volatile verwendet, obwohl konkurrierende Zugriffe von mehreren Threads aus erlaubt sind. Das ist die Optimierung, die beim Racy-Single-Check-Idiom gewünscht ist. Sie ist auch korrekt. Wegen des Fehlens von Synchronisation und volatile gibt es hier zwar keine Sichtbarkeitsgarantien. Es könnte deshalb sein, dass ein Thread gar nicht sieht, dass ein anderer Thread bereits die Lazy-Initialisierung gemacht hat. Das stört aber nicht; dann macht der Thread die Initialisierung eben noch einmal selber. Racy-Single-Check läßt also Mehrfach-Initialisierungen zu. Voraussetzung für dieses Idiom ist, dass das lazyField nach der Initialisierung nicht mehr geändert wird und außerdem auf ein Objekt von einem unveränderlichen Typ zeigt; sonst funktioniert das Idiom nicht. Wenn sich an dem Feld oder dem referenzierten Objekt nach der Initiasierung noch etwas ändern könnte, dann wären Mehrfach-Initialisierungen problematisch: das Feld oder das referenzierte Objekt könnten nach der Initialisierung modifiziert werden und eine erneute Initialisierung würde die bereits erfolgte Modifikation wieder zunichte machen. Damit ergäbe sich eine problematische Race Condition. Aber wenn das lazyField sich nicht ändert und auf ein Objekt von einem unveränderlichen Typ verweist, braucht man in der Tat im oben gezeigten Beispiel weder Synchronisation noch volatile. Dabei ist es wichtig, dass der Typ des referenzierten Objekts unveränderlich in einem sehr spezifischen Sinne ist. Wir haben in den letzten beiden Beiträgen (siehe [ JMM7 ] und [ JMM8 ]) erläutert, was von einem unveränderlichen Typ erwartet wird. Nicht jeder Typ, der von sich behauptet, er sei unveränderlich, ist ohne Synchronisation und volatile in einem Racy-Single-Check-Idiom verwendbar. Für einen unveränderlichen Typ genügt es nämlich nicht, dass er nur lesende und keine modifizierenden Methoden hat und alle seine Felder ihrerseits wiederum unveränderlich sind. Ein unveränderlicher Typ muss außerdem für die Sichtbarkeit seiner Inhalte sorgen; sonst kann man ihn nicht ohne Synchronisation und volatile in einem Racy-Single-Check-Idiom verwenden. Das bedeutet, ein unveränderlicher Typ muss sicherstellen, dass die unveränderlichen Inhalte des Objekts nach der Konstruktion allen benutzenden Threads sichtbar werden. Zu diesem Zweck werden alle Felder eines unveränderlichen Typs als final deklariert, damit die Initialisation-Safety-Garantie des Java Memory Modells für die Sichtbarkeit sorgt Wenn alle diese (teilweise subtilen) Randbedingungen erfüllt sind, dann kann man aus Performancegründen für eine Lazy-Initialisierung eines Feldes nach dem Racy-Single-Check-Idiom sowohl auf Synchronisation als auch auf die Verwendung von volatile verzichten. Das ist eine hoch-optimierte Lösung und es stellt sich die Frage: Sind solche Optimierungen sinnvoll? Wie oft kommt sowas vor? Braucht man für den konkurrierenden Zugriff auf ein unveränderliches Objekt grundsätzlich keine Synchronisation und niemals volatile? Wir hatten im letzten Beitrag komplett auf Synchronisation und volatile verzichtet - im wesentlichen, weil wir die Initialisation-Safety-Garantie für final-Felder diskutieren wollten, die das Java Memory Modell gibt. In diesem Beitrag wollen wir demonstrieren, wie fragil eine solch hoch-optimierte Lösung sein kann. public class MyClass {Nun ist der Racy-Single-Check ohne Synchronisation und volatile nicht mehr möglich, weil Mehrfachinitialisierungen zu Fehlern führen könnten. Nach der ersten Initialisierung könnte das lazyField bereits geändert worden sein, die anderen Threads sähen aber möglicherweise immer noch den Wert null und würden eine erneute Initialisierung machen, die die bereits erfolgte Änderung des lazyField überschreiben würde.
Um das zu verhindern, haben wir das Feld als volatile deklariert. Die
Speichereffekte von volatile (siehe [
JMM4
]) sorgen
dafür, dass der Inhalt des Feldes lazyField (also die Adresse des
neu erzeugten Integer-Objekts) allen anderen Threads, die die Methode getMyField()
aufrufen, sichtbar wird.
Genereller Verzicht auf Synchronisation/volatile bei Verwendung von unveränderlichen Typen ?Manchmal unterstellen Java-Programmierer, dass beim Zugriff auf Objekte von einem unveränderlichen Typ grundsätzlich keine Synchronisation und auch kein volatile erforderlich wäre weil, konkurrierende Zugriffe auf unveränderliche Objekte prinzipiell unproblematisch sind. Das ist leider ein Irrtum.Man muss in dem obigen Beispiel nur eine Kleinigkeit ändern und schon ist es falsch. In dem Racy-Single-Check-Beispiel sind Mehrfach-Initialisierungen harmlos und es ist deshalb egal, dass es keinerlei Garantien für die Sichtbarkeit der Referenz lazyField und des referenzierten Objekts gibt. Im Allgemeinen wird es aber wahrscheinlich doch so sein, dass andere Threads garantiert sehen sollen, was ein initialisierender Thread gemacht hat. Das wäre beispielsweise der Fall, wenn die Mehrfachinitialisierungen nicht akzeptabel sind und die Initialisierung nur einmal gemacht werden sollte. Ein Beispiel wäre eine Initialisierung, bei der die Kommunikationsverbindung zu einem anderen Service aufgebaut wird. Unter solchen geringfügig veränderten Randbedingungen wird plötzlich eine Sichtbarkeitsgarantie für das lazyField gebraucht, damit die anderen Threads nach der ersten Initialisierung sehen, dass das Feld nicht mehr null ist und keine weitere Initialisierung mehr gemacht werden darf. Die oben gezeigte Lösung, die komplett auf Synchronisation und volatile verzichtet, wäre dann falsch. Die Mehrfachinitialisierungen ist auch dann inakzeptabel, wenn sich beispielsweise die Referenz auf das unveränderliche Objekt ändern kann, weil es eine Methode gibt, die zuläßt, dass die Referenz neu belegt wird und dann auf ein anderes (auch wieder unveränderliches) Objekt verweist. Hier ist ein Beispiel für diese Situation: das lazyField ist nicht mehr unveränderlich, weil es eine Methode setMyField() gibt, die die Veränderung des Feldes erlaubt. Man kann die Sichtbarkeit auch mit Hilfe von Synchronisation garantieren. Das könnte dann so aussehen: public class MyClass {
Wie auch immer man für die Sichtbarkeit des konkurrierend verwendeten Feldes lazyField sorgt, das Beispiel zeigt, dass auch bei der Verwendung von unveränderlichen Typen sehr wohl Synchronisation oder volatile gebraucht wird und dass der völlige Verzicht darauf nur in seltenen Fällen überhaupt möglich ist. Das Racy-Single-Check-Idiom ist ein solcher seltener Fall, der aber so viele subtile Randbedingungen hat, dass eine winzige Änderung des Kontextes bereits dazu führt, dass man die im letzten Beitrag besprochene Initialisation-Safety-Garantie gar nicht braucht, weil man sowieso andere Mittel des Java Memory Modells wie Synchronisation oder volatile benutzen muss. Es gibt aber noch mehr Irrtümer "überflüssige" Synchronisation betreffend, die zu Fehlern führen können. Race Conditions bei der Konstruktion von ObjektenIrrtümlicherweise wird gelegentlich vermutet, die Konstruktion von Objekten sei atomar und es könne keine Race Conditions in Konstruktoren geben. Das ist nicht so, wie das folgende Beispiel zeigt. Es geht um eine Klasse mit einem Feld, einem Konstruktor, einer lesenden Zugriffsmethode und weiteren Methoden, die hier aber nicht gezeigt sind; darunter sind auch modifizierende Methoden.class Sizes {Nun kann es vorkommen, dass der Konstruktor konkurrierend mit der toString-Methode abläuft, etwa in der folgenden Situation: class Test { // falschAuf die Referenzvariable ref vom Typ Sizes wird von zwei Threads mit Namen "Publisher" und "Spy" konkurrierend zugegriffen. Der Publisher-Thread ruft den Konstruktor auf und die Referenz auf das neu konstruierte Objekt wird an die gemeinsam verwendete Referenzvariable ref zugewiesen. Der andere Thread wiederum greift über die Referenzvariable ref auf das Objekt zu und will es ausdrucken. Dann kann es passieren, dass die Adresse des neuen Objekts dem Spy-Thread sichtbar wird, die Felder des referenzierten, neu erzeugten Objekts jedoch nicht oder nur teilweise sichtbar sind. Das heißt, für den Spy-Thread sieht es so aus, als wäre das neue Objekt noch nicht fertig konstruiert. Wie kann so etwas geschehen? Die Situation ist zugegebenerweise ein wenig ungewöhnlich, denn hier ist es nicht so, dass erst die Referenzvariable ref mit einer Adresse belegt wird, ehe ein oder mehrere Threads gestartet werden, die dann gemeinsam auf das referenzierte Objekt zugreifen. In dem Falle liefe die Konstruktion des neuen Objekts und die Zuweisung der Adresse an die gemeinsam verwendete Referenzvariable ref vor dem Start der konkurrierend zugreifenden Threads ab. Dann würde der Start der jeweiligen Threads zu einem Refresh der Caches der Threads führen, so dass die Threads die Adresse und das referenzierte Objekt zu sehen bekämen. Es gäbe also ein klare Happens-Before-Beziehung: das Objekt wird erzeugt und über die gemeinsam verwendete Referenzvariable zugänglich gemacht, ehe andere, neu gestartete Threads darauf zugreifen. Stattdessen werden in der oben gezeigten Situation die beiden Threads gestartet, noch ehe die die gemeinsam verwendete Referenzvariable ref initialisiert ist. Die beiden Threads warten auch nicht aufeinander. Deshalb laufen hier die Konstruktion des neuen Objekts und die Zuweisung der Adresse an die gemeinsam verwendete Referenzvariable ref (beides im Publisher-Thread) konkurrierend zum lesenden Zugriff (im Spy-Thread) ab. Da es hier keine Sichtbarkeitsgarantien gibt, kann es wie oben beschrieben passieren, dass der Spy-Thread das neue Objekt während der Konstruktion sieht, noch ehe der Konstruktor fertig ist, weil nicht für Synchronisation gesorgt wird. Korrekt wäre folgende Implementierung, die Thread-Synchronisation nutzt: class Test { // okayDie Zugriffe auf die gemeinsam verwendete Referenzvariable ref sind nun sequenzialisiert und es gibt keine Race Condition mehr. Der Spy-Thread kann die Adresse des neuen Objekts entweder vor oder nach dem entsprechenden synchronized-Block im Publisher-Thread sehen. Da das Anfordern und das Freigeben von Locks einen Refresh bzw. Flush des Arbeitsspeichers auslöst, ist gesichert, dass der Spy-Thread entweder die Referenz und den Inhalt des neuen Objekts oder null zu sehen bekommt. Würde es hier genügen, die gemeinsam verwendete Referenzvariable ref als volatile zu deklarieren und auf die Synchronisation zu verzichten? Das sähe dann so aus: class Test { // falschNein, volatile würde hier nicht ausreichen. Der Typ Sizes ist ein veränderlicher Typ, der nicht einmal thread-sicher ist, was man daran sehen kann, dass seine Methoden (siehe z.B. toString()) nicht synchronized deklariert sind und auch sonst keine Synchronisation in der Implementierung des Typs verwendet wird. Die benutzerseitige Synchronisierung ist also zwingend erforderlich. Was wäre, wenn der Typ Sizes unveränderlich wäre, so wie java.lang.Integer aus dem ersten Beispiel? Würde es dann genügen, die gemeinsam verwendete Referenzvariable ref als volatile zu deklarieren und auf die Synchronisation zu verzichten? Ja, das wäre möglich. Da es dann nur lesende Zugriffe auf das Sizes-Objekt gäbe, machte es keine Probleme, wenn die Zugriffe konkurrierend statt sequentiell erfolgten. Man müsste dann nur noch für die Sichtbarkeit des Objekts sorgen und dafür würde es genügen, die Referenzvariable ref als volatile zu deklarieren. Spielt es dann eine Rolle, ob der unveränderliche Typ Sizes korrekt implementiert ist und alle seine Felder als final deklariert hat, so wir es in den letzten beiden Beiträgen (siehe [ JMM7 ] und [ JMM8 ]) besprochen haben? Nein, es ist egal, vorausgesetzt wir haben die Adresse auf das Objekt als volatile deklariert, denn dann sorgen die Speichereffekte von volatile für die Sichtbarkeit des Objekts und man braucht die final-Deklaration der Felder und somit die Initialisation-Safety Garantie nicht mehr.
Die final-Deklaration der Felder eines unveränderlichen Typs wird
nur gebraucht, wenn ansonsten weder Synchronisation noch volatile
verwendet wird, so wie es im Racy-Single-Check-Idiom der Fall ist.
ZusammenfassungDas Weglassen von Synchronisation und volatile (d.h. beides gleichzeitig weggelassen) ist eine aggressive Optimierung, die nur selten (z.B. beim Racy-Single-Check-Idiom) angewandt werden kann. Bereits geringfügige Änderungen an der Racy-Single-Check-Situation können dazu führen, dass eine volatile-Deklaration oder gar Synchronisation gebraucht wird. Eigentlich ist es eine Binsenweisheit: bei jeder Optimierung muss zuvor überlegt werden, ob die Optimierung möglich und korrekt ist, und insbesondere Optimierungen, die auf Synchronisation von konkurrierenden Zugriffen verzichten, sind diffizil und fehleranfällig.Im Zusammenhang mit typischen Missverständnissen über den Verzicht auf Synchronisation haben wir außerdem gezeigt, dass gelegentlich sogar der Aufruf eines Konstruktors synchronisiert werden muss.
Im den nächsten Beiträgen sehen wir uns ein weiteres Optimierungsinstrument
der Java Concurrency an: die atomaren Variablen.
Literaturverweise
Die gesamte Serie über das Java Memory Model:
|
|||||||||||||||||||||||||||||||
© Copyright 1995-2015 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/45.JMM-AggressiveOpt/45.JMM-AggressiveOpt.html> last update: 22 Mar 2015 |