Problem mit der doppelten Sperre im Singleton-Modus

Das Singleton-Erstellungsmuster ist eine gängige Programmiersprache. Bei Verwendung mit mehreren Threads ist eine Art Synchronisierung erforderlich. Um effizienteren Code zu erstellen, haben Java- Programmierer das Double-Checked-Locking-Idiom entwickelt, das sie mit dem Singleton-Erstellungsmuster verwenden, um die Menge des Synchronisationscodes zu begrenzen. Aufgrund einiger weniger verbreiteter Details des Java-Speichermodells gibt es jedoch keine Garantie dafür, dass diese doppelt überprüfte Sperrsprache funktioniert.

Es schlägt gelegentlich fehl, nicht immer. Außerdem schlägt es aus nicht offensichtlichen Gründen fehl und enthält einige kryptische Details des Java-Speichermodells. Diese Tatsachen führen dazu, dass der Code fehlschlägt, da eine doppelt überprüfte Sperrung schwer nachzuverfolgen ist. Im weiteren Verlauf dieses Artikels gehen wir detailliert auf die doppelt überprüfte Sperrsprache ein, um zu verstehen, wo sie fehlschlägt.


Um zu verstehen, woher das Double-Checked-Locking-Idiom stammt, muss man das gängige Singleton-Erstellungsidiom verstehen, wie in Listing 1 dargestellt:


Listing 1. Das Idiom zur Singleton-Erstellung

Code kopieren
import java.util.*; 
Klasse Singleton 
{ 
  private statische Singleton-Instanz; 
  privater Vektor v; 
  privater boolescher Wert inUse; 

  private Singleton() 
  { 
    v = new Vector(); 
    v.addElement(new Object()); 
    inUse = true; 
  } 

  public static Singleton getInstance() 
  { 
    if (instance == null) //1 
      Instanz = new Singleton(); //2 
    Rückgabeinstanz; //3 
  } 
}
Code kopieren

 

Das Design dieser Klasse stellt sicher, dass nur ein  Singleton Objekt erstellt wird. Konstruktoren werden als deklariert  private, getInstance() Methoden erstellen lediglich ein Objekt. Diese Implementierung eignet sich für Single-Threaded-Programme. Bei der Einführung von Multithreading  getInstance() müssen Methoden jedoch durch Synchronisierung geschützt werden. Wenn die Methode nicht protected ist  getInstance() , Singleton werden möglicherweise zwei verschiedene Instanzen des Objekts zurückgegeben. Angenommen, zwei Threads rufen  getInstance() gleichzeitig Methoden auf und die Aufrufe werden in der folgenden Reihenfolge ausgeführt:

  1. Thread 1 ruft  getInstance() die Methode auf und entscheidet sich  instance für //1  null

  2. Thread 1 trat in  if den Codeblock ein, wurde jedoch von Thread 2 beim Ausführen der Codezeile bei //2 vorbelegt. 

  3. Thread 2 ruft  die Methode auf und  entscheidet  getInstance() bei //1  instancenull

  4. Thread 2 betritt  if den Codeblock, erstellt ein neues  Singleton Objekt und weist die Variable diesem neuen Objekt unter //2 zu  instance . 

  5. Thread 2 gibt die Objektreferenz bei //3 zurück  Singleton .

  6. Thread 2 wird von Thread 1 vorbelegt. 

  7. Thread 1 beginnt dort, wo er aufgehört hat, und führt die Codezeile //2 aus, wodurch ein weiteres  Singleton Objekt erstellt wird. 

  8. Thread 1 gibt dieses Objekt bei //3 zurück.

Das Ergebnis ist, dass  getInstance() die Methode zwei Objekte erstellt,  Singleton obwohl sie nur eines hätte erstellen sollen. Dieses Problem wird durch die Synchronisierung  getInstance() von Methoden behoben, sodass jeweils nur ein Thread Code ausführen darf, wie in Listing 2 gezeigt:


Listing 2. Thread-sichere getInstance()-Methode

öffentlicher statischer synchronisierter Singleton getInstance() 
{ 
  if (instance == null) //1 
    Instanz = new Singleton(); //2 
  Rückgabeinstanz; //3 
}

 

getInstance() Der Code in Listing 2 funktioniert gut für Multithread-Zugriffsmethoden  . Bei der Analyse dieses Codes stellen Sie jedoch fest, dass eine Synchronisierung nur beim ersten Aufruf der Methode erforderlich ist. Da nur der erste Aufruf den Code bei //2 ausführt und nur diese Codezeile synchronisiert werden muss, besteht bei nachfolgenden Aufrufen keine Notwendigkeit, die Synchronisierung zu verwenden. Alle anderen Aufrufe werden verwendet, um  instance true und false  zu ermitteln null und zurückzugeben. Mehrere Threads können alle Aufrufe außer dem ersten sicher gleichzeitig ausführen. Da es sich bei dieser Methode jedoch um eine Methode handelt synchronized , müssen Sie für jeden Aufruf dieser Methode den Preis für die Synchronisierung zahlen, auch wenn nur der erste Aufruf synchronisiert werden muss.

Um diesen Ansatz effizienter zu gestalten, wurde eine Redewendung namens Double-Checked Locking entwickelt. Die Idee besteht darin, die teuren Kosten für die Synchronisierung aller Anrufe bis auf den ersten Anruf zu vermeiden. Die Kosten für die Synchronisierung variieren zwischen verschiedenen JVMs. In der Anfangszeit war der Preis recht hoch. Mit dem Aufkommen fortschrittlicherer JVMs sind die Kosten für die Synchronisierung gesunken, synchronized es gibt jedoch immer noch Leistungseinbußen beim Eingeben und Verlassen von Methoden oder Blöcken. Ungeachtet der Fortschritte in der JVM-Technologie möchten Programmierer niemals unnötig Verarbeitungszeit verschwenden.

Da in Listing 2 nur die Zeile //2 synchronisiert werden muss, können wir sie einfach in einen synchronisierten Block einschließen, wie in Listing 3 gezeigt:


Listing 3. getInstance()-Methode

Code kopieren
public static Singleton getInstance() 
{ 
  if (instance == null) 
  { 
    synchronisiert(Singleton.class) { 
      Instanz = new Singleton(); 
    } 
  } 
  return-Instanz; 
}
Code kopieren

 

Der Code in Listing 3 zeigt das gleiche Problem wie Listing 1, veranschaulicht am Beispiel von Multithreading. Bei  instance true null können zwei Threads  if gleichzeitig an der Anweisung teilnehmen. Dann betritt ein Thread  synchronized den Block zur Initialisierung,  instancewährend der andere Thread blockiert ist. Wenn der erste Thread  synchronized den Block verlässt, tritt der wartende Thread ein und erstellt ein weiteres  Singleton Objekt. Hinweis: Wenn der zweite Thread  synchronized den Block betritt, prüft er nicht  instance auf Negation  null.

 

Doppelcheck-Verriegelung

Um das Problem in Listing 3 zu lösen, müssen wir  instance eine zweite Prüfung durchführen. Daher kommt auch der Name „doppelt geprüfte Schließung“. Listing 4 ist das Ergebnis der Anwendung der doppelt überprüften Sperrsprache auf Listing 3.


Listing 4. Doppelt überprüftes Verriegelungsbeispiel

Code kopieren
public static Singleton getInstance() 
{ 
  if (instance == null) 
  { 
    synchronisiert(Singleton.class) { //1 
      if (instance == null) //2 
        Instanz = new Singleton(); //3 
    } 
  } 
  Return-Instanz; 
}
Code kopieren

 

Die Theorie hinter dem doppelt geprüften Sperren besteht darin, dass die zweite Prüfung bei //2 es unmöglich macht (wie in Listing 3), zwei verschiedene  Singleton Objekte zu erstellen. Gehen Sie von folgender Abfolge von Ereignissen aus:

  1. Thread 1 betritt  getInstance() die Methode. 

  2. Thread 1 betritt den Block  aufgrund  instance von  //1  . nullsynchronized

  3. Thread 1 wird von Thread 2 vorbelegt.

  4. Thread 2 betritt  getInstance() die Methode.

  5. Da  instance es immer noch ist  null, versucht Thread 2, die Sperre bei //1 zu erlangen. Thread 2 blockiert jedoch bei //1, da Thread 1 die Sperre hält.

  6. Thread 2 wird von Thread 1 vorbelegt.

  7. Thread 1 wird ausgeführt, und da sich die Instanz noch bei //2 befindet  null, erstellt Thread 1 auch ein  Singleton Objekt und weist dessen Referenz zu  instance.

  8. Thread 1 verlässt  synchronized den Block und  getInstance() gibt die Instanz der Methode zurück. 

  9. Thread 1 wird von Thread 2 vorbelegt.

  10. Thread 2 erhält die Sperre bei //1 und prüft,  instance ob sie vorhanden ist  null

  11. Da  instance no  null , wird das zweite  Singleton Objekt nicht erstellt und das von Thread 1 erstellte Objekt zurückgegeben.

Die Theorie hinter der doppelt geprüften Verriegelung ist perfekt. Leider sieht die Realität ganz anders aus. Das Problem bei der doppelt überprüften Sperrung besteht darin, dass es keine Garantie dafür gibt, dass sie auf einem Einprozessor- oder Multiprozessor-Computer reibungslos funktioniert.

Das Scheitern der doppelt geprüften Sperre ist nicht auf einen Implementierungsfehler in der JVM zurückzuführen, sondern auf das Speichermodell der Java-Plattform. Das Speichermodell ermöglicht sogenannte „Out-of-Order-Schreibvorgänge“, und dies ist ein Hauptgrund für das Scheitern dieser Redewendungen.

 

außer der Reihe schreiben

Um dies zu erklären, muss die Zeile //3 in Listing 4 oben noch einmal betrachtet werden. Diese Codezeile erstellt ein  Singleton Objekt und initialisiert Variablen  instance , um auf dieses Objekt zu verweisen. Das Problem bei dieser Codezeile besteht darin, dass  die Variable  möglicherweise negiert wird,  Singleton bevor der Konstruktorkörper ausgeführt wird   .instancenull

Was? Diese Aussage mag Sie überraschen, aber sie ist wahr. Bevor Sie erklären, wie dieses Phänomen auftritt, akzeptieren Sie diese Tatsache bitte vorübergehend. Lassen Sie uns zunächst untersuchen, wie die doppelt überprüfte Sperre unterbrochen wird. Angenommen, der Code in Listing 4 führt die folgende Ereignissequenz aus:

  1. Thread 1 betritt  getInstance() die Methode.

  2. Thread 1 betritt den Block  aufgrund  instance von  //1  . nullsynchronized

  3. Thread 1 geht zu //3 über, aber bevor der Konstruktor ausführt , wird die Instanz negiert  null

  4. Thread 1 wird von Thread 2 vorbelegt.

  5. Thread 2 prüft, ob die Instanz  null. Da die Instanz nicht null ist, gibt Thread 2  instance einen Verweis auf ein vollständig erstelltes, aber teilweise initialisiertes  SingletonObjekt zurück. 

  6. Thread 2 wird von Thread 1 vorbelegt.

  7. Thread 1 schließt die Initialisierung des Objekts ab, indem er  Singleton den Konstruktor des Objekts ausführt und einen Verweis darauf zurückgibt.

Diese Ereignisfolge tritt ein, wenn Thread 2 ein Objekt zurückgibt, dessen Konstruktor noch nicht ausgeführt wurde.

Um dies zu veranschaulichen, gehen wir davon aus,  instance =new Singleton(); dass der folgende Pseudocode für die Codezeile ausgeführt wird: Instanz =new Singleton();

mem = allocate(); //Speicher für Singleton-Objekt zuweisen. 
Instanz = mem; //Beachten Sie, dass die Instanz jetzt ungleich Null ist, aber 
                              //nicht initialisiert wurde. 
ctorSingleton(instance); //Konstruktor für Singleton-Übergabe 
                              //instanz aufrufen.

 

Dieser Pseudocode ist nicht nur möglich, sondern kommt bei einigen JIT-Compilern tatsächlich vor. Die Ausführungsreihenfolge ist umgekehrt, aber angesichts des aktuellen Speichermodells ist dies zulässig. Dieses Verhalten des JIT-Compilers macht das Problem des doppelt überprüften Sperrens zu einer bloßen akademischen Übung.

Nehmen Sie zur Veranschaulichung den Code in Listing 5 an. Es enthält eine abgespeckte Version der  getInstance() Methode. Ich habe die „doppelte Prüfung“ entfernt, um unsere Überprüfung des generierten Assembler-Codes zu vereinfachen (Listing 6). Uns interessiert nur, wie der JIT-Compiler  instance=new Singleton(); den Code kompiliert. Darüber hinaus stelle ich einen einfachen Konstruktor zur Verfügung, um explizit zu veranschaulichen, wie dieser Konstruktor im Assemblercode funktioniert.


Listing 5. Singleton-Klasse zur Demonstration von Schreibvorgängen außerhalb der Reihenfolge

Code kopieren
Klasse Singleton 
{ 
  private statische Singleton-Instanz; 
  privater boolescher Wert inUse; 
  privater int val;  

  private Singleton() 
  { 
    inUse = true; 
    Wert = 5; 
  } 
  public static Singleton getInstance() 
  { 
    if (instance == null) 
      Instanz = new Singleton(); 
    Rückgabeinstanz; 
  } 
}
Code kopieren

 

getInstance() Listing 6 enthält den vom Sun JDK 1.2.1 JIT-Compiler generierten Assemblercode für den Methodenkörper in Listing 5  .


Listing 6. Assembler-Code, der aus dem Code in Listing 5 generiert wurde

Code kopieren
;ASM-Code generiert für getInstance 
054D20B0 mov eax,[049388C8] ;Instanzreferenz laden 
054D20B5 test eax,eax ;Test auf Null 
054D20B7 jne 054D20D7 
054D20B9 mov eax,14C0988h 
054D20BE Aufruf 503EF8F0 ;Speicher zuweisen 
054D20C3 mov [049388C8],eax ;Zeiger speichern in 
                                           ;Instanzref. Instanz   
                                           ;ungleich Null und ctor 
                                           ;wurde nicht ausgeführt 
054D20C8 mov ecx,dword ptr [eax]  
054D20CA mov dword ptr [ecx],1 ;inline ctor - inUse=true;
054D20D0 mov dword ptr [ecx+4],5 ;inline ctor - val=5;
054D20D7 mov ebx,dword ptr ds:[49388C8h] 
054D20DD jmp 054D20B0
Code kopieren

 

HINWEIS:  Um in den folgenden Anweisungen auf die Zeilen des Assembler-Codes zu verweisen, beziehe ich mich auf die letzten beiden Werte der Befehlsadresse, da diese beide mit beginnen  054D20 . Beispiel: B5 rep  test eax,eax.

Assemblercode wird durch die Ausführung eines Testprogramms generiert , das getInstance() Methoden in einer Endlosschleife  aufruft. Führen Sie während der Ausführung des Programms den Microsoft Visual C++-Debugger aus und hängen Sie ihn an den Java-Prozess an, der das Testprogramm darstellt. Brechen Sie dann die Ausführung ab und suchen Sie den Assembler-Code, der diese Endlosschleife darstellt.

B0B5 Die ersten beiden Zeilen des Assembler-Codes   laden  die instance Referenz aus dem Speicherort  049388C8 hinein  eax und  null überprüfen sie. getInstance() Dies entspricht der ersten Codezeile der Methode in Listing 5 . Beim ersten Aufruf dieser Methode führt der Code bis  instance zu aus  .  Der Code   weist Speicher für das Objekt aus dem Heap zu und speichert darin einen Zeiger auf diesen Speicherblock   . Die nächste Codezeile, , nimmt   den Zeiger auf und speichert ihn wieder im Speicher an  der Instanzreferenz. Das Ergebnis ist, dass  es jetzt NOT ist   und auf ein gültiges   Objekt verweist. Allerdings ist der Konstruktor für dieses Objekt noch nicht ausgeführt worden, was genau das ist, was die doppelt überprüfte Sperre unterbricht.  Anschließend   wird der Zeiger an der Zeile dereferenziert und in gespeichert  .  Die   Linien und stellen Inline-Konstruktoren dar, die Werte speichern   und   in   Objekten speichern. Wenn dieser Code   nach der Ausführungszeile und vor Abschluss des Konstruktors durch einen anderen Thread unterbrochen wird, schlägt die doppelt überprüfte Sperre fehl.nullB9BESingletoneaxC3eax049388C8instancenullSingletonC8instanceecxCAD0true5SingletonC3

Nicht alle JIT-Compiler generieren den oben genannten Code. Einige generieren Code, der erst nach der  instance Ausführung  des Konstruktors negiert wird null. Version 1.3 der IBM SDK für Java-Technologie und Sun JDK 1.3 generieren beide solchen Code. Dies bedeutet jedoch nicht, dass in diesen Fällen eine doppelt überprüfte Sperrung verwendet werden sollte. Es gibt noch einige andere Gründe, warum diese Redewendung scheitert. Außerdem wissen Sie nicht immer, auf welchen JVMs Ihr Code ausgeführt wird, und JIT-Compiler können jederzeit Änderungen vornehmen, um Code zu generieren, der gegen dieses Idiom verstößt.

 

Doppelt überprüfte Verriegelung: Erwirbt zwei

Da die aktuelle doppelt überprüfte Sperrung nicht funktioniert, habe ich eine andere Version des Codes eingefügt, die in Listing 7 gezeigt wird, um das gerade gesehene Problem beim Schreiben außerhalb der Reihenfolge zu verhindern.


Listing 7. Versuchen Sie, das Problem beim Schreiben außerhalb der Reihenfolge zu lösen

Code kopieren
public static Singleton getInstance() 
{ 
  if (instance == null) 
  { 
    synchronisiert(Singleton.class) { //1 
      Singleton inst = Instanz; //2 
      if (inst == null) 
      { 
        synchronisiert(Singleton.class) { //3 
          inst = new Singleton(); //4 
        } 
        Instanz = inst; //5 
      } 
    } 
  } 
  Return-Instanz; 
}
Code kopieren

 

Wenn Sie sich den Code in Listing 7 ansehen, sollten Sie erkennen, dass die Dinge etwas lächerlich werden. getInstance() Denken Sie daran, dass eine doppelt überprüfte Sperre erstellt wurde, um eine Synchronisierung bei einfachen dreizeiligen  Methoden zu vermeiden. Der Code in Listing 7 wird widerspenstig. Außerdem löst dieser Code das Problem nicht. Eine sorgfältige Untersuchung wird den Grund aufdecken.

Dieser Code versucht, das Problem des Schreibens außerhalb der Reihenfolge zu vermeiden. Es versucht, dieses Problem durch die Einführung lokaler Variablen  inst und eines zweiten  Blocks synchronized zu lösen . Die Theorie wird wie folgt umgesetzt:

  1. Thread 1 betritt  getInstance() die Methode.

  2. Thread 1 tritt wegen  //1 in den ersten Block   ein  instance . nullsynchronized

  3. Der Wert, den die lokale Variable  inst erhält  instance , der bei //2 liegt  null

  4. Aufgrund  inst dessen  nulltritt Thread 1 bei //3 in den zweiten  synchronized Block ein. 

  5. Thread 1 beginnt dann mit der Ausführung des Codes bei //4, während er negiert  inst ,  nullaber  Singleton bevor der Konstruktor von //4 ausgeführt wird. (Dies ist das Problem beim Schreiben außerhalb der Reihenfolge, das wir gerade gesehen haben.) 

  6. Thread 1 wird von Thread 2 vorbelegt.

  7. Thread 2 betritt  getInstance() die Methode.

  8. instance Aus diesem  Grund  versucht Thread 2, den ersten  Block bei //1 nulleinzugeben  . synchronizedDa Thread 1 derzeit die Sperre hält, ist Thread 2 blockiert.

  9. Thread 1 schließt die Ausführung dann bei //4 ab.

  10. Singleton Thread 1 weist dann der Variablen bei //5  ein vollständig konstruiertes  Objekt zu instanceund verlässt beide  synchronized Blöcke. 

  11. Thread 1 gibt zurück  instance.

  12. instance Führen Sie dann Thread 2 aus und weisen Sie ihn  bei //2  zu inst.

  13. Thread 2 stellt fest,  instance dass dies nicht der Fall ist  null, und gibt es zurück.

Die Schlüsselzeile hier ist //5. Diese Zeile sollte sicherstellen  instance , dass nur  null ein vollständiges  Singleton Objekt erstellt oder referenziert wird. Das Problem entsteht, wenn Theorie und Praxis im Widerspruch zueinander stehen.

Der Code in Listing 7 ist aufgrund der Definition des aktuellen Speichermodells ungültig. Die Java Language Specification ( JLS) schreibt vor  synchronized, dass Code innerhalb eines Blocks nicht verschoben werden kann. synchronized Es heißt jedoch nicht , dass Code außerhalb des Blocks  nicht in den Block verschoben werden kann  synchronized .

Der JIT-Compiler sieht hier eine Optimierungsmöglichkeit. Diese Optimierung entfernt den Code bei //4 und //5 und kombiniert und generiert den in Listing 8 gezeigten Code.


Listing 8. Der optimierte Code aus Listing 7.

Code kopieren
public static Singleton getInstance() 
{ 
  if (instance == null) 
  { 
    synchronisiert(Singleton.class) { //1 
      Singleton inst = Instanz; //2 
      if (inst == null) 
      { 
        synchronisiert(Singleton.class) { //3 
          //inst = new Singleton(); //4 
          Instanz = new Singleton();               
        } 
        //instance = inst; //5 
      } 
    } 
  } 
  Return-Instanz; 
}
Code kopieren

 

Wenn Sie diese Optimierung durchführen, werden Sie das gleiche Problem beim Schreiben außerhalb der Reihenfolge haben, das wir zuvor besprochen haben.

 

Wie wäre es, jede Variable als flüchtig zu deklarieren?

Eine andere Idee besteht darin, sowohl auf Variablen  inst als auch  instance auf Schlüsselwörter abzuzielen  volatile. Laut JLS (siehe Verwandte Themen)  volatile gelten deklarierte Variablen als sequentiell konsistent, dh nicht neu angeordnet. Ich versuche jedoch,  volatile das Problem der doppelt überprüften Sperre mit den folgenden zwei Problemen zu beheben:

  • Das Problem liegt hier nicht in der sequentiellen Konsistenz, sondern darin, dass der Code verschoben und nicht neu angeordnet wurde.

  • Selbst wenn die sequentielle Konsistenz berücksichtigt wird, implementieren die meisten JVMs sie nicht korrekt  volatile.

Der zweite Punkt verdient eine weitere Diskussion. Nehmen Sie den Code in Listing 9 an:


Listing 9. Sequentielle Konsistenz mit volatile

Code kopieren
Klassentest 
{ 
  private volatile boolean stop = false; 
  private volatile int num = 0; 

  public void foo() 
  { 
    num = 100; //Das kann passieren second 
    stop = true; //Dies kann zuerst passieren 
    //... 
  } 

  public void bar() 
  { 
    if (stop) 
      num += num; //num can == 0! 
  } 
  //... 
}
Code kopieren

 

stop Da und  num als deklariert sind  volatile, sollten sie laut JLS  sequentiell konsistent sein. Das heißt, wenn  stop es jemals so war  true, num muss es auf eingestellt worden sein  100. Aufgrund der sequentiellen Konsistenzfunktionen, die viele JVMs nicht implementieren  volatile , können Sie sich jedoch nicht auf dieses Verhalten verlassen. Wenn daher Thread 1  foo und Thread 2 gleichzeitig  aufrufen bar, wird Thread 1 möglicherweise   auf gesetzt   , bevor  num er auf gesetzt wird  .  Dies führt dazu, dass der Thread „Ja“  sieht,  während er   noch auf eingestellt ist  .  Es gibt zusätzliche Probleme bei der Verwendung von  Ordnungszahlen mit 64-Bit-Variablen, diese gehen jedoch über den Rahmen dieses Artikels hinaus. Weitere Informationen zu diesem Thema finden Sie unter Ressourcen.100stoptruestoptruenum0volatile

 

Lösung

Das Fazit lautet: Double-Checked Locking sollte in keiner Form verwendet werden, da Sie nicht garantieren können, dass es bei jeder JVM-Implementierung reibungslos funktioniert. Bei JSR-133 geht es um die Behebung von Problemen mit dem Speichermodell. Das neue Speichermodell unterstützt jedoch kein doppelt überprüftes Sperren. Daher haben Sie zwei Möglichkeiten:

  • Akzeptieren Sie die in Listing 2 gezeigte Methode  getInstance() zur Synchronisierung.

  • Geben Sie die Synchronisierung auf und verwenden Sie  static stattdessen ein Feld.

Option 2 ist in Listing 10 dargestellt


Listing 10. Singleton-Implementierung mit statischen Feldern

Code kopieren
Klasse Singleton 
{ 
  private Vector v; 
  privater boolescher Wert inUse; 
  private static Singleton-Instanz = new Singleton(); 

  private Singleton() 
  { 
    v = new Vector(); 
    inUse = true; 
    //... 
  } 

  public static Singleton getInstance() 
  { 
    return example; 
  } 
}
Code kopieren

 

Der Code in Listing 10 verwendet keine Synchronisierung und stellt sicher, dass  static getInstance() die Methode erst erstellt wird, wenn sie aufgerufen wird  Singleton. Dies ist eine großartige Option, wenn Sie die Synchronisierung vermeiden möchten.

 

String ist nicht unveränderlich

Angesichts des Problems, dass Schreibvorgänge außerhalb der Reihenfolge und Referenzen negiert werden, bevor der Konstruktor ausgeführt wird  null , könnten Sie  String Klassen in Betracht ziehen. Angenommen, Sie haben den folgenden Code:

private String str; 
//... 
str = new String("hello");

 

String Klassen sollten unveränderlich sein. Würde dies angesichts des zuvor besprochenen Problems beim Schreiben außerhalb der Reihenfolge hier jedoch ein Problem darstellen? Die Antwort ist ja. Betrachten Sie zwei Thread-Zugriffe String str. str Ein Thread kann einen Verweis auf ein  Objekt sehen  String , in dem der Konstruktor noch nicht ausgeführt wurde. Tatsächlich enthält Listing 11 Code, der dies zeigt. Beachten Sie, dass dieser Code nur auf der älteren JVM fehlschlägt, mit der ich ihn getestet habe. Sowohl IBM 1.3- als auch Sun 1.3-JVMs werden erwartungsgemäß unverändert generiert  String.


Listing 11. Beispiel für einen veränderlichen String

Code kopieren
Klasse StringCreator erweitert Thread 
{ 
  MutableString ms; 
  public StringCreator(MutableString muts) 
  { 
    ms = muts; 
  } 
  public void run() 
  { 
    while(true) 
      ms.str = new String("hello"); //1 
  } 
} 
class StringReader erweitert Thread 
{ 
  MutableString ms; 
  public StringReader(MutableString muts) 
  { 
    ms = muts; 
  } 
  public void run() 
  { 
    while(true) 
    { 
      if (!(ms.str.equals("hello"))) //2 
      { 
        System.out.println("String is not immutable!");
        brechen; 
      } 
    }
  } 
} 
class MutableString 
{ 
  public String str; //3 
  public static void main(String args[]) 
  { 
    MutableString ms = new MutableString(); //4 
    new StringCreator(ms).start(); //5 
    new StringReader(ms).start(); //6 
  } 
}
Code kopieren

 

Dieser Code erstellt eine  MutableString Klasse bei //4, die eine  String Referenz enthält, die von den beiden Threads bei //3 gemeinsam genutzt wird. StringCreator In den Zeilen //5 und //6 werden zwei Objekte und  in zwei separaten Threads erstellt  StringReader. Übergeben Sie einen Verweis auf ein  MutableString Objekt. StringCreator Die Klasse tritt in eine Endlosschleife ein und erstellt  String das Objekt bei //1 mit dem Wert „hello“. StringReader Tritt außerdem in eine Endlosschleife ein und prüft bei //2,  String ob der Wert des aktuellen Objekts „Hallo“ ist. Wenn nicht, StringReader gibt der Thread eine Nachricht aus und stoppt. Wenn  String die Klasse unveränderlich ist, sollten Sie keine Ausgabe dieses Programms sehen. Wenn ein Problem beim Schreiben außerhalb der Reihenfolge auftritt, ist die einzige Möglichkeit, eine Referenz zu  StringReader sehen  str , niemals  String ein Objekt mit dem Wert „Hallo“.

Das Ausführen dieses Codes auf einer älteren JVM wie Sun JDK 1.2.1 führt zu Problemen beim Schreiben außerhalb der Reihenfolge. und ergeben somit eine nichtinvariante  String.

 

Abschluss

Um eine kostspielige Synchronisierung in Singletons zu vermeiden, waren die Programmierer sehr clever und erfanden die doppelt geprüfte Sperrsprache. Leider ist diese Redewendung angesichts des aktuellen Speichermodells noch nicht weit verbreitet und es handelt sich eindeutig um ein unsicheres Programmierkonstrukt. Die Arbeiten in diesem Bereich der Neudefinition des fragilen Gedächtnismodells sind im Gange. Selbst im neu vorgeschlagenen Speichermodell ist die doppelt überprüfte Sperre jedoch wirkungslos. Die beste Lösung für dieses Problem besteht darin, die Synchronisierung zu akzeptieren oder eine solche zu verwenden  static field.

 

Verweise

Das Singleton-Erstellungsmuster ist eine gängige Programmiersprache. Bei Verwendung mit mehreren Threads ist eine Art Synchronisierung erforderlich. Um effizienteren Code zu erstellen, haben Java- Programmierer das Double-Checked-Locking-Idiom entwickelt, das sie mit dem Singleton-Erstellungsmuster verwenden, um die Menge des Synchronisationscodes zu begrenzen. Aufgrund einiger weniger verbreiteter Details des Java-Speichermodells gibt es jedoch keine Garantie dafür, dass diese doppelt überprüfte Sperrsprache funktioniert.

Es schlägt gelegentlich fehl, nicht immer. Außerdem schlägt es aus nicht offensichtlichen Gründen fehl und enthält einige kryptische Details des Java-Speichermodells. Diese Tatsachen führen dazu, dass der Code fehlschlägt, da eine doppelt überprüfte Sperrung schwer nachzuverfolgen ist. Im weiteren Verlauf dieses Artikels gehen wir detailliert auf die doppelt überprüfte Sperrsprache ein, um zu verstehen, wo sie fehlschlägt.


Um zu verstehen, woher das Double-Checked-Locking-Idiom stammt, muss man das gängige Singleton-Erstellungsidiom verstehen, wie in Listing 1 dargestellt:


Listing 1. Das Idiom zur Singleton-Erstellung

Code kopieren
import java.util.*; 
Klasse Singleton 
{ 
  private statische Singleton-Instanz; 
  privater Vektor v; 
  privater boolescher Wert inUse; 

  private Singleton() 
  { 
    v = new Vector(); 
    v.addElement(new Object()); 
    inUse = true; 
  } 

  public static Singleton getInstance() 
  { 
    if (instance == null) //1 
      Instanz = new Singleton(); //2 
    Rückgabeinstanz; //3 
  } 
}
Code kopieren

 

Das Design dieser Klasse stellt sicher, dass nur ein  Singleton Objekt erstellt wird. Konstruktoren werden als deklariert  private, getInstance() Methoden erstellen lediglich ein Objekt. Diese Implementierung eignet sich für Single-Threaded-Programme. Bei der Einführung von Multithreading  getInstance() müssen Methoden jedoch durch Synchronisierung geschützt werden. Wenn die Methode nicht protected ist  getInstance() , Singleton werden möglicherweise zwei verschiedene Instanzen des Objekts zurückgegeben. Angenommen, zwei Threads rufen  getInstance() gleichzeitig Methoden auf und die Aufrufe werden in der folgenden Reihenfolge ausgeführt:

  1. Thread 1 ruft  getInstance() die Methode auf und entscheidet sich  instance für //1  null

  2. Thread 1 trat in  if den Codeblock ein, wurde jedoch von Thread 2 beim Ausführen der Codezeile bei //2 vorbelegt. 

  3. Thread 2 ruft  die Methode auf und  entscheidet  getInstance() bei //1  instancenull

  4. Thread 2 betritt  if den Codeblock, erstellt ein neues  Singleton Objekt und weist die Variable diesem neuen Objekt unter //2 zu  instance . 

  5. Thread 2 gibt die Objektreferenz bei //3 zurück  Singleton .

  6. Thread 2 wird von Thread 1 vorbelegt. 

  7. Thread 1 beginnt dort, wo er aufgehört hat, und führt die Codezeile //2 aus, wodurch ein weiteres  Singleton Objekt erstellt wird. 

  8. Thread 1 gibt dieses Objekt bei //3 zurück.

Das Ergebnis ist, dass  getInstance() die Methode zwei Objekte erstellt,  Singleton obwohl sie nur eines hätte erstellen sollen. Dieses Problem wird durch die Synchronisierung  getInstance() von Methoden behoben, sodass jeweils nur ein Thread Code ausführen darf, wie in Listing 2 gezeigt:


Listing 2. Thread-sichere getInstance()-Methode

öffentlicher statischer synchronisierter Singleton getInstance() 
{ 
  if (instance == null) //1 
    Instanz = new Singleton(); //2 
  Rückgabeinstanz; //3 
}

 

getInstance() Der Code in Listing 2 funktioniert gut für Multithread-Zugriffsmethoden  . Bei der Analyse dieses Codes stellen Sie jedoch fest, dass eine Synchronisierung nur beim ersten Aufruf der Methode erforderlich ist. Da nur der erste Aufruf den Code bei //2 ausführt und nur diese Codezeile synchronisiert werden muss, besteht bei nachfolgenden Aufrufen keine Notwendigkeit, die Synchronisierung zu verwenden. Alle anderen Aufrufe werden verwendet, um  instance true und false  zu ermitteln null und zurückzugeben. Mehrere Threads können alle Aufrufe außer dem ersten sicher gleichzeitig ausführen. Da es sich bei dieser Methode jedoch um eine Methode handelt synchronized , müssen Sie für jeden Aufruf dieser Methode den Preis für die Synchronisierung zahlen, auch wenn nur der erste Aufruf synchronisiert werden muss.

Um diesen Ansatz effizienter zu gestalten, wurde eine Redewendung namens Double-Checked Locking entwickelt. Die Idee besteht darin, die teuren Kosten für die Synchronisierung aller Anrufe bis auf den ersten Anruf zu vermeiden. Die Kosten für die Synchronisierung variieren zwischen verschiedenen JVMs. In der Anfangszeit war der Preis recht hoch. Mit dem Aufkommen fortschrittlicherer JVMs sind die Kosten für die Synchronisierung gesunken, synchronized es gibt jedoch immer noch Leistungseinbußen beim Eingeben und Verlassen von Methoden oder Blöcken. Ungeachtet der Fortschritte in der JVM-Technologie möchten Programmierer niemals unnötig Verarbeitungszeit verschwenden.

Da in Listing 2 nur die Zeile //2 synchronisiert werden muss, können wir sie einfach in einen synchronisierten Block einschließen, wie in Listing 3 gezeigt:


Listing 3. getInstance()-Methode

Code kopieren
public static Singleton getInstance() 
{ 
  if (instance == null) 
  { 
    synchronisiert(Singleton.class) { 
      Instanz = new Singleton(); 
    } 
  } 
  return-Instanz; 
}
Code kopieren

 

Der Code in Listing 3 zeigt das gleiche Problem wie Listing 1, veranschaulicht am Beispiel von Multithreading. Bei  instance true null können zwei Threads  if gleichzeitig an der Anweisung teilnehmen. Dann betritt ein Thread  synchronized den Block zur Initialisierung,  instancewährend der andere Thread blockiert ist. Wenn der erste Thread  synchronized den Block verlässt, tritt der wartende Thread ein und erstellt ein weiteres  Singleton Objekt. Hinweis: Wenn der zweite Thread  synchronized den Block betritt, prüft er nicht  instance auf Negation  null.

 

Doppelcheck-Verriegelung

Um das Problem in Listing 3 zu lösen, müssen wir  instance eine zweite Prüfung durchführen. Daher kommt auch der Name „doppelt geprüfte Schließung“. Listing 4 ist das Ergebnis der Anwendung der doppelt überprüften Sperrsprache auf Listing 3.


Listing 4. Doppelt überprüftes Verriegelungsbeispiel

Code kopieren
public static Singleton getInstance() 
{ 
  if (instance == null) 
  { 
    synchronisiert(Singleton.class) { //1 
      if (instance == null) //2 
        Instanz = new Singleton(); //3 
    } 
  } 
  Return-Instanz; 
}
Code kopieren

 

Die Theorie hinter dem doppelt geprüften Sperren besteht darin, dass die zweite Prüfung bei //2 es unmöglich macht (wie in Listing 3), zwei verschiedene  Singleton Objekte zu erstellen. Gehen Sie von folgender Abfolge von Ereignissen aus:

  1. Thread 1 betritt  getInstance() die Methode. 

  2. Thread 1 betritt den Block  aufgrund  instance von  //1  . nullsynchronized

  3. Thread 1 wird von Thread 2 vorbelegt.

  4. Thread 2 betritt  getInstance() die Methode.

  5. Da  instance es immer noch ist  null, versucht Thread 2, die Sperre bei //1 zu erlangen. Thread 2 blockiert jedoch bei //1, da Thread 1 die Sperre hält.

  6. Thread 2 wird von Thread 1 vorbelegt.

  7. Thread 1 wird ausgeführt, und da sich die Instanz noch bei //2 befindet  null, erstellt Thread 1 auch ein  Singleton Objekt und weist dessen Referenz zu  instance.

  8. Thread 1 verlässt  synchronized den Block und  getInstance() gibt die Instanz der Methode zurück. 

  9. Thread 1 wird von Thread 2 vorbelegt.

  10. Thread 2 erhält die Sperre bei //1 und prüft,  instance ob sie vorhanden ist  null

  11. Da  instance no  null , wird das zweite  Singleton Objekt nicht erstellt und das von Thread 1 erstellte Objekt zurückgegeben.

Die Theorie hinter der doppelt geprüften Verriegelung ist perfekt. Leider sieht die Realität ganz anders aus. Das Problem bei der doppelt überprüften Sperrung besteht darin, dass es keine Garantie dafür gibt, dass sie auf einem Einprozessor- oder Multiprozessor-Computer reibungslos funktioniert.

Das Scheitern der doppelt geprüften Sperre ist nicht auf einen Implementierungsfehler in der JVM zurückzuführen, sondern auf das Speichermodell der Java-Plattform. Das Speichermodell ermöglicht sogenannte „Out-of-Order-Schreibvorgänge“, und dies ist ein Hauptgrund für das Scheitern dieser Redewendungen.

 

außer der Reihe schreiben

Um dies zu erklären, muss die Zeile //3 in Listing 4 oben noch einmal betrachtet werden. Diese Codezeile erstellt ein  Singleton Objekt und initialisiert Variablen  instance , um auf dieses Objekt zu verweisen. Das Problem bei dieser Codezeile besteht darin, dass  die Variable  möglicherweise negiert wird,  Singleton bevor der Konstruktorkörper ausgeführt wird   .instancenull

Was? Diese Aussage mag Sie überraschen, aber sie ist wahr. Bevor Sie erklären, wie dieses Phänomen auftritt, akzeptieren Sie diese Tatsache bitte vorübergehend. Lassen Sie uns zunächst untersuchen, wie die doppelt überprüfte Sperre unterbrochen wird. Angenommen, der Code in Listing 4 führt die folgende Ereignissequenz aus:

  1. Thread 1 betritt  getInstance() die Methode.

  2. Thread 1 betritt den Block  aufgrund  instance von  //1  . nullsynchronized

  3. Thread 1 geht zu //3 über, aber bevor der Konstruktor ausführt , wird die Instanz negiert  null

  4. Thread 1 wird von Thread 2 vorbelegt.

  5. Thread 2 prüft, ob die Instanz  null. Da die Instanz nicht null ist, gibt Thread 2  instance einen Verweis auf ein vollständig erstelltes, aber teilweise initialisiertes  SingletonObjekt zurück. 

  6. Thread 2 wird von Thread 1 vorbelegt.

  7. Thread 1 schließt die Initialisierung des Objekts ab, indem er  Singleton den Konstruktor des Objekts ausführt und einen Verweis darauf zurückgibt.

Diese Ereignisfolge tritt ein, wenn Thread 2 ein Objekt zurückgibt, dessen Konstruktor noch nicht ausgeführt wurde.

Um dies zu veranschaulichen, gehen wir davon aus,  instance =new Singleton(); dass der folgende Pseudocode für die Codezeile ausgeführt wird: Instanz =new Singleton();

mem = allocate(); //Speicher für Singleton-Objekt zuweisen. 
Instanz = mem; //Beachten Sie, dass die Instanz jetzt ungleich Null ist, aber 
                              //nicht initialisiert wurde. 
ctorSingleton(instance); //Konstruktor für Singleton-Übergabe 
                              //instanz aufrufen.

 

Dieser Pseudocode ist nicht nur möglich, sondern kommt bei einigen JIT-Compilern tatsächlich vor. Die Ausführungsreihenfolge ist umgekehrt, aber angesichts des aktuellen Speichermodells ist dies zulässig. Dieses Verhalten des JIT-Compilers macht das Problem des doppelt überprüften Sperrens zu einer bloßen akademischen Übung.

Nehmen Sie zur Veranschaulichung den Code in Listing 5 an. Es enthält eine abgespeckte Version der  getInstance() Methode. Ich habe die „doppelte Prüfung“ entfernt, um unsere Überprüfung des generierten Assembler-Codes zu vereinfachen (Listing 6). Uns interessiert nur, wie der JIT-Compiler  instance=new Singleton(); den Code kompiliert. Darüber hinaus stelle ich einen einfachen Konstruktor zur Verfügung, um explizit zu veranschaulichen, wie dieser Konstruktor im Assemblercode funktioniert.


Listing 5. Singleton-Klasse zur Demonstration von Schreibvorgängen außerhalb der Reihenfolge

Code kopieren
Klasse Singleton 
{ 
  private statische Singleton-Instanz; 
  privater boolescher Wert inUse; 
  privater int val;  

  private Singleton() 
  { 
    inUse = true; 
    Wert = 5; 
  } 
  public static Singleton getInstance() 
  { 
    if (instance == null) 
      Instanz = new Singleton(); 
    Rückgabeinstanz; 
  } 
}
Code kopieren

 

getInstance() Listing 6 enthält den vom Sun JDK 1.2.1 JIT-Compiler generierten Assemblercode für den Methodenkörper in Listing 5  .


Listing 6. Assembler-Code, der aus dem Code in Listing 5 generiert wurde

Code kopieren
;ASM-Code generiert für getInstance 
054D20B0 mov eax,[049388C8] ;Instanzreferenz laden 
054D20B5 test eax,eax ;Test auf Null 
054D20B7 jne 054D20D7 
054D20B9 mov eax,14C0988h 
054D20BE Aufruf 503EF8F0 ;Speicher zuweisen 
054D20C3 mov [049388C8],eax ;Zeiger speichern in 
                                           ;Instanzref. Instanz   
                                           ;ungleich Null und ctor 
                                           ;wurde nicht ausgeführt 
054D20C8 mov ecx,dword ptr [eax]  
054D20CA mov dword ptr [ecx],1 ;inline ctor - inUse=true;
054D20D0 mov dword ptr [ecx+4],5 ;inline ctor - val=5;
054D20D7 mov ebx,dword ptr ds:[49388C8h] 
054D20DD jmp 054D20B0
Code kopieren

 

HINWEIS:  Um in den folgenden Anweisungen auf die Zeilen des Assembler-Codes zu verweisen, beziehe ich mich auf die letzten beiden Werte der Befehlsadresse, da diese beide mit beginnen  054D20 . Beispiel: B5 rep  test eax,eax.

Assemblercode wird durch die Ausführung eines Testprogramms generiert , das getInstance() Methoden in einer Endlosschleife  aufruft. Führen Sie während der Ausführung des Programms den Microsoft Visual C++-Debugger aus und hängen Sie ihn an den Java-Prozess an, der das Testprogramm darstellt. Brechen Sie dann die Ausführung ab und suchen Sie den Assembler-Code, der diese Endlosschleife darstellt.

B0B5 Die ersten beiden Zeilen des Assembler-Codes   laden  die instance Referenz aus dem Speicherort  049388C8 hinein  eax und  null überprüfen sie. getInstance() Dies entspricht der ersten Codezeile der Methode in Listing 5 . Beim ersten Aufruf dieser Methode führt der Code bis  instance zu aus  .  Der Code   weist Speicher für das Objekt aus dem Heap zu und speichert darin einen Zeiger auf diesen Speicherblock   . Die nächste Codezeile, , nimmt   den Zeiger auf und speichert ihn wieder im Speicher an  der Instanzreferenz. Das Ergebnis ist, dass  es jetzt NOT ist   und auf ein gültiges   Objekt verweist. Allerdings ist der Konstruktor für dieses Objekt noch nicht ausgeführt worden, was genau das ist, was die doppelt überprüfte Sperre unterbricht.  Anschließend   wird der Zeiger an der Zeile dereferenziert und in gespeichert  .  Die   Linien und stellen Inline-Konstruktoren dar, die Werte speichern   und   in   Objekten speichern. Wenn dieser Code   nach der Ausführungszeile und vor Abschluss des Konstruktors durch einen anderen Thread unterbrochen wird, schlägt die doppelt überprüfte Sperre fehl.nullB9BESingletoneaxC3eax049388C8instancenullSingletonC8instanceecxCAD0true5SingletonC3

Nicht alle JIT-Compiler generieren den oben genannten Code. Einige generieren Code, der erst nach der  instance Ausführung  des Konstruktors negiert wird null. Version 1.3 der IBM SDK für Java-Technologie und Sun JDK 1.3 generieren beide solchen Code. Dies bedeutet jedoch nicht, dass in diesen Fällen eine doppelt überprüfte Sperrung verwendet werden sollte. Es gibt noch einige andere Gründe, warum diese Redewendung scheitert. Außerdem wissen Sie nicht immer, auf welchen JVMs Ihr Code ausgeführt wird, und JIT-Compiler können jederzeit Änderungen vornehmen, um Code zu generieren, der gegen dieses Idiom verstößt.

 

Doppelt überprüfte Verriegelung: Erwirbt zwei

Da die aktuelle doppelt überprüfte Sperrung nicht funktioniert, habe ich eine andere Version des Codes eingefügt, die in Listing 7 gezeigt wird, um das gerade gesehene Problem beim Schreiben außerhalb der Reihenfolge zu verhindern.


Listing 7. Versuchen Sie, das Problem beim Schreiben außerhalb der Reihenfolge zu lösen

Code kopieren
public static Singleton getInstance() 
{ 
  if (instance == null) 
  { 
    synchronisiert(Singleton.class) { //1 
      Singleton inst = Instanz; //2 
      if (inst == null) 
      { 
        synchronisiert(Singleton.class) { //3 
          inst = new Singleton(); //4 
        } 
        Instanz = inst; //5 
      } 
    } 
  } 
  Return-Instanz; 
}
Code kopieren

 

Wenn Sie sich den Code in Listing 7 ansehen, sollten Sie erkennen, dass die Dinge etwas lächerlich werden. getInstance() Denken Sie daran, dass eine doppelt überprüfte Sperre erstellt wurde, um eine Synchronisierung bei einfachen dreizeiligen  Methoden zu vermeiden. Der Code in Listing 7 wird widerspenstig. Außerdem löst dieser Code das Problem nicht. Eine sorgfältige Untersuchung wird den Grund aufdecken.

Dieser Code versucht, das Problem des Schreibens außerhalb der Reihenfolge zu vermeiden. Es versucht, dieses Problem durch die Einführung lokaler Variablen  inst und eines zweiten  Blocks synchronized zu lösen . Die Theorie wird wie folgt umgesetzt:

  1. Thread 1 betritt  getInstance() die Methode.

  2. Thread 1 tritt wegen  //1 in den ersten Block   ein  instance . nullsynchronized

  3. Der Wert, den die lokale Variable  inst erhält  instance , der bei //2 liegt  null

  4. Aufgrund  inst dessen  nulltritt Thread 1 bei //3 in den zweiten  synchronized Block ein. 

  5. Thread 1 beginnt dann mit der Ausführung des Codes bei //4, während er negiert  inst ,  nullaber  Singleton bevor der Konstruktor von //4 ausgeführt wird. (Dies ist das Problem beim Schreiben außerhalb der Reihenfolge, das wir gerade gesehen haben.) 

  6. Thread 1 wird von Thread 2 vorbelegt.

  7. Thread 2 betritt  getInstance() die Methode.

  8. instance Aus diesem  Grund  versucht Thread 2, den ersten  Block bei //1 nulleinzugeben  . synchronizedDa Thread 1 derzeit die Sperre hält, ist Thread 2 blockiert.

  9. Thread 1 schließt die Ausführung dann bei //4 ab.

  10. Singleton Thread 1 weist dann der Variablen bei //5  ein vollständig konstruiertes  Objekt zu instanceund verlässt beide  synchronized Blöcke. 

  11. Thread 1 gibt zurück  instance.

  12. instance Führen Sie dann Thread 2 aus und weisen Sie ihn  bei //2  zu inst.

  13. Thread 2 stellt fest,  instance dass dies nicht der Fall ist  null, und gibt es zurück.

Die Schlüsselzeile hier ist //5. Diese Zeile sollte sicherstellen  instance , dass nur  null ein vollständiges  Singleton Objekt erstellt oder referenziert wird. Das Problem entsteht, wenn Theorie und Praxis im Widerspruch zueinander stehen.

Der Code in Listing 7 ist aufgrund der Definition des aktuellen Speichermodells ungültig. Die Java Language Specification ( JLS) schreibt vor  synchronized, dass Code innerhalb eines Blocks nicht verschoben werden kann. synchronized Es heißt jedoch nicht , dass Code außerhalb des Blocks  nicht in den Block verschoben werden kann  synchronized .

Der JIT-Compiler sieht hier eine Optimierungsmöglichkeit. Diese Optimierung entfernt den Code bei //4 und //5 und kombiniert und generiert den in Listing 8 gezeigten Code.


Listing 8. Der optimierte Code aus Listing 7.

Code kopieren
public static Singleton getInstance() 
{ 
  if (instance == null) 
  { 
    synchronisiert(Singleton.class) { //1 
      Singleton inst = Instanz; //2 
      if (inst == null) 
      { 
        synchronisiert(Singleton.class) { //3 
          //inst = new Singleton(); //4 
          Instanz = new Singleton();               
        } 
        //instance = inst; //5 
      } 
    } 
  } 
  Return-Instanz; 
}
Code kopieren

 

Wenn Sie diese Optimierung durchführen, werden Sie das gleiche Problem beim Schreiben außerhalb der Reihenfolge haben, das wir zuvor besprochen haben.

 

Wie wäre es, jede Variable als flüchtig zu deklarieren?

Eine andere Idee besteht darin, sowohl auf Variablen  inst als auch  instance auf Schlüsselwörter abzuzielen  volatile. Laut JLS (siehe Verwandte Themen)  volatile gelten deklarierte Variablen als sequentiell konsistent, dh nicht neu angeordnet. Ich versuche jedoch,  volatile das Problem der doppelt überprüften Sperre mit den folgenden zwei Problemen zu beheben:

  • Das Problem liegt hier nicht in der sequentiellen Konsistenz, sondern darin, dass der Code verschoben und nicht neu angeordnet wurde.

  • Selbst wenn die sequentielle Konsistenz berücksichtigt wird, implementieren die meisten JVMs sie nicht korrekt  volatile.

Der zweite Punkt verdient eine weitere Diskussion. Nehmen Sie den Code in Listing 9 an:


Listing 9. Sequentielle Konsistenz mit volatile

Code kopieren
Klassentest 
{ 
  private volatile boolean stop = false; 
  private volatile int num = 0; 

  public void foo() 
  { 
    num = 100; //Das kann passieren second 
    stop = true; //Dies kann zuerst passieren 
    //... 
  } 

  public void bar() 
  { 
    if (stop) 
      num += num; //num can == 0! 
  } 
  //... 
}
Code kopieren

 

stop Da und  num als deklariert sind  volatile, sollten sie laut JLS  sequentiell konsistent sein. Das heißt, wenn  stop es jemals so war  true, num muss es auf eingestellt worden sein  100. Aufgrund der sequentiellen Konsistenzfunktionen, die viele JVMs nicht implementieren  volatile , können Sie sich jedoch nicht auf dieses Verhalten verlassen. Wenn daher Thread 1  foo und Thread 2 gleichzeitig  aufrufen bar, wird Thread 1 möglicherweise   auf gesetzt   , bevor  num er auf gesetzt wird  .  Dies führt dazu, dass der Thread „Ja“  sieht,  während er   noch auf eingestellt ist  .  Es gibt zusätzliche Probleme bei der Verwendung von  Ordnungszahlen mit 64-Bit-Variablen, diese gehen jedoch über den Rahmen dieses Artikels hinaus. Weitere Informationen zu diesem Thema finden Sie unter Ressourcen.100stoptruestoptruenum0volatile

 

Lösung

Das Fazit lautet: Double-Checked Locking sollte in keiner Form verwendet werden, da Sie nicht garantieren können, dass es bei jeder JVM-Implementierung reibungslos funktioniert. Bei JSR-133 geht es um die Behebung von Problemen mit dem Speichermodell. Das neue Speichermodell unterstützt jedoch kein doppelt überprüftes Sperren. Daher haben Sie zwei Möglichkeiten:

  • Akzeptieren Sie die in Listing 2 gezeigte Methode  getInstance() zur Synchronisierung.

  • Geben Sie die Synchronisierung auf und verwenden Sie  static stattdessen ein Feld.

Option 2 ist in Listing 10 dargestellt


Listing 10. Singleton-Implementierung mit statischen Feldern

Code kopieren
Klasse Singleton 
{ 
  private Vector v; 
  privater boolescher Wert inUse; 
  private static Singleton-Instanz = new Singleton(); 

  private Singleton() 
  { 
    v = new Vector(); 
    inUse = true; 
    //... 
  } 

  public static Singleton getInstance() 
  { 
    return example; 
  } 
}
Code kopieren

 

Der Code in Listing 10 verwendet keine Synchronisierung und stellt sicher, dass  static getInstance() die Methode erst erstellt wird, wenn sie aufgerufen wird  Singleton. Dies ist eine großartige Option, wenn Sie die Synchronisierung vermeiden möchten.

 

String ist nicht unveränderlich

Angesichts des Problems, dass Schreibvorgänge außerhalb der Reihenfolge und Referenzen negiert werden, bevor der Konstruktor ausgeführt wird  null , könnten Sie  String Klassen in Betracht ziehen. Angenommen, Sie haben den folgenden Code:

private String str; 
//... 
str = new String("hello");

 

String Klassen sollten unveränderlich sein. Würde dies angesichts des zuvor besprochenen Problems beim Schreiben außerhalb der Reihenfolge hier jedoch ein Problem darstellen? Die Antwort ist ja. Betrachten Sie zwei Thread-Zugriffe String str. str Ein Thread kann einen Verweis auf ein  Objekt sehen  String , in dem der Konstruktor noch nicht ausgeführt wurde. Tatsächlich enthält Listing 11 Code, der dies zeigt. Beachten Sie, dass dieser Code nur auf der älteren JVM fehlschlägt, mit der ich ihn getestet habe. Sowohl IBM 1.3- als auch Sun 1.3-JVMs werden erwartungsgemäß unverändert generiert  String.


Listing 11. Beispiel für einen veränderlichen String

Code kopieren
Klasse StringCreator erweitert Thread 
{ 
  MutableString ms; 
  public StringCreator(MutableString muts) 
  { 
    ms = muts; 
  } 
  public void run() 
  { 
    while(true) 
      ms.str = new String("hello"); //1 
  } 
} 
class StringReader erweitert Thread 
{ 
  MutableString ms; 
  public StringReader(MutableString muts) 
  { 
    ms = muts; 
  } 
  public void run() 
  { 
    while(true) 
    { 
      if (!(ms.str.equals("hello"))) //2 
      { 
        System.out.println("String is not immutable!");
        brechen; 
      } 
    }
  } 
} 
class MutableString 
{ 
  public String str; //3 
  public static void main(String args[]) 
  { 
    MutableString ms = new MutableString(); //4 
    new StringCreator(ms).start(); //5 
    new StringReader(ms).start(); //6 
  } 
}
Code kopieren

 

Dieser Code erstellt eine  MutableString Klasse bei //4, die eine  String Referenz enthält, die von den beiden Threads bei //3 gemeinsam genutzt wird. StringCreator In den Zeilen //5 und //6 werden zwei Objekte und  in zwei separaten Threads erstellt  StringReader. Übergeben Sie einen Verweis auf ein  MutableString Objekt. StringCreator Die Klasse tritt in eine Endlosschleife ein und erstellt  String das Objekt bei //1 mit dem Wert „hello“. StringReader Tritt außerdem in eine Endlosschleife ein und prüft bei //2,  String ob der Wert des aktuellen Objekts „Hallo“ ist. Wenn nicht, StringReader gibt der Thread eine Nachricht aus und stoppt. Wenn  String die Klasse unveränderlich ist, sollten Sie keine Ausgabe dieses Programms sehen. Wenn ein Problem beim Schreiben außerhalb der Reihenfolge auftritt, ist die einzige Möglichkeit, eine Referenz zu  StringReader sehen  str , niemals  String ein Objekt mit dem Wert „Hallo“.

Das Ausführen dieses Codes auf einer älteren JVM wie Sun JDK 1.2.1 führt zu Problemen beim Schreiben außerhalb der Reihenfolge. und ergeben somit eine nichtinvariante  String.

 

Abschluss

Um eine kostspielige Synchronisierung in Singletons zu vermeiden, waren die Programmierer sehr clever und erfanden die doppelt geprüfte Sperrsprache. Leider ist diese Redewendung angesichts des aktuellen Speichermodells noch nicht weit verbreitet und es handelt sich eindeutig um ein unsicheres Programmierkonstrukt. Die Arbeiten in diesem Bereich der Neudefinition des fragilen Gedächtnismodells sind im Gange. Selbst im neu vorgeschlagenen Speichermodell ist die doppelt überprüfte Sperre jedoch wirkungslos. Die beste Lösung für dieses Problem besteht darin, die Synchronisierung zu akzeptieren oder eine solche zu verwenden  static field.

 

Verweise

Ich denke du magst

Origin blog.csdn.net/qq_34507736/article/details/60598737
Empfohlen
Rangfolge