Angelika Langer - Training & Consulting
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | Twitter | Lanyrd | Linkedin
 
HOME 

  OVERVIEW

  BY TOPIC
    JAVA
    C++

  BY COLUMN
    EFFECTIVE JAVA
    EFFECTIVE STDLIB

  BY MAGAZINE
    JAVA MAGAZIN
    JAVA SPEKTRUM
    JAVA WORLD
    JAVA SOLUTIONS
    JAVA PRO
    C++ REPORT
    CUJ
    OTHER
 

GENERICS 
LAMBDAS 
IOSTREAMS 
ABOUT 
CONTACT 
An Introductory Glance at the Java Memory Model (JMM)

An Introductory Glance at the Java Memory Model (JMM)
Java Memory Model: Einführung
Wozu braucht man volatile?

Java Magazin, Juli 2008
Klaus Kreft & Angelika Langer

Dies ist das Manuskript eines Artikels, der im Rahmen einer Kolumne mit dem Titel "Effective Java" im Java Magazin erschienen ist.  Die übrigen Artikel dieser Serie sind ebenfalls verfügbar ( click here ).
 
Die ganze Serie zum  "Java Memory Modell" als PDF (985 KB).
 

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 Zugriffe

Wie 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 {
    private boolean connectionPrepared = false;
    public void prepareConnection() {
        // ... open connection ...
        connectionPrepared = true;
    }
    public void start() throws InterruptedException {
        // ... various initializations ...
        while (!connectionPrepared )
            Thread.sleep(500);
        // ... start actual processing ...
    }
}
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 Consistency

Der 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-Variablen

Fü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 {
    private volatile boolean connectionPrepared = false;
    public void prepareConnection() {
        // ... open connection ...
        connectionPrepared = true;
    }
    public void start() throws InterruptedException {
        // ... various initializations ...
        while (!connectionPrepared )
            Thread.sleep(500);
        // ... start actual processing ...
    }
}
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 Synchronisation

Wir 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 {
    private boolean connectionPrepared = false;
    public synchronized void prepareConnection() {
        // ... open connection ...
        connectionPrepared = true;
    }
    public synchronized void start() throws InterruptedException {
        // ... various initializations ...
        while (!connectionPrepared )
            Thread.sleep(500);
        // ... start actual processing ...
    }
}
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.

Vorsicht!  Wichtiger Hinweis!  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.

Zusammenfassung

Wir 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

/JLS/ Java Language Specification, 3rd Edition
Chapter 17: Threads and Locks
URL:  http://java.sun.com/docs/books/jls/third_edition/html/memory.html

Die gesamte Serie über das Java Memory Model:

/JMM1/ Einführung in das Java Memory Model: Wozu braucht man volatile?
Klaus Kreft & Angelika Langer, Java Magazin, Juli 2008
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/37.JMM-Introduction/37.JMM-Introduction.html
/JMM2/ Überblick über das Java Memory Model
Klaus Kreft & Angelika Langer, Java Magazin, August 2008
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/38.JMM-Overview/38.JMM-Overview.html
/JMM3/ Die Kosten der Synchronisation
Klaus Kreft & Angelika Langer, Java Magazin, September 2008
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/39.JMM-CostOfSynchronization/39.JMM-CostOfSynchronization.html
/JMM4/ Details zu volatile-Variablen
Klaus Kreft & Angelika Langer, Java Magazin, Oktober 2008
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/40.JMM-volatileDetails/40.JMM-volatileDetails.html
/JMM5/ volatile und das Double-Check-Idiom
Klaus Kreft & Angelika Langer, Java Magazin, November 2008
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/41.JMM-DoubleCheck/41.JMM-DoubleCheck.html
/JMM6/ Regeln für die Verwendung von volatile
Klaus Kreft & Angelika Langer, Java Magazin, Dezember 2008
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/42.JMM-volatileIdioms/42.JMM-volatileIdioms.html

 

If you are interested to hear more about this and related topics you might want to check out the following seminar:
Seminar
 
Concurrent Java - An in-depth seminar covering all that is worth knowing about concurrent programming in Java, from basics such as synchronization over the Java 5.0 concurrency utilities to the intricacies of the Java Memory Model (JMM).
4 day seminar ( open enrollment and on-site)
 

 
  © 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