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
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 } }
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:
- Thread 1 ruft
getInstance()
die Methode auf und entscheidet sichinstance
für //1null
.
- Thread 1 trat in
if
den Codeblock ein, wurde jedoch von Thread 2 beim Ausführen der Codezeile bei //2 vorbelegt.
- Thread 2 ruft die Methode auf und entscheidet
getInstance()
bei //1 .instance
null
- Thread 2 betritt
if
den Codeblock, erstellt ein neuesSingleton
Objekt und weist die Variable diesem neuen Objekt unter //2 zuinstance
.
- Thread 2 gibt die Objektreferenz bei //3 zurück
Singleton
.
- Thread 2 wird von Thread 1 vorbelegt.
- Thread 1 beginnt dort, wo er aufgehört hat, und führt die Codezeile //2 aus, wodurch ein weiteres
Singleton
Objekt erstellt wird.
- 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
public static Singleton getInstance() { if (instance == null) { synchronisiert(Singleton.class) { Instanz = new Singleton(); } } return-Instanz; }
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, instance
wä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
public static Singleton getInstance() { if (instance == null) { synchronisiert(Singleton.class) { //1 if (instance == null) //2 Instanz = new Singleton(); //3 } } Return-Instanz; }
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:
- Thread 1 betritt
getInstance()
die Methode.
- Thread 1 betritt den Block aufgrund
instance
von //1 .null
synchronized
- Thread 1 wird von Thread 2 vorbelegt.
- Thread 2 betritt
getInstance()
die Methode.
- Da
instance
es immer noch istnull
, versucht Thread 2, die Sperre bei //1 zu erlangen. Thread 2 blockiert jedoch bei //1, da Thread 1 die Sperre hält.
- Thread 2 wird von Thread 1 vorbelegt.
- Thread 1 wird ausgeführt, und da sich die Instanz noch bei //2 befindet
null
, erstellt Thread 1 auch einSingleton
Objekt und weist dessen Referenz zuinstance
.
- Thread 1 verlässt
synchronized
den Block undgetInstance()
gibt die Instanz der Methode zurück.
- Thread 1 wird von Thread 2 vorbelegt.
- Thread 2 erhält die Sperre bei //1 und prüft,
instance
ob sie vorhanden istnull
.
- Da
instance
nonull
, wird das zweiteSingleton
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 .instance
null
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:
- Thread 1 betritt
getInstance()
die Methode.
- Thread 1 betritt den Block aufgrund
instance
von //1 .null
synchronized
- Thread 1 geht zu //3 über, aber bevor der Konstruktor ausführt , wird die Instanz negiert
null
.
- Thread 1 wird von Thread 2 vorbelegt.
- Thread 2 prüft, ob die Instanz
null
. Da die Instanz nicht null ist, gibt Thread 2instance
einen Verweis auf ein vollständig erstelltes, aber teilweise initialisiertesSingleton
Objekt zurück.
- Thread 2 wird von Thread 1 vorbelegt.
- 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
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; } }
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
;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
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.
B0
B5
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.null
B9
BE
Singleton
eax
C3
eax
049388C8
instance
null
Singleton
C8
instance
ecx
CA
D0
true
5
Singleton
C3
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
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; }
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:
- Thread 1 betritt
getInstance()
die Methode.
- Thread 1 tritt wegen //1 in den ersten Block ein
instance
.null
synchronized
- Der Wert, den die lokale Variable
inst
erhältinstance
, der bei //2 liegtnull
.
- Aufgrund
inst
dessennull
tritt Thread 1 bei //3 in den zweitensynchronized
Block ein.
- Thread 1 beginnt dann mit der Ausführung des Codes bei //4, während er negiert
inst
,null
aberSingleton
bevor der Konstruktor von //4 ausgeführt wird. (Dies ist das Problem beim Schreiben außerhalb der Reihenfolge, das wir gerade gesehen haben.)
- Thread 1 wird von Thread 2 vorbelegt.
- Thread 2 betritt
getInstance()
die Methode.
instance
Aus diesem Grund versucht Thread 2, den ersten Block bei //1null
einzugeben .synchronized
Da Thread 1 derzeit die Sperre hält, ist Thread 2 blockiert.
- Thread 1 schließt die Ausführung dann bei //4 ab.
Singleton
Thread 1 weist dann der Variablen bei //5 ein vollständig konstruiertes Objekt zuinstance
und verlässt beidesynchronized
Blöcke.
- Thread 1 gibt zurück
instance
.
instance
Führen Sie dann Thread 2 aus und weisen Sie ihn bei //2 zuinst
.
- Thread 2 stellt fest,
instance
dass dies nicht der Fall istnull
, 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.
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; }
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
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! } //... }
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.100
stop
true
stop
true
num
0
volatile
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
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; } }
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
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 } }
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
-
- Sie können die englische Originalversion dieses Artikels auf der globalen Website von DeveloperWorks lesen .
- In Peter Haggars Buch „Practical Java Programming Language Guide“ (Addison-Wesley, 2000) behandelt er mehrere Java-Programmierthemen, darunter ein ganzes Kapitel über Multithreading-Probleme und Programmiertechniken.
- Die Java Language Specification, Second Edition von Bill Joy et al. (Addison-Wesley, 2000) ist die maßgebliche technische Referenz zur Programmiersprache Java.
- Die Java Virtual Machine Specification, Second Edition (Addison-Wesley, 1999) von Tim Lindholm und Frank Yellin ist das maßgebliche Dokument zum Java-Compiler und zur Laufzeitumgebung.
- Besuchen Sie die Java Memory Model-Website von Bill Pugh, um zahlreiche Informationen zu diesem Thema zu erhalten.
- Weitere Informationen zu
volatile
64-Bit-Variablen finden Sie in Peter Haggars Artikel „Does Java Guarantee Thread Safety?“ in der Juniausgabe 2002 von Dr. Dobb's Journal .
- JSR-133 befasst sich mit Überarbeitungen des Speichermodells und der Threading-Spezifikation der Java-Plattform.
- Brian Goetz, Berater für Java-Software , erklärt in „ Threading leicht gemacht: Synchronisierung ist nicht der Feind “ ( developerWorks , Juli 2001), wann Synchronisierung eingesetzt werden sollte.
- In „ Threading leicht gemacht: Manchmal ist Unsharing am besten “ ( developerWorks , Oktober 2001) stellt Brian Goetz es vor
ThreadLocal
und gibt einige Tipps zur Nutzung seiner Leistungsfähigkeit.
- In „ Threading leicht gemacht: Synchronisation ist nicht der Feind “ ( developerWorks , Februar 2001) stellt Alex Roetter die Java Thread API vor, gibt einen Überblick über Probleme im Zusammenhang mit Multithreading und bietet Lösungen für häufige Probleme.
- Allen Holub schlägt in „ If I Were King: Vorschläge zur Lösung von Threading-Problemen in der Java-Programmiersprache “ ( developerWorks , Oktober 2000) wesentliche Änderungen und Ergänzungen der Java-Sprache vor .
- Weiteres Java-Technologiematerial finden Sie in der Java-Technologiezone von DeveloperWorks.
- Sie können die englische Originalversion dieses Artikels auf der globalen Website von DeveloperWorks lesen .
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
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 } }
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:
- Thread 1 ruft
getInstance()
die Methode auf und entscheidet sichinstance
für //1null
.
- Thread 1 trat in
if
den Codeblock ein, wurde jedoch von Thread 2 beim Ausführen der Codezeile bei //2 vorbelegt.
- Thread 2 ruft die Methode auf und entscheidet
getInstance()
bei //1 .instance
null
- Thread 2 betritt
if
den Codeblock, erstellt ein neuesSingleton
Objekt und weist die Variable diesem neuen Objekt unter //2 zuinstance
.
- Thread 2 gibt die Objektreferenz bei //3 zurück
Singleton
.
- Thread 2 wird von Thread 1 vorbelegt.
- Thread 1 beginnt dort, wo er aufgehört hat, und führt die Codezeile //2 aus, wodurch ein weiteres
Singleton
Objekt erstellt wird.
- 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
public static Singleton getInstance() { if (instance == null) { synchronisiert(Singleton.class) { Instanz = new Singleton(); } } return-Instanz; }
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, instance
wä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
public static Singleton getInstance() { if (instance == null) { synchronisiert(Singleton.class) { //1 if (instance == null) //2 Instanz = new Singleton(); //3 } } Return-Instanz; }
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:
- Thread 1 betritt
getInstance()
die Methode.
- Thread 1 betritt den Block aufgrund
instance
von //1 .null
synchronized
- Thread 1 wird von Thread 2 vorbelegt.
- Thread 2 betritt
getInstance()
die Methode.
- Da
instance
es immer noch istnull
, versucht Thread 2, die Sperre bei //1 zu erlangen. Thread 2 blockiert jedoch bei //1, da Thread 1 die Sperre hält.
- Thread 2 wird von Thread 1 vorbelegt.
- Thread 1 wird ausgeführt, und da sich die Instanz noch bei //2 befindet
null
, erstellt Thread 1 auch einSingleton
Objekt und weist dessen Referenz zuinstance
.
- Thread 1 verlässt
synchronized
den Block undgetInstance()
gibt die Instanz der Methode zurück.
- Thread 1 wird von Thread 2 vorbelegt.
- Thread 2 erhält die Sperre bei //1 und prüft,
instance
ob sie vorhanden istnull
.
- Da
instance
nonull
, wird das zweiteSingleton
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 .instance
null
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:
- Thread 1 betritt
getInstance()
die Methode.
- Thread 1 betritt den Block aufgrund
instance
von //1 .null
synchronized
- Thread 1 geht zu //3 über, aber bevor der Konstruktor ausführt , wird die Instanz negiert
null
.
- Thread 1 wird von Thread 2 vorbelegt.
- Thread 2 prüft, ob die Instanz
null
. Da die Instanz nicht null ist, gibt Thread 2instance
einen Verweis auf ein vollständig erstelltes, aber teilweise initialisiertesSingleton
Objekt zurück.
- Thread 2 wird von Thread 1 vorbelegt.
- 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
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; } }
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
;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
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.
B0
B5
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.null
B9
BE
Singleton
eax
C3
eax
049388C8
instance
null
Singleton
C8
instance
ecx
CA
D0
true
5
Singleton
C3
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
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; }
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:
- Thread 1 betritt
getInstance()
die Methode.
- Thread 1 tritt wegen //1 in den ersten Block ein
instance
.null
synchronized
- Der Wert, den die lokale Variable
inst
erhältinstance
, der bei //2 liegtnull
.
- Aufgrund
inst
dessennull
tritt Thread 1 bei //3 in den zweitensynchronized
Block ein.
- Thread 1 beginnt dann mit der Ausführung des Codes bei //4, während er negiert
inst
,null
aberSingleton
bevor der Konstruktor von //4 ausgeführt wird. (Dies ist das Problem beim Schreiben außerhalb der Reihenfolge, das wir gerade gesehen haben.)
- Thread 1 wird von Thread 2 vorbelegt.
- Thread 2 betritt
getInstance()
die Methode.
instance
Aus diesem Grund versucht Thread 2, den ersten Block bei //1null
einzugeben .synchronized
Da Thread 1 derzeit die Sperre hält, ist Thread 2 blockiert.
- Thread 1 schließt die Ausführung dann bei //4 ab.
Singleton
Thread 1 weist dann der Variablen bei //5 ein vollständig konstruiertes Objekt zuinstance
und verlässt beidesynchronized
Blöcke.
- Thread 1 gibt zurück
instance
.
instance
Führen Sie dann Thread 2 aus und weisen Sie ihn bei //2 zuinst
.
- Thread 2 stellt fest,
instance
dass dies nicht der Fall istnull
, 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.
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; }
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
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! } //... }
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.100
stop
true
stop
true
num
0
volatile
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
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; } }
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
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 } }
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
-
- Sie können die englische Originalversion dieses Artikels auf der globalen Website von DeveloperWorks lesen .
- In Peter Haggars Buch „Practical Java Programming Language Guide“ (Addison-Wesley, 2000) behandelt er mehrere Java-Programmierthemen, darunter ein ganzes Kapitel über Multithreading-Probleme und Programmiertechniken.
- Die Java Language Specification, Second Edition von Bill Joy et al. (Addison-Wesley, 2000) ist die maßgebliche technische Referenz zur Programmiersprache Java.
- Die Java Virtual Machine Specification, Second Edition (Addison-Wesley, 1999) von Tim Lindholm und Frank Yellin ist das maßgebliche Dokument zum Java-Compiler und zur Laufzeitumgebung.
- Besuchen Sie die Java Memory Model-Website von Bill Pugh, um zahlreiche Informationen zu diesem Thema zu erhalten.
- Weitere Informationen zu
volatile
64-Bit-Variablen finden Sie in Peter Haggars Artikel „Does Java Guarantee Thread Safety?“ in der Juniausgabe 2002 von Dr. Dobb's Journal .
- JSR-133 befasst sich mit Überarbeitungen des Speichermodells und der Threading-Spezifikation der Java-Plattform.
- Brian Goetz, Berater für Java-Software , erklärt in „ Threading leicht gemacht: Synchronisierung ist nicht der Feind “ ( developerWorks , Juli 2001), wann Synchronisierung eingesetzt werden sollte.
- In „ Threading leicht gemacht: Manchmal ist Unsharing am besten “ ( developerWorks , Oktober 2001) stellt Brian Goetz es vor
ThreadLocal
und gibt einige Tipps zur Nutzung seiner Leistungsfähigkeit.
- In „ Threading leicht gemacht: Synchronisation ist nicht der Feind “ ( developerWorks , Februar 2001) stellt Alex Roetter die Java Thread API vor, gibt einen Überblick über Probleme im Zusammenhang mit Multithreading und bietet Lösungen für häufige Probleme.
- Allen Holub schlägt in „ If I Were King: Vorschläge zur Lösung von Threading-Problemen in der Java-Programmiersprache “ ( developerWorks , Oktober 2000) wesentliche Änderungen und Ergänzungen der Java-Sprache vor .
- Weiteres Java-Technologiematerial finden Sie in der Java-Technologiezone von DeveloperWorks.
- Sie können die englische Originalversion dieses Artikels auf der globalen Website von DeveloperWorks lesen .