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 
Über die Gefahren allzu aggressiver Optimierungen

Über die Gefahren allzu aggressiver Optimierungen
Java Memory Model
Über die Gefahren allzu aggressiver Optimierungen

Java Magazin, Juni 2009
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).
 

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 Typen

Sehen 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 {
    private Integer lazyField = null;
    ...

    public Integer getMyField() {
        if (lazyField == null)
            lazyField = new Integer(10000);

        return lazyField;
    }
    ...
}

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 {
    private volatile Integer lazyField = null;
    ...

    public Integer getMyField() {
        if (lazyField == null)
            lazyField = new Integer(10000);

        return lazyField;
    }
    public void setMyField(int i) {
        lazyField = new Integer(i);
    }
}

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 {
    @GuardedBy("lock") private Integer lazyField = null;
    private Object lock = new Object();

    ...

    public Integer getMyField() {
        synchronized(lock) {
            if (lazyField == null)
                lazyField = new Integer();
        }
        return lazyField;
    }
    public void setMyField(int i) {
        synchronized(lock) {
            lazyField = new Integer(i);
        }
    }
    ...
}

Man kann die Sichtbarkeit auch mit Hilfe von Synchronisation garantieren.  Das könnte dann so aussehen [ 1 ] :
public class MyClass {
    @GuardedBy("lock") private Integer lazyField = null;
    private Object lock = new Object();

    ...

    public Integer getMyField() {
        synchronized(lock) {
            if (lazyField == null)
                lazyField = new Integer();
        }
        return lazyField;
    }
    public void setMyField(int i) {
        synchronized(lock) {
            lazyField = new Integer(i);
        }
    }
    ...
}

1) Die Annotation @GuardedByLock ist übrigens keine vordefinierte Standard-Annotation wie zum Beispiel @Override oder @SuppressWarning .  Es handelt sich hingegen um eine Annotation, die man sich selbst zu Zwecken der besseren Dokumentation des Source-Codes definieren kann.  Die Idee dafür stammt von Brian Goetz, der die Annotation in den Code-Beispielen in seinem Buch "Java Concurrency in Practice" (siehe [ JCP ]) verwendet hat.

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 Objekten

Irrtü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 {
        private int[] sizes ;
        public Sizes(int... args) {
            System.arraycopy(args, 0, sizes=new int[args.length], 0, args.length);
        }
        public String toString() {
            return "Sizes: "+Arrays.toString(sizes);
        }
        // … further methods, including modifying methods …
    }
Nun kann es vorkommen, dass der Konstruktor konkurrierend mit der toString-Methode abläuft, etwa in der folgenden Situation:
class Test { // falsch

    private static Sizes ref = null;

    public static void main(String[] args) {
      Runnable publisher = new Runnable() {
        public void run(){
           ref = new Sizes(512, 1024, 2048);
           // … do some more stuff …
        }
      };
      new Thread(publisher,"Publisher").start();

      Runnable spy = new Runnable() {
        public void run(){
          if (ref != null)
             System.out.println(ref);
        }
      };
      new Thread(spy,"Spy").start();
    }
}

Auf 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 { // okay

    @GuardedBy ("lock") private static Sizes ref = null;
     private static Object lock = new Object( );

    public static void main(String[] args) {
      Runnable publisher = new Runnable() {
            public void run() {
                synchronized (lock) {
                    ref = new Sizes(512, 1024, 2048);
                }
                // … do some more stuff …
            }
      };
      new Thread(publisher,"Publisher").start();

      Runnable spy = new Runnable() {
            public void run() {
             synchronized (lock) {
                    System.out.println(ref);
             }
            }
      };
      new Thread(spy,"Spy").start();
    }
}

Die 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 { // falsch

    private static volatile Sizes ref = null;

    public static void main(String[] args) {
      Runnable publisher = new Runnable() {
        public void run(){
           ref = new Sizes(512, 1024, 2048);
           // … do some more stuff …
        }
      };
      new Thread(publisher,"Publisher").start();

      Runnable spy = new Runnable() {
        public void run(){
          System.out.println(ref);
        }
      };
      new Thread(spy,"Spy").start();
    }
}

Nein, 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.
 

Zusammenfassung

Das 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

/JCP/ Java Concurrency in Practice
Brian Goetz et.al.
Addison-Wesley 2006

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
/JMM7/ Die Initialisation-Safety-Garantie für final-Felder von primitivem Typ
Klaus Kreft & Angelika Langer, Java Magazin, Februar 2009
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/43.JMM-InitializationSafety.1/43.JMM-InitializationSafety.1.html
/JMM8/ Die Initialisation-Safety-Garantie für final-Felder von einem Referenztyp
Klaus Kreft & Angelika Langer, Java Magazin, April 2009
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/44.JMM-InitializationSafety.2/44.JMM-InitializationSafety.2.htm l
/JMM9/ Über die Gefahren allzu aggressiver Optimierungen
Klaus Kreft & Angelika Langer, Java Magazin, Juni 2009
URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/45.JMM-AggressiveOpt/45.JMM-AggressiveOpt.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-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