Doppelte Verriegelung: Clever, aber kaputt

Von den hoch angesehenen Elementen des Java-Stils bis zu den Seiten von JavaWorld (siehe Java-Tipp 67) empfehlen viele gut gemeinte Java-Gurus die Verwendung der DCL-Sprache (Double Checked Locking). Es gibt nur ein Problem damit - diese klug wirkende Redewendung funktioniert möglicherweise nicht.

Eine doppelt überprüfte Verriegelung kann für Ihren Code gefährlich sein!

Diese Woche konzentriert sich JavaWorld auf die Gefahren der doppelt überprüften Sperrsprache . Lesen Sie mehr darüber, wie diese scheinbar harmlose Verknüpfung Ihren Code zerstören kann:
  • "Warnung! Einfädeln in einer Multiprozessor-Welt", sagte Allen Holub
  • Doppelte Verriegelung: Clever, aber kaputt ", sagte Brian Goetz
  • Weitere Informationen zum doppelt überprüften Sperren finden Sie in der Diskussion zu Programmiertheorie und -praxis von Allen Holub

Was ist DCL?

Das DCL-Idiom wurde entwickelt, um die verzögerte Initialisierung zu unterstützen, die auftritt, wenn eine Klasse die Initialisierung eines eigenen Objekts verzögert, bis es tatsächlich benötigt wird:

Klasse SomeClass {private Resource resource = null; öffentliche Ressource getResource () {if (resource == null) resource = new Resource (); Ressource zurückgeben; }}

Warum sollten Sie die Initialisierung verschieben? Möglicherweise ist das Erstellen eines ResourceVorgangs eine teure Operation, und Benutzer von SomeClassrufen möglicherweise getResource()keinen bestimmten Lauf an. In diesem Fall können Sie vermeiden, das Resourcevollständig zu erstellen . Unabhängig davon kann das SomeClassObjekt schneller erstellt werden, wenn es nicht auch zur ResourceKonstruktionszeit erstellt werden muss. Wenn Sie einige Initialisierungsvorgänge verzögern, bis ein Benutzer die Ergebnisse tatsächlich benötigt, können Programme schneller gestartet werden.

Was ist, wenn Sie versuchen, SomeClassin einer Multithread-Anwendung zu verwenden? Dann ergibt sich eine Race-Bedingung: Zwei Threads könnten gleichzeitig den Test ausführen, um festzustellen, ob er resourcenull ist, und als Ergebnis resourcezweimal initialisieren . In einer Multithread - Umgebung sollten Sie erklären getResource()sein synchronized.

Leider laufen synchronisierte Methoden viel langsamer - bis zu 100-mal langsamer - als gewöhnliche nicht synchronisierte Methoden. Eine der Beweggründe für eine verzögerte Initialisierung ist die Effizienz. Um jedoch einen schnelleren Programmstart zu erreichen, müssen Sie nach dem Start des Programms eine langsamere Ausführungszeit akzeptieren. Das klingt nicht nach einem guten Kompromiss.

DCL gibt vor, uns das Beste aus beiden Welten zu bieten. Bei Verwendung von DCL getResource()würde die Methode folgendermaßen aussehen:

Klasse SomeClass {private Resource resource = null; öffentliche Ressource getResource () {if (resource == null) {synchronisiert {if (resource == null) resource = new Resource (); }} Ressource zurückgeben; }}

Nach dem ersten Aufruf getResource(), resourcebereits initialisiert, die in den häufigsten Codepfad die Synchronisation Treffer vermeidet. DCL verhindert auch den Rennzustand, indem es resourceein zweites Mal innerhalb des synchronisierten Blocks prüft . Dadurch wird sichergestellt, dass nur ein Thread versucht, zu initialisieren resource. DCL scheint eine clevere Optimierung zu sein - aber es funktioniert nicht.

Lernen Sie das Java-Speichermodell kennen

Genauer gesagt, es ist nicht garantiert, dass DCL funktioniert. Um zu verstehen, warum, müssen wir uns die Beziehung zwischen der JVM und der Computerumgebung ansehen, auf der sie ausgeführt wird. Insbesondere müssen wir uns das Java-Speichermodell (JMM) ansehen, das in Kapitel 17 der Java-Sprachspezifikation von Bill Joy, Guy Steele, James Gosling und Gilad Bracha (Addison-Wesley, 2000) definiert wurde Java übernimmt die Interaktion zwischen Threads und Speicher.

Im Gegensatz zu den meisten anderen Sprachen definiert Java seine Beziehung zur zugrunde liegenden Hardware durch ein formales Speichermodell, das voraussichtlich auf allen Java-Plattformen gültig ist, und ermöglicht Javas Versprechen "Einmal schreiben, überall ausführen". Im Vergleich dazu fehlt anderen Sprachen wie C und C ++ ein formales Speichermodell. In solchen Sprachen erben Programme das Speichermodell der Hardwareplattform, auf der das Programm ausgeführt wird.

In einer synchronen Umgebung (Single-Threaded) ist die Interaktion eines Programms mit dem Speicher recht einfach oder zumindest so. Programme speichern Elemente in Speicherorten und erwarten, dass sie bei der nächsten Untersuchung dieser Speicherorte noch vorhanden sind.

Eigentlich ist die Wahrheit ganz anders, aber eine komplizierte Illusion, die vom Compiler, der JVM und der Hardware aufrechterhalten wird, verbirgt sie vor uns. Obwohl wir uns Programme als sequentielle Ausführung vorstellen - in der vom Programmcode angegebenen Reihenfolge -, geschieht dies nicht immer. Compiler, Prozessoren und Caches können sich mit unseren Programmen und Daten alle möglichen Freiheiten nehmen, sofern sie das Ergebnis der Berechnung nicht beeinflussen. Beispielsweise können Compiler Anweisungen in einer anderen Reihenfolge als der vom Programm vorgeschlagenen offensichtlichen Interpretation generieren und Variablen in Registern anstelle des Speichers speichern. Prozessoren können Anweisungen parallel oder außerhalb der Reihenfolge ausführen. und Caches können die Reihenfolge variieren, in der Schreibvorgänge in den Hauptspeicher übertragen werden. Das JMM sagt, dass all diese verschiedenen Neuordnungen und Optimierungen akzeptabel sind.solange die Umwelt erhalten bleibtas-if-serial- Semantik - das heißt, solange Sie das gleiche Ergebnis erzielen, das Sie hätten, wenn die Anweisungen in einer streng sequentiellen Umgebung ausgeführt worden wären.

Compiler, Prozessoren und Caches ordnen die Reihenfolge der Programmvorgänge neu, um eine höhere Leistung zu erzielen. In den letzten Jahren haben wir enorme Verbesserungen der Computerleistung festgestellt. Während erhöhte Prozessortaktraten wesentlich zu einer höheren Leistung beigetragen haben, hat auch eine erhöhte Parallelität (in Form von Pipeline- und superskalaren Ausführungseinheiten, dynamischer Befehlsplanung und spekulativer Ausführung sowie ausgefeilten mehrstufigen Speichercaches) einen wesentlichen Beitrag geleistet. Gleichzeitig ist die Aufgabe, Compiler zu schreiben, viel komplizierter geworden, da der Compiler den Programmierer vor diesen Komplexitäten schützen muss.

Wenn Sie Single-Thread-Programme schreiben, können Sie die Auswirkungen dieser verschiedenen Befehls- oder Speicheroperations-Neuordnungen nicht sehen. Bei Multithread-Programmen ist die Situation jedoch ganz anders: Ein Thread kann Speicherorte lesen, die ein anderer Thread geschrieben hat. Wenn Thread A einige Variablen in einer bestimmten Reihenfolge ändert, ohne dass eine Synchronisation vorliegt, sieht Thread B sie möglicherweise nicht in derselben Reihenfolge - oder überhaupt nicht. Dies kann dazu führen, dass der Compiler die Anweisungen neu angeordnet oder eine Variable vorübergehend in einem Register gespeichert und später in den Speicher geschrieben hat. oder weil der Prozessor die Anweisungen parallel oder in einer anderen Reihenfolge als der angegebene Compiler ausgeführt hat; oder weil sich die Anweisungen in verschiedenen Speicherbereichen befanden,und der Cache aktualisierte die entsprechenden Hauptspeicherorte in einer anderen Reihenfolge als der, in der sie geschrieben wurden. Unabhängig von den Umständen sind Multithread-Programme von Natur aus weniger vorhersehbar, es sei denn, Sie stellen mithilfe der Synchronisierung ausdrücklich sicher, dass Threads eine konsistente Ansicht des Speichers haben.

Was bedeutet synchronisiert wirklich?

Java behandelt jeden Thread so, als würde er auf einem eigenen Prozessor mit einem eigenen lokalen Speicher ausgeführt, wobei jeder mit einem gemeinsam genutzten Hauptspeicher spricht und mit diesem synchronisiert. Selbst auf einem Einzelprozessorsystem ist dieses Modell aufgrund der Auswirkungen von Speichercaches und der Verwendung von Prozessorregistern zum Speichern von Variablen sinnvoll. Wenn ein Thread einen Speicherort in seinem lokalen Speicher ändert, sollte diese Änderung schließlich auch im Hauptspeicher angezeigt werden, und der JMM definiert die Regeln, wann die JVM Daten zwischen lokalem und Hauptspeicher übertragen muss. Die Java-Architekten erkannten, dass ein zu restriktives Speichermodell die Programmleistung ernsthaft beeinträchtigen würde. Sie versuchten, ein Speichermodell zu entwickeln, das es Programmen ermöglicht, auf moderner Computerhardware eine gute Leistung zu erbringen, und gleichzeitig Garantien bietet, die es Threads ermöglichen, auf vorhersehbare Weise zu interagieren.

Javas Hauptwerkzeug zum vorhersehbaren Rendern von Interaktionen zwischen Threads ist das synchronizedSchlüsselwort. Viele Programmierer denken synchronizedstrikt daran, ein Semaphor für gegenseitigen Ausschluss ( Mutex ) durchzusetzen , um die Ausführung kritischer Abschnitte durch mehr als einen Thread gleichzeitig zu verhindern. Leider beschreibt diese Intuition nicht vollständig, was synchronizedbedeutet.

Die Semantik von synchronizeddo beinhaltet zwar den gegenseitigen Ausschluss der Ausführung basierend auf dem Status eines Semaphors, aber sie enthält auch Regeln für die Interaktion des synchronisierenden Threads mit dem Hauptspeicher. Insbesondere das Erfassen oder Aufheben einer Sperre löst eine Speicherbarriere aus - eine erzwungene Synchronisation zwischen dem lokalen Speicher des Threads und dem Hauptspeicher. (Einige Prozessoren - wie der Alpha - verfügen über explizite Maschinenanweisungen zum Durchführen von Speicherbarrieren.) Wenn ein Thread einen synchronizedBlock verlässt , führt er eine Schreibbarriere durch. Er muss alle in diesem Block geänderten Variablen in den Hauptspeicher spülen, bevor er freigegeben wird sperren. Ebenso bei der Eingabe von asynchronized Block führt es eine Lesebarriere durch - es ist, als ob der lokale Speicher ungültig gemacht worden wäre, und es muss alle Variablen, auf die im Block verwiesen wird, aus dem Hauptspeicher abrufen.

Die ordnungsgemäße Verwendung der Synchronisation garantiert, dass ein Thread die Auswirkungen eines anderen Threads auf vorhersehbare Weise erkennt. Nur wenn die Threads A und B auf demselben Objekt synchronisiert werden, garantiert das JMM, dass Thread B die von Thread A vorgenommenen Änderungen sieht und dass die von Thread A innerhalb des synchronizedBlocks vorgenommenen Änderungen für Thread B atomar erscheinen (entweder wird der gesamte Block ausgeführt oder keiner von Dies ist der Fall.) Darüber hinaus stellt das JMM sicher, dass synchronizedBlöcke, die mit demselben Objekt synchronisiert werden, in derselben Reihenfolge wie im Programm ausgeführt werden.

Was ist an DCL kaputt?

DCL relies on an unsynchronized use of the resource field. That appears to be harmless, but it is not. To see why, imagine that thread A is inside the synchronized block, executing the statement resource = new Resource(); while thread B is just entering getResource(). Consider the effect on memory of this initialization. Memory for the new Resource object will be allocated; the constructor for Resource will be called, initializing the member fields of the new object; and the field resource of SomeClass will be assigned a reference to the newly created object.

However, since thread B is not executing inside a synchronized block, it may see these memory operations in a different order than the one thread A executes. It could be the case that B sees these events in the following order (and the compiler is also free to reorder the instructions like this): allocate memory, assign reference to resource, call constructor. Suppose thread B comes along after the memory has been allocated and the resource field is set, but before the constructor is called. It sees that resource is not null, skips the synchronized block, and returns a reference to a partially constructed Resource! Needless to say, the result is neither expected nor desired.

When presented with this example, many people are skeptical at first. Many highly intelligent programmers have tried to fix DCL so that it does work, but none of these supposedly fixed versions work either. It should be noted that DCL might, in fact, work on some versions of some JVMs -- as few JVMs actually implement the JMM properly. However, you don't want the correctness of your programs to rely on implementation details -- especially errors -- specific to the particular version of the particular JVM you use.

Other concurrency hazards are embedded in DCL -- and in any unsynchronized reference to memory written by another thread, even harmless-looking reads. Suppose thread A has completed initializing the Resource and exits the synchronized block as thread B enters getResource(). Now the Resource is fully initialized, and thread A flushes its local memory out to main memory. The resource's fields may reference other objects stored in memory through its member fields, which will also be flushed out. While thread B may see a valid reference to the newly created Resource, because it didn't perform a read barrier, it could still see stale values of resource's member fields.

Volatile doesn't mean what you think, either

A commonly suggested nonfix is to declare the resource field of SomeClass as volatile. However, while the JMM prevents writes to volatile variables from being reordered with respect to one another and ensures that they are flushed to main memory immediately, it still permits reads and writes of volatile variables to be reordered with respect to nonvolatile reads and writes. That means -- unless all Resource fields are volatile as well -- thread B can still perceive the constructor's effect as happening after resource is set to reference the newly created Resource.

Alternatives to DCL

Der effektivste Weg, das DCL-Idiom zu korrigieren, besteht darin, es zu vermeiden. Der einfachste Weg, dies zu vermeiden, ist natürlich die Verwendung der Synchronisation. Immer wenn eine von einem Thread geschriebene Variable von einem anderen gelesen wird, sollten Sie die Synchronisierung verwenden, um sicherzustellen, dass Änderungen für andere Threads auf vorhersehbare Weise sichtbar sind.

Eine weitere Möglichkeit, um Probleme mit DCL zu vermeiden, besteht darin, die verzögerte Initialisierung zu beenden und stattdessen die eifrige Initialisierung zu verwenden . Anstatt die Initialisierung zu verzögern, resourcebis sie zum ersten Mal verwendet wird, initialisieren Sie sie bei der Erstellung. Der Klassenladeprogramm, das mit dem Klassenobjekt synchronisiert Classwird, führt zur Klasseninitialisierungszeit statische Initialisierungsblöcke aus. Das bedeutet, dass der Effekt statischer Initialisierer automatisch für alle Threads sichtbar ist, sobald die Klasse geladen wird.