|
|||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||||
|
An Introductory Glance at the Java Memory Model (JMM)
|
||||||||||||||||||||||
Mit diesem Beitrag wollen wir eine kleine Reihe über Aspekte des Java-Memory-Modells (JMM) beginnen. Kenntnisse des Memory-Modells werden für die Programmierung mit mehreren Threads gebraucht und detaillierte Kenntnisse werden insbesondere durch die zunehmende Verwendung von Multicore-Prozessoren immer wichtiger. Deshalb wollen wir einige der wesentlichen Aspekte des Memory-Modells erläutern. Wir beginnen mit dem volatile -Schüsselwort. Was bedeutet volatile? Wofür braucht man es? Worauf muss man achten? Unter Java-Programmierern ist allgemein bekannt, dass man bei der Programmierung mit mehreren Threads besonders aufpassen muss, wenn diese parallel ablaufenden Threads auf gemeinsam verwendete, veränderliche Daten (engl. shared mutable data) zugreifen. Dabei gibt es eine Reihe von Aspekten, die man als Programmierer im Auge behalten muss. Das bekannteste Problem ist die Race Condition: wenn der Zugriff auf die gemeinsam verwendeten Daten in mehreren Schritten erfolgt, dann ist der Zugriff unterbrechbar. Ein Thread etwa macht die ersten Schritte des Zugriff, wird mittendrin vom Thread-Scheduler verdrängt, ein anderer Thread kommt zum Zuge, greift auf die halbveränderten Daten zu und modifizert sie womöglich, wird seinerseits unterbrochen, der erste Thread kommt wieder dran. Er macht weiter, als sei nichts gewesen. Das Resultat dieser konkurrierenden Zugriffe ist unvorhersehbar. Die übliche Abhilfe ist Synchronisation. Mit Hilfe von einem Lock (auch Mutex genannt) wird dafür gesorgt, dass der Zugriff auf die gemeinsam verwendeten veränderlichen Daten ununterbrechbar ist. Das geht so: alle beteiligten Threads, die auf die Daten zugreifen wollen, benutzen ein bestimmtes Lock-Objekts. Vor jedem Zugriff auf die kritischen Daten wird das Lock angefordert, nach Beendigung aller Zugriffsschritte wird das Lock wieder frei gegeben. Da ein Lock immer nur einen Thread als Besitzer haben kann, muss beim Anfordern gewartet werden, bis der aktuelle Besitzer das Lock wieder frei gibt. Auf dieser Weise ist gesichert, dass immer nur ein Thread zu einer Zeit auf die gemeinsam verwendeten veränderlichen Daten zugreifen kann, weil das Lock besetzt ist und alle anderen Threads warten müssen, bis der Thread fertig ist mit seinem Zugriff. Diese Grundlagen sind sicher jedem Java-Entwickler geläufig, denn die Instrumente für die Synchronisation sind in Java direkt in die Sprache eingebettet worden in Form des synchronized-Schlüsselworts und durch die Tatsache, dass an jedem Objekt automatisch ein Lock dranhängt, das man zwar nicht sieht, das aber immer vorhanden ist und implizit über das synchronized-Schlüsselwort angesprochen wird. Als Alternative gibt es seit Java 5 auch noch die etwas flexibleren, expliziten Locks, siehe Interface Lock im Package java.util.concurrent.locks. Nun ist die Verwendung von Locks aber teuer und deswegen kommt als Optimierung das volatile-Schlüsselwort ins Spiel. Die Kosten der Synchronisation mit Hilfe von Locks bestehen zum einen im Aufwand, den das Anfordern und Freigeben von Locks für die Virtuelle Maschine und das Betriebsystem bedeuten und zum anderen in der Tatsache, dass Synchronisation Wartezustände auslöst, die den Durchsatz der Anwendung reduzieren. Beim Anfordern und Freigeben von Locks hat das Laufzeitsystem nämlich ein Menge zu tun: es werden Systemressourcen angelegt und weggeräumt, es werden Threads in Warteschlangen gestellt oder aus Wartezuständen aufgeweckt, es passieren Kontextwechsel, Daten-Caches müssen abgeglichen werden - all das führt dazu, dass Synchronisation Zeit und Aufwand kostet, der die Performance der Anwendung reduziert.
Daneben wirkt sich Synchronisation nachteilig auf den Durchsatz der
Anwendung aus. Es kann passieren, dass der synchronisierte Zugriff auf
gemeinsam verwendete veränderliche Daten zu einem echten Engpass werden
kann. Wenn viele Threads gleichzeitig auf gemeinsam verwendete Daten
zugreifen wollen und immer nur einer das Lock bekommt und alle anderen
warten müssen, dann entsteht ein Stau, der sich negativ auf den Durchsatz
der Anwendung auswirkt. Mit anderen Worten, Synchronisation skaliert
nicht beliebig und je weniger Synchronisation gebraucht wird, desto besser
ist es. Also ist das Motto: Synchronisation reduzieren, wo immer
es geht.
Atomare ZugriffeWie oben erläutert wird die Synchronisation gebraucht, um komplexere Zugriffe auf gemeinsam verwendete veränderliche Daten ununterbrechbar zu machen. Wenn die betreffenden Daten aber elementar sind und der Zugriff darauf schon von Natur aus ununterbrechbar (man sagt auch atomar) ist, dann braucht man doch keine Synchronisation, richtig? Also, wenn zum Beispiel die gemeinsam verwendeten Daten lediglich aus einem boolean bestehen, dann sind Zugriffe wie Lesen oder Schreiben atomar. Das garantiert die Sprachdefinitionin Kapitel 17 (siehe /JLS/). Dort ist festgelegt, dass lesende und schreibende Zugriffe auf Variablen von primitivem Typ (außer long und double) atomar sind. Deshalb wird in der Praxis vielfach die Synchronisation weggelassen, wenn es um konkurriende Zugriffe auf Variablen von primitivem Typ geht.Hier ist ein typisches Beispiel: public class Processor {Wir haben hier zwei Methoden der Klasse Processor, die beide auf das Feld connectionPrepared zugreifen. 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 garantiert atomar, deshalb wird keine Synchronisation verwendet. Leider wurde hier ein wesentlicher Aspekt übersehen. Sequential ConsistencyDer Autor dieser kleinen Klasse ist augenscheinlich davon ausgegangen, dass der eine Thread irgendwann einmal das boolean Feld connectionPrepared setzen wird und dass der andere Thread den veränderten Wert dann sehen kann. Eine derartige Garantie gibt es in Java aber gar nicht. Es ist keineswegs so, dass ein Thread immer sofort sehen kann, was ein anderer Thread im Speicher gemacht hat. Es kann passieren, dass der eine Thread das boolean Feld connectionPrepared auf true gesetzt hat und der andere Thread diese Änderung nie zu sehen bekommt.Das Schwierige an der Sache ist, dass der beschriebene Effekt nicht auftreten muss, aber auftreten kann. Das heißt, die Klasse hat einen Fehler, der sich aber unter Umständen gar nicht bemerkbar macht. In manchen existierenden Anwendungen schlummern derartige Fehler, die bisher einfach noch nicht entdeckt wurden. Nun ist es so, dass auf Maschinen mit nur einem Single-Core-Prozessor die Fehler häufig tatsächlich nicht auftreten. Aber in einer Multi-Prozessor- oder Multicore-Umgebung kann der Fehler dann plötzlich doch passieren. Da heute Dual-Core-Prozessoren Standard sind, kann man in der Tat beobachten, wie Anwendungen, die auf einer Single-Core-Maschine tadellos funktioniert haben, plötzlich ganz seltsame Fehler aufweisen, sobald sie auf einer Maschine mit einem Multi-Core-Prozessor ablaufen. Welchen Fehler hat der Autor der Klasse gemacht? Er hat Annahmen über das Verhalten der Virtuellen Maschine gemacht, die unzutreffend sind. Was er unterstellt hat, wird allgemein als Sequential Consistency bezeichnet. Sequential Consistency bedeutet, dass ein Thread, der später drankommt, sehen kann, was die Threads vor ihm im Speicher an den gemeinsam verwendeten Daten gemacht haben. Das ist ein wunderbar simples mentales Modell, das aber leider von Java nicht unterstützt wird. Wir haben a priori keine Sequential Consistency in Java.
Es gibt in der Sprachspezifikation stattdessen eine Reihe von Garantien
für die Reihenfolge von Operationen und auch für die Sichtbarkeit
von Memory-Modifikationen, aber sie sind wesentlich schwächer als
unserer Intuition entsprechende Sequential Consistency. Wir wollen
jetzt nicht das gesamte Memory-Modell von Java aufrollen, sondern nur eine
einzige der Regeln aus der Sprachspezifikation herausgreifen, nämlich
die Garantien für volatile-Variablen.
Sichtbarkeitsregeln für Volatile-VariablenFür eine volatile-Variable ist garantiert, dass ein Thread, der die Variable liest, den Wert bekommt, den zuletzt zuvor ein anderer Thread derselben Variablen zugewiesen hat. Es ist außerdem garantiert, dass der Wert, den ein Thread in einer volatile-Variablen ablegt, allen anderen Threads zugänglich gemacht wird. Das heißt, für volatile-Variablen haben wir die gewünschte Sequential Consistency. Allerdings gilt dies nur für die Variable selbst: bei einer Variablen von einem Referenztyp gilt es nur für die Adresse, nicht für das referenzierte Objekt. Die Garantien sind streng genommen sogar noch umfangreicher: beim Schreiben auf eine volatile-Variable wird nicht nur der neue Inhalt eben jener volatile-Variablen sichtbar gemacht, sondern alle Modifikation, die der Thread zuvor im Speicher gemacht hat. Das interessiert uns aber im Moment nicht. Da das Thema etwas umfangreicher ist, werden wir die Details in einem anderen Beitrag näher erläutern. Kehren wir lieber zu unserem fehlerhaften Beispielcode zurück.Man könnte den Fehler ganz einfach dadurch beheben, dass man das boolean Feld overHeated als volatile deklariert: public class Processor {Jetzt ist die Klasse korrekt, weil nun garantiert ist, dass die Modifikation, die der eine Thread mit Hilfe der prepareConnection-Methode am volatile boolean connectionPrepared vornimmt, dem anderen Thread sichtbar gemacht wird. Mit dieser volatile-Deklaration kann es nicht mehr passieren, dass der eine Thread das boolean-Feld ändert und der andere Thread es gar nicht mitbekommt. Sichtbarkeitsregeln für SynchronisationWir wollen nicht verschweigen, dass man das Problem auch anders lösen kann, nämlich indem man den Zugriff auf das connectionPrepared-Feld synchronisiert:public class Processor {Synchronisation hat nicht nur Auswirkungen auf die Ununterbrechbarkeit einer Sequenz von Operationen, sondern hat zusätzlich Auswirkungen auf die Sichtbarkeit von Speichermodifikationen. Wenn ein Lock freigegeben wird, dann ist garantiert, dass alle Modifikationen, die der betreffende Thread im Speicher gemacht hat, allen anderen Threads sichtbar gemacht werden. Umgekehrt ist garantiert, dass ein Thread beim Erhalt eines Locks alle Modifkationen im Speicher zu sehen bekommt, die ein anderer Thread bewirkt hat, der dasselbe Lock vorher gehalten hat. Das heißt, im Falle von Synchronisation gilt wieder die intuitive Sequential Consistency. Wer immer und überall korrekt synchronisiert, braucht kein volatile und wird nie Probleme damit haben, dass ein Thread nicht sehen kann, was ein anderer gemacht hat. Die fehlende Sequential Consistency macht sich erst dann bemerkbar, wenn es unsynchronisierte Zugriffe auf Variablen gibt, die nicht volatile sind. Unsere ursprüngliche Überlegung war aber, dass Synchronisation teuer ist und wenn möglich vermieden werden sollte. Da hier die Zugriffe auf das boolean-Feld atomar sind, wird die Synchronisation nicht gebraucht, um für Ununterbrechbarkeit des Datenzugriffs zu sorgen. Deshalb hatten wir die Synchronisation absichtlich weggelassen. Wenn man eine solche Optimierung vornimmt, dann muss man aber das boolean-Feld als volatile deklarieren, um für die Sichtbarkeit der Modifikationen an diesem Feld zu sorgen. Die Lösung mit Synchronisation nicht nur ineffizient, sondern auch noch fehlerhaft. Die Verwendung von Synchronisation löst zwar - ebenso wie die Verwendung von volatile - das Sichtbarkeitsproblem für das connectionPrepared-Flag. Aber die Synchronisation führt - nicht immer, aber in diesem Beispiel - zu üblen Fehlern, u.a. zu einem Deadlock: wenn prepareConnection() vor start() aufgerufen wird, dann hält die prepareConnection()-Methode das Lock und die start()-Methode muss aufs Lock warten, d..h sie kann das connectionPrepared-Flag nicht setzen. Dann dreht sich die prepareConnection()-Methode in einer Endlos-Schleife und nichts geht weiter. ZusammenfassungWir haben in diesem Beitrag gesehen, dass man Synchronisation eliminiert, um die Performance- und Scalability-Kosten der Synchronisation zu vermeiden. Das kommt immer dann in Frage, wenn der Zugriff auf eine gemeinsam verwendete veränderliche Variable sowieso schon atomar ist und deshalb für die Ununterberechbarkeit des Zugriff keine Synchronisation gebraucht wird. Sobald man aber unsynchronisiert auf eine gemeinsam verwendete veränderliche Variable zugreift, ist nicht mehr gesichert, dass ein Thread sehen kann, was ein anderer Thread zuvor in der Variablen abgelegt hat. Solche Variablen, auf die unsynchronisiert zugegriffen wird, müssen als volatile deklariert werden, damit Modifikationen, die ein Thread an der Variablen vornimmt, garantiert allen anderen Threads sichtbar gemacht werden.
In den nächsten Beitrag wollen wir das Thema vertiefen und uns
ansehen, was eigentlich hinter diesen Sichtbarkeitsregeln steckt und wie
das mit volatile-Referenzvariablen ist, welche anderen Garantien das Memory-Model
sonst noch zu bieten hat, wie das mit final-Variablen ist, was atomic-Variablen
sind und was sie wiederum mit volatile zu tun haben.
Literaturverweise und weitere Informationsquellen
Die gesamte Serie über das Java Memory Model:
|
|||||||||||||||||||||||
© Copyright 1995-2022 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/37.JMM-Introduction/37.JMM-Introduction.html> last update: 20 Jun 2022 |