Einführung in das Java-Speichermodell

Das Java Memory Model (JMM) beschreibt das Verhalten von Threads in der Java-Laufzeitumgebung. Das Speichermodell ist Teil der Semantik der Java-Sprache und beschreibt, was ein Programmierer erwarten kann und was nicht, wenn er Software nicht für eine bestimmte Java-Maschine, sondern für Java als Ganzes entwickelt.

Das 1995 entwickelte ursprüngliche Java-Speichermodell (das sich insbesondere auf „perkolokalen Speicher“ bezieht) gilt als gescheitert: Viele Optimierungen können nicht vorgenommen werden, ohne die Garantie der Codesicherheit zu verlieren. Insbesondere gibt es mehrere Möglichkeiten, Multithread-„Single“ zu schreiben:

  • Entweder führt jeder Zugriff auf ein Singleton (auch wenn das Objekt vor langer Zeit erstellt wurde und sich nichts ändern kann) zu einer Inter-Thread-Sperre;
  • oder unter bestimmten Umständen gibt das System einen unvollendeten Einzelgänger aus;
  • oder unter bestimmten Umständen erzeugt das System zwei Einzelgänger;
  • oder das Design hängt vom Verhalten einer bestimmten Maschine ab.

Daher wurde der Speichermechanismus neu gestaltet. Im Jahr 2005 wurde mit der Veröffentlichung von Java 5 ein neuer Ansatz vorgestellt, der mit der Veröffentlichung von Java 14 weiter verbessert wurde.

Das neue Modell basiert auf drei Regeln:

Regel Nr. 1 : Single-Threaded-Programme werden pseudosequenziell ausgeführt. Das bedeutet: In der Realität kann der Prozessor mehrere Vorgänge pro Takt ausführen und gleichzeitig deren Reihenfolge ändern. Allerdings bleiben alle Datenabhängigkeiten bestehen, sodass sich das Verhalten nicht vom sequentiellen unterscheidet.

Regel Nummer 2 : Es gibt keine Werte, die aus dem Nichts kommen. Das Lesen einer beliebigen Variablen (mit Ausnahme der nichtflüchtigen Variablen long und double, für die diese Regel möglicherweise nicht gilt) gibt entweder den Standardwert (Null) oder etwas zurück, das von einem anderen Befehl dort geschrieben wurde.

Und Regel Nummer 3 : Die restlichen Ereignisse werden der Reihe nach ausgeführt, wenn sie durch eine strikte Teilreihenfolgebeziehung „wird vor“ ( passiert vor ) verbunden sind.

Passiert schon einmal

Leslie Lamport hatte zuvor das Konzept von Happens . Dies ist eine strikte Teilordnungsbeziehung, die zwischen atomaren Befehlen eingeführt wird (++ und -- sind nicht atomar) und bedeutet nicht „physikalisch vorher“.

Es heißt, dass das zweite Team über die Änderungen des ersten Teams „im Bilde“ sein wird.

Passiert schon einmal

Beispielsweise wird bei solchen Operationen eine vor der anderen ausgeführt:

Synchronisation und Monitore:

  • Erfassen des Monitors ( Sperrmethode , synchronisierter Start) und alles, was danach im selben Thread passiert.
  • Die Rückkehr des Monitors (Methode unlock , Ende synchronisiert) und was auch immer im selben Thread davor passiert.
  • Rückgabe des Monitors und anschließende Erfassung durch einen anderen Thread.

Schreiben und Lesen:

  • In eine beliebige Variable schreiben und sie dann im selben Stream lesen.
  • Alles im selben Thread vor dem Schreiben in die flüchtige Variable und dem Schreiben selbst. volatile read und alles im selben Thread danach.
  • In eine flüchtige Variable schreiben und sie dann erneut lesen. Ein flüchtiger Schreibvorgang interagiert mit dem Speicher auf die gleiche Weise wie eine Monitorrückgabe, während ein Lesevorgang einer Erfassung ähnelt. Es stellt sich heraus, dass, wenn ein Thread in eine flüchtige Variable schreibt und der zweite sie findet, alles, was dem Schreiben vorausgeht, vor allem ausgeführt wird, was nach dem Lesen kommt; siehe Bild.

Objektpflege:

  • Statische Initialisierung und alle Aktionen mit beliebigen Instanzen von Objekten.
  • Schreiben in letzte Felder im Konstruktor und alles nach dem Konstruktor. Ausnahmsweise stellt die Passes-Before-Beziehung keine transitive Verbindung zu anderen Regeln her und kann daher zu einem Wettlauf zwischen Threads führen.
  • Jegliche Arbeit mit dem Objekt und finalize() .

Stream-Dienst:

  • Einen Thread und beliebigen Code im Thread starten.
  • Nullsetzen von Variablen, die sich auf den Thread und jeglichen Code im Thread beziehen.
  • Code in Thread und join() ; Code im Thread und isAlive() == false .
  • interrupt() den Thread und stellt fest, dass er gestoppt wurde.

Passiert vor Arbeitsnuancen

Die Freigabe eines Monitors erfolgt vor der Erfassung desselben Monitors. Es ist erwähnenswert, dass es sich um die Veröffentlichung und nicht um den Ausgang handelt, d. h. Sie müssen sich bei der Verwendung von „wait“ keine Sorgen um die Sicherheit machen.

Mal sehen, wie dieses Wissen uns hilft, unser Beispiel zu korrigieren. In diesem Fall ist alles ganz einfach: Entfernen Sie einfach die externe Prüfung und lassen Sie die Synchronisierung unverändert. Jetzt sieht der zweite Thread garantiert alle Änderungen, da er den Monitor erst erhält, nachdem der andere Thread ihn freigegeben hat. Und da er es erst freigeben wird, wenn alles initialisiert ist, sehen wir alle Änderungen auf einmal und nicht einzeln:

public class Keeper {
    private Data data = null;

    public Data getData() {
        synchronized(this) {
            if(data == null) {
                data = new Data();
            }
        }

        return data;
    }
}

Das Schreiben in eine flüchtige Variable erfolgt vor dem Lesen derselben Variablen. Die von uns vorgenommene Änderung behebt natürlich den Fehler, aber sie bringt denjenigen, der den ursprünglichen Code geschrieben hat, dorthin zurück, wo er herkam – und blockiert jedes Mal. Das Schlüsselwort volatile kann sparen. Tatsächlich bedeutet die fragliche Aussage, dass wir beim Lesen von allem, was als flüchtig deklariert ist, immer den tatsächlichen Wert erhalten.

Darüber hinaus ist das Schreiben für flüchtige Felder, wie ich bereits sagte, immer (einschließlich Long und Double) eine atomare Operation. Ein weiterer wichtiger Punkt: Wenn Sie eine flüchtige Entität haben, die Verweise auf andere Entitäten hat (z. B. ein Array, eine Liste oder eine andere Klasse), dann ist nur ein Verweis auf die Entität selbst immer „frisch“, aber nicht auf alles darin es kommt.

Also zurück zu unseren Doppelverriegelungszylindern. Mit volatile können Sie die Situation wie folgt beheben:

public class Keeper {
    private volatile Data data = null;

    public Data getData() {
        if(data == null) {
            synchronized(this) {
                if(data == null) {
                    data = new Data();
                }
            }
        }
        return data;
    }
}

Hier haben wir noch eine Sperre, aber nur wenn data == null. Die restlichen Fälle filtern wir mit volatile read heraus. Die Korrektheit wird durch die Tatsache gewährleistet, dass die flüchtige Speicherung vor dem flüchtigen Lesen erfolgt und alle im Konstruktor ausgeführten Vorgänge für jeden sichtbar sind, der den Wert des Felds liest.