|
|||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||
|
Details zu volatile-Variablen
|
||||||||||||||||||||
Wir haben im letzten Beitrag diskutiert, dass Synchronisation mit
Hilfe von Locks Performance kosten kann. Um dies zu vermeiden, kann
die Synchronisation unter Umständen durch andere Techniken ersetzt
werden. Eine Alternative zur Synchronisation ist die Verwendung von
volatile-Variablen. Deshalb wollen wir uns in diesem Beitrag näher
mit volatile-Variablen befassen.
volatile als Alternative zur SynchronisationWir haben schon in einem früheren Beitrag (siehe / JMM1 /) ein Beispiel gezeigt, in dem bewußt auf Synchronisation verzichtet wurde:public class Processor {Hier greifen zwei Methoden einer Klasse konkurrierend auf das boolean-Feld connectionPrepared zu. Die Idee ist, dass die start-Methode in einem Thread ausgeführt wird, der alle vorbereitenden Arbeiten anstößt und dann abwartet, bis alle Vorbereitungen abgeschlossen sind, ehe er die eigentliche Verarbeitung beginnt. Die beiden Threads kommunizieren miteinander über gemeinsam verwendete veränderliche Daten, nämlich das boolean-Feld connectionPrepared. Der eine Thread setzt connectionPrepared auf true, wenn er fertig ist, und der andere Thread beobachtet, ob connectionPrepared auf true gesetzt wurde, um dann mit der eigentlichen Arbeit zu beginnen. Das Lesen und Verändern des boolean Feldes ist nicht unterbrechbar. Das garantiert die Sprachspezifikation mit der Regel, dass der Zugriff auf Variablen von primitiven Typ (außer long und double) und auf Referenzvariablen atomar ist. Deshalb braucht man in dem Beispiel keine Synchronisation, um den Zugriff ununterbrechbar zu machen. Wir haben an diesem Beispiel diskutiert, dass das boolean-Feld connectionPrepared aber als volatile deklariert werden muss, wenn auf Synchronisation verzichtet wird, damit die Modifikation, die der Initialisierungsthread macht, dem wartenden Thread sichtbar gemacht wird. Diese Deklaration ist notwendig, weil das Java-Memory-Modell (JMM) nur unter bestimmten Bedingungen garantiert, dass Speichermodifikation anderen Threads sichtbar gemacht werden. Welche Bedingungen das sind, haben wir in einem Beitrag über das JMM (siehe / JMM2 /) diskutiert. Unter anderem gibt es Sichtbarkeitgarantien für Synchronisation, aber auch für volatile-Variablen. Hier noch mal eine kurze Wiederholung: Das Memory-Modell in Java ähnelt einer abstrakten SMP (= symmetric multi processing)-Maschine: die Threads laufen parallel und konzeptionell haben alle Threads Zugriff auf einen gemeinsamen Hauptspeicher (main memory), in dem die gemeinsam verwendeten Variablen abgelegt sind. Daneben hat jeder Thread einen eigenen lokalen Speicherbereich (cache), in den er Variablen hineinladen und lokal bearbeiten kann. Das Zurückschreiben der lokalen Daten in den Hauptspeicher (flush) und das Hereinladen von Daten aus dem Hauptspeicher (refresh) muss nach den Regeln des JMM geschehen.
MißverständnisBei solchen Optimierungen können sich Fehler einschleichen. Schauen wir uns einen solchen Fehler an:public class IntStack { // falsch; nicht nachmachen !!!Es ist das Beispiel eines vereinfachten Stacks von Integerzahlen. Die Methoden push und pop sind synchronisiert, die Methode size nicht. Die Methode size kommt ohne Synchronisation aus, weil sie den Wert von cnt zurück liefert. Das Feld cnt ist vom primitiven Typ int und der lesende Zugriff ist deshalb atomar. Für die Ununterbrechbarkeit der size-Methode wird die Synchronisation also nicht gebraucht; deshalb wurde sie weggelasen. Wie ist das aber nun mit der Sichtbarkeit der Modifikationen am cnt-Feld? Das cnt-Feld ist nicht volatile, aber es sind alle modifzierenden Zugriffe auf das Feld synchronisiert. Da Synchronisation Flushes auslöst, werden die Änderungen am cnt-Feld sichtbar, die in den Methoden push und pop gemacht wurden. Also ist die Implementierung in Ordnung - so lautet zumindest ein gelegentlich anzutreffendes Mißverständnis. Leider ist es nicht ganz so. Es genügt nicht, dass die Modifikationen am cnt-Feld wegen der Synchronisation der Methoden push und pop gemäß JMM-Regeln in den Hauptspeicher geflusht werden. Wenn die size-Methode unsynchronisiert auf das cnt-Feld zugreift, dann ist nicht garantiert, dass vor dem Lesezugriff ein Refresh gemacht wurde. Es kann also passieren, dass ein Thread durch zahlreiche pop-Aufrufe das cnt-Feld inkrementiert und ein anderer Thread, der periodisch mit der size-Methode das cnt-Feld abfragt, diese Änderung nicht zu sehen bekommt, weil der cnt-Wert stets aus dem Cache des Threads und nicht aus dem Hauptspeicher geholt wird. Es genügt also bereits ein einziger Zugriff ohne Speichergarantien auf eine Variable und die Sichtbarkeit ist nicht mehr gewährleistet. In dem Beispiel muss daher das cnt-Feld als volatile deklariert werden. public class IntStack { // jetzt ist es in Ordnung Kosten von volatileWir haben volatile als preiswerte Alternative zur Synchronisation vorgestellt, aber natürlich sind auch mit der Verwendung von volatile Kosten verbunden. Zwar löst der Zugriff auf volatile-Variablen keine Aufwände für die Verwaltung von Warteschlangen oder das Aufwecken von Threads aus Wartezuständen aus. Es kann auch keinen Stau von Threads geben, die auf den Erhalt eines Locks warten. Aber der Zugriff auf volatile-Variablen löst Flushes und Refreshes aus, die ungünstig in die Caching-Mechanismen der Prozessoren eingreifen, und ist damit teurer als der Zugriff auf normale Variablen. Man sollte also auch volatile nicht gänzlich gedankenlos verwenden.So kann zum Beispiel ein sehr häufiger Zugriff auf eine volatile-Variable, zum Beispiel in einer Schleife, in Summe teurer sein, als die einmalige Synchronisation der gesamten Schleife, weil jeder einzelen Zugriff auf die volatile-Variable einen Refresh oder Flush auslöst, die Synchronisation aber nur jeweils einen Refresh und Flush. Wo jeweils der Trade-off ist, muss man im Einzelfall mit einer Benchmark-Messung bestimmen. Wir wollen an dieser Stelle nur darauf hinweisen, dass man volatile auch aus Versehen zu ungünstig verwenden kann, dass sich u.U. negative Performance-Effekte einstellen.
Natürlich ist die Benutzung von volatile und Synchronisation mit
Hilfe von Locks nicht in allen Belangen gleichwertig. Die Locks sind zusammen
mit Conditions in einen Framework eingebettet, der auch komplexere Synchronisation
mit Hilfe von wait()/await() und notify()/signal() erlaubt. Bei volatile
ist das nicht so. Hier bleibt nur das mehrmalige Versuchen (Pollen),
bis sich die Situation eingestellt hat, auf die man wartet. Das muss
nicht immer zu schlechten Lösungen führen. Dies kann man an dem
ersten Beispiel in diesem Artikel sehen. Eine Implementierung des
Latch-Pattern auf Basis von volatile, bei dem der Startthread wartet, bis
die Kommunikationsfunktionalität initialisiert ist, macht durchaus
Sinn. In anderen Situationen können sich aber ernste Nachteile
aus dem erhöhten CPU-Verbrauch (auf Grund des Pollens) und der lose
gekoppelten Synchronisation beider Threads ergeben. In diesem Sinne muss
man auch einige Beispiele in diesem Artikel sehen. Sie haben einen didaktischen,
theoretischen Hintergrund: um an möglichst einfachen Beispielen die
Speichereffekte von volatile zu diskutieren. Deshalb wrden wir in unserem
nächsten Artikel das Thema noch mal von der praktischen Seite am Beispiel
des Double-Check-Idioms aufrollen.
Speichereffekte von volatile auf andere VariablenBislang haben wir nur Beispiele betrachtet, in denen es um den Zugriff auf eine einzige Variable ging. Wenn der Zugriff darauf atomar war und die Variable keine Abhängigkeiten zu anderen Variablen hatte, dann konnten wir die Synchronisation durch die Verwendung von volatile reduzieren. Wie ist das bei mehreren Variablen? Schauen wir uns ein Beispiel an:class FutureResult { // korrekt, aber nicht unbedingt empfehlenswertHier ist die Idee, dass ein FutureResult von einem Thread verwendet wird, um anderen Threads ein Ergebnis zu übergeben. Ob das Ergebnis vorliegt und abgeholt werden kann, wird über das boolean-Feld ready angezeigt. Die empfangenden Threads pollen das Feld mit Hilfe der isReady-Methode und holen das Ergebnis mit getResult ab, sobald isReady true liefert. Auf Synchronisation wird komplett verzichtet. Für die Methoden getResult und isReady geht das, weil sie lediglich atomare Operationen ausführen und deshalb nicht unterbrechbar sind. Die Method putResult braucht keine Synchronisation, weil unterstellt wird, dass nur ein einziger Thread diese Methode einmal aufruft. Wegen dieser Benutzungskonvention kann es keine konkurrierenden putResult-Aufrufe geben. Die konkurrierenden Aufrufe von getResult und isReady stellen auch kein Problem dar. Wenn eine dieser Methoden mitten in der putResult-Methode auf die Felder ready und/oder data zugreift, dann liefert isReady schlimmstenfalls noch false zurück, obwohl das Resultat bereits im Feld data abgelegt ist. Diese vorübergehende Inkonsistenz der Daten ist aber kein Problem, weil der abfragende Thread dann beim nächsten Aufruf von isReady die konsistente Information erhält. Da auf Synchronisation verzichtet wurde, haben wir unsynchronisierte Zugriffe auf die beiden Felder ready und data und wir müssen für die Sichtbarkeit der Modifikationen an diesen Feldern sorgen. Das wird getan, indem das boolean-Feld ready als volatile deklariert ist. Das Referenzfeld data ist hingegen nicht volatile. Ist das korrekt oder müssen beide Felder volatile sein? Wenn man die Sichtbarkeitsregeln für volatile-Variablen genau ansieht, stellt man fest, dass es in der Tat genügt, wenn das ready-Feld volatile ist. Der schreibende Zugriff auf ready in der Methode putResult löst nämlich einen Flush aus. Dabei wird nicht nur der Inhalt von ready geflusht, sondern es werden alle Speichermodifikationen sichtbar, die der Thread bis dahin gemacht hat, also auch die Modifikation an der Referenzvariablen data. Die seltsame if-Abfrage in der Methode getResult mit dem leeren Statement im true-Fall dient einem ganz ähnlichen Zweck und ist keineswegs überflüssig. Der Lesezugriff auf die volatile-Variable ready löst einen Refresh aus, der nicht nur den aktuellen Inhalt von ready aus dem Hauptspeicher beschafft, sondern auch alle anderen Variablen auffrischt, auf die der Thread zugreifen wird. Damit wird auch der aktuelle Inhalt der Referenzvariablen data dem lesenden Thread sichtbar. Nun ist das ganze Beispiel etwas seltsam, weil es um maximale Optimierung bemüht ist. Der Verzicht auf die Synchronisation ist nur wegen der Benutzungskonvention möglich und dann wird auch noch versucht, mit möglichst wenig volatile-Variablen auszukommen. Im Hinblick auf die Optimierung ist es gut, in Hinblick auf die Verständlichkeit des Codes muss man sich allerdings fragen, ob es hier nicht sinnvoller wäre, beide Felder als volatile zu erklären. Der Verzicht auf die volatile-Deklaration für die Referenzvarable data führt schließlich zu einem subtilen und damit recht fragilen Code, der bei geringfügigen Änderungen bereits inkorrekt wird. Der Leser des Source-Code muss folgende Aspekte verstanden haben:
class FutureResult { // korrektWir haben das Beispiel bewußt ausgewählt, um zu demonstrieren, dass Zugriffe auf volatile-Variablen Speichereffekte haben, die nicht nur den Inhalt der volatile-Variablen betreffen, sondern dass alle im Cache eines Threads gehaltenen Variablen durch die Flushes und Refreshes betroffen sind. volatile-ReferenzvariablenFür eine volatile-Referenzvariable gelten dieselben Garantien wie für volatile-Variablen von einem primitiven Typ. Allerdings beziehen sich alle Regeln stets nur auf die Referenz selbst, also die Adresse des Objekts, nicht aber auf das referenzierte Objekt. Sehen wir uns das einmal an einem Beispiel genauer an. Ändern wir die obige Klasse FutureResult so, dass die nicht ein einzelnes Objekt als Resultat enthält, sondern ein Paar von Resultaten:public class Pair {Die FutureResult-Klasse hat dieselben Benutzungskonventionen wie zuvor: nur ein Thread darf die putResult-Methode einmal aufrufen und die Empfänger des Resultats dürfen getResult erst aufrufen, wenn isReady true geliefert hat. In diesem Falle kommen wir wieder ganz ohne Synchronisation aus. Die Frage ist nun, ob es genügt, dass die Referenzvariable data als volatile erklärt ist. Schließlich interessieren den Empfanger des Resultats die Inhalte des Pair-Objekts und nicht dessen Adresse. Die Methode putResult erzeugt und füllt ein temporäres Pair-Objekt und weist danach der volatile-Variablen data die Adresse dieses temporären Objekts zu. Diese Adressezuweisung löst den Flush aus und dabei werden alle ggf. im Cache des Threads gemachten Speichermodifikationen in den Hauptspeicher geschrieben. Damit werden garantiert auch die Inhalte des referenzierten Pair-Objekts sichtbar. Die Methoden getResult und isReady müssen lesend auf die volatile-Variablen data zu, ehe sie den Inhalt des referenzierten Pair-Objekts anschauen können. Das Lesen der Adresse löst den Refresh aus, der auch die Inhalte des referenzierten Pair-Objekts sichtbar macht. Wichtig ist hierbei, dass die Methode putResult die Modifikation der Adresse nach der Modifikation der Inhalte des referenzierten Objekts macht. Folgende Implementierung wäre daher falsch: public void putResult(Object o1,Object o2) {Hier erfolgt die Modifikation auf die volatile-Referenzvariable vor den Modifikationen des Objekts. Geflusht werden daher die Default-Inhalte des konstruierten Pair-Objekts. Ob die danach erfolgten Modifikationen am Pair-Objekt in den Hauptspeicher geschrieben werden, ist nicht gesichert. Es kann also passieren, dass der empfangende Thread das Ergebnis nie zu Gesicht bekommt. Damit die putResult() Methode von oben korrekt funktioniert, kann die Pair Klasse so geändert werde, dass die beiden Felder first und second auch volatile sind: public class Pair {Jetzt löst auch die Zuweisung in data.addFirst(o1) bzw. data.addSecond(o2) jeweils einen eigenen Flush aus und macht damit die Änderungen für andere Threads sichtbar. Der Ansatz, weitere geschachtelt enthaltene Felder volatile zu machen, hat natürlich seine Limitationen: Wenn wir keinen Zugriff auf den Sourcecode der Klasse (in unserem Fall Pair) haben, ist dies nicht möglich. Ein anderes Beispiel sind Java Arrays. Hier ist es auch nicht möglich, die Elemente des Arrays explizit volatile zu machen., sodass die korrekte Implementierung unseres Beispiels mit einem Object-Array so aussieht: public class FutureResult {Zum Abschluss noch ein Hinweis auf ein gelegentlich anzutreffendes Missverständnis: die Sichtbarkeitsregeln, die wir in den diskutierten Beispielen ausgenutzt haben, gelten immer nur dann, wenn die Zugriffe auf die volatile-Variablen zusammen passen. Das heißt, wenn ein Thread durch den schreibenden Zugriff auf eine volatile Variable einen Flush auslöst, dann werden die Speichermmodifikationen nicht allen Threads sichtbar, sondern nur denjenigen, die einen lesenden Zugriff auf diesselbe volatile Variable machen. Diese Randbedingung wird gelegentlich übersehen, ist aber eigentlich nicht besonders überraschend. Für die Speichereffekte im Zusammenhang mit dem Anfordern und Freigeben von Locks gilt genau dasgleiche: wenn ein Thread durch durch das Freigeben eines Locks einen Flush auslöst, dann werden die Speichermodifikationen nicht allen Threads sichtbar, sondern nur denjenigen, die dasselbe Lock anschließend anfordern und bekommen. ZusammenfassungIn diesem Beitrag haben wir die Speichereffekte von volatile Variablen diskutiert. Der Gedanke dahinter ist: man möchte Datensynchronization, wo möglich und erwünscht, durch volatile zu ersetzen. Dadurch lassen sich Java Programme stärker parallelisieren, sodass sie auf Multicore- und Multi-Prozessor-Architekturen besser skalieren. Beim nächsten Mal wollen wir uns das Ganze an einem Beispiel aus der Praxis, dem Double-Check-Idiom, ansehen.Literaturverweise und weitere InformationsquellenDie gesamte Serie über das Java Memory Model:
|
|||||||||||||||||||||
© Copyright 1995-2015 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/40.JMM-volatileDetails/40.JMM-volatileDetails.html> last update: 22 Mar 2015 |