Suche…


Bemerkungen

Das Java-Speichermodell ist der Abschnitt des JLS, der die Bedingungen angibt, unter denen ein Thread garantiert die Auswirkungen von Speicherschreibvorgängen eines anderen Threads erkennt. Der relevante Abschnitt in den letzten Ausgaben ist "JLS 17.4 Memory Model" (in Java 8 , Java 7 , Java 6 ).

In Java 5 wurde das Java-Speichermodell grundlegend überarbeitet, was (unter anderem) die Art und Weise geändert hat, wie volatile funktioniert. Seitdem ist das Speichermodell im Wesentlichen unverändert.

Motivation für das Speichermodell

Betrachten Sie das folgende Beispiel:

public class Example {
    public int a, b, c, d;
    
    public void doIt() {
       a = b + 1;
       c = d + 1;
    }
}

Wenn diese Klasse eine Single-Thread-Anwendung ist, ist das beobachtbare Verhalten genau so, wie Sie es erwarten würden. Zum Beispiel:

public class SingleThreaded {
    public static void main(String[] args) {
        Example eg = new Example();
        System.out.println(eg.a + ", " + eg.c);
        eg.doIt();
        System.out.println(eg.a + ", " + eg.c);
    }
}

wird ausgeben:

0, 0
1, 1

Soweit der "main" doIt() kann , werden die Anweisungen in der main() Methode und der doIt() Methode in der Reihenfolge ausgeführt, in der sie im Quellcode geschrieben werden. Dies ist eine klare Anforderung der Java Language Specification (JLS).

Betrachten Sie nun dieselbe Klasse, die in einer Multithread-Anwendung verwendet wird.

public class MultiThreaded {
    public static void main(String[] args) {
        final Example eg = new Example();
        new Thread(new Runnable() {
            public void run() {
                while (true) {
                    eg.doIt();
                }
            }
        }).start();
        while (true) {
            System.out.println(eg.a + ", " + eg.c);
        }
    }
}

Was wird das drucken?

Tatsächlich ist es laut JLS nicht möglich vorherzusagen, dass dies gedruckt wird:

  • Sie werden wahrscheinlich ein paar Zeilen von 0, 0 , um damit zu beginnen.
  • Dann sehen Sie wahrscheinlich Zeilen wie N, N oder N, N + 1 .
  • Möglicherweise sehen Sie Zeilen wie N + 1, N
  • Theoretisch sehen Sie vielleicht sogar, dass die 0, 0 Zeilen für immer bestehen 1 .

1 - In der Praxis kann das Vorhandensein der println Anweisungen zu einer println Synchronisation und zum Leeren des Speichercaches führen. Das wird wahrscheinlich einige der Effekte verdecken, die das oben genannte Verhalten verursachen würden.

Wie können wir das erklären?

Neuordnung der Aufträge

Eine mögliche Erklärung für unerwartete Ergebnisse ist, dass der JIT-Compiler die Reihenfolge der Zuweisungen in der Methode doIt() geändert hat. Das JLS erfordert, dass Anweisungen aus der Sicht des aktuellen Threads der Reihe nach ausgeführt werden. In diesem Fall kann nichts im Code der Methode doIt() die Auswirkung einer (hypothetischen) Neuordnung dieser beiden Anweisungen feststellen. Dies bedeutet, dass der JIT-Compiler dazu berechtigt ist.

Warum sollte es das tun?

Bei einer typischen modernen Hardware werden Maschinenbefehle unter Verwendung einer Befehlspipeline ausgeführt, die es ermöglicht, dass eine Folge von Anweisungen in verschiedenen Stufen abläuft. Einige Phasen der Befehlsausführung dauern länger als andere, und Speicheroperationen dauern in der Regel länger. Ein intelligenter Compiler kann den Befehlsdurchsatz der Pipeline optimieren, indem er die Anweisungen so anordnet, dass die Überlappung maximiert wird. Dies kann dazu führen, dass Teile von Anweisungen außerhalb der Reihenfolge ausgeführt werden. Das JLS erlaubt dies vorausgesetzt, dass das Ergebnis der Berechnung aus Sicht des aktuellen Threads nicht beeinflusst wird .

Auswirkungen von Speicher-Caches

Eine zweite mögliche Erklärung ist der Effekt des Memory-Caching. In einer klassischen Computerarchitektur verfügt jeder Prozessor über einen kleinen Satz von Registern und eine größere Menge an Speicher. Der Zugriff auf Register ist viel schneller als der Zugriff auf den Hauptspeicher. In modernen Architekturen gibt es Speichercaches, die langsamer als Register sind, aber schneller als Hauptspeicher.

Ein Compiler wird dies ausnutzen, indem er versucht, Kopien von Variablen in Registern oder in den Speichercaches zu halten. Wenn eine Variable muss nicht in dem Hauptspeicher geleert werden, oder muss nicht aus dem Speicher gelesen wird, gibt es erhebliche Leistungsvorteile in nicht tun. Wenn für das JLS keine Speicheroperationen für einen anderen Thread sichtbar sein müssen, fügt der Java-JIT-Compiler wahrscheinlich nicht die Anweisungen "Lesesperre" und "Schreibsperre" hinzu, die das Lesen und Schreiben von Hauptspeicher erzwingen. Wieder einmal sind die Leistungsvorteile dabei erheblich.

Richtige Synchronisation

Bisher haben wir gesehen, dass JLS es dem JIT-Compiler ermöglicht, Code zu generieren, der Single-Thread-Code schneller macht, indem Speicheroperationen neu angeordnet oder vermieden werden. Was passiert aber, wenn andere Threads den Status der (gemeinsam genutzten) Variablen im Hauptspeicher beobachten können?

Die Antwort ist, dass die anderen Threads Variablenzustände beobachten können, die als unmöglich erscheinen ... basierend auf der Codereihenfolge der Java-Anweisungen. Die Lösung hierfür ist die Verwendung einer geeigneten Synchronisation. Die drei Hauptansätze sind:

  • Verwenden primitiver Mutexe und der synchronized Konstrukte.
  • Verwendung volatile Variablen
  • Unterstützung für Parallelität auf höherer Ebene verwenden; zB Klassen in den java.util.concurrent Paketen.

Trotzdem ist es wichtig zu wissen, wo Synchronisierung erforderlich ist und auf welche Effekte Sie sich verlassen können. Hier kommt das Java Memory Model ins Spiel.

Das Speichermodell

Das Java-Speichermodell ist der Abschnitt des JLS, der die Bedingungen angibt, unter denen ein Thread garantiert die Auswirkungen von Speicherschreibvorgängen eines anderen Threads erkennt. Das Speichermodell ist mit einer gewissen formalen Strenge spezifiziert und erfordert (als Ergebnis) detailliertes und sorgfältiges Lesen, um es zu verstehen. Das Grundprinzip besteht jedoch darin, dass bestimmte Konstrukte eine "Vor-Vorher-Beziehung" zwischen dem Schreiben einer Variablen durch einen Thread und einem nachfolgenden Lesen derselben Variablen durch einen anderen Thread erzeugen. Wenn die „geschieht vor“ Beziehung existiert, wird die JIT - Compiler verpflichtet Code zu generieren , die sicherstellen, dass der Lesevorgang den Wert durch den Schreib geschrieben sieht.

Damit ist es möglich, über die Speicherkohärenz in einem Java-Programm zu entscheiden und zu entscheiden, ob dies für alle Ausführungsplattformen vorhersehbar und konsistent ist.

Geschieht vor Beziehungen

(Nachfolgend finden Sie eine vereinfachte Version der Java-Sprachspezifikation. Für ein tieferes Verständnis müssen Sie die Spezifikation selbst lesen.)

Happens-before-Beziehungen sind der Teil des Speichermodells, mit dem wir die Sichtbarkeit des Speichers verstehen und begründen können. Wie die JLS sagt ( JLS 17.4.5 ):

"Zwei Aktionen können durch eine zufällige Beziehung geordnet werden. Wenn eine Aktion vor einer anderen passiert , ist die erste für die zweite sichtbar und vor ihr angeordnet."

Was bedeutet das?

Aktionen

Die Aktionen, auf die sich das obige Zitat bezieht, sind in JLS 17.4.2 angegeben . Es gibt 5 Arten von Aktionen, die durch die Spezifikation definiert werden:

  • Lesen: Lesen einer nichtflüchtigen Variablen.

  • Schreiben: Schreiben einer nichtflüchtigen Variablen.

  • Synchronisationsaktionen:

    • Flüchtiges Lesen: Lesen einer flüchtigen Variablen.

    • Flüchtiges Schreiben: Schreiben einer flüchtigen Variablen.

    • Sperren. Einen Monitor sperren

    • Freischalten. Entsperren eines Monitors

    • Die (synthetischen) ersten und letzten Aktionen eines Threads.

    • Aktionen, die einen Thread starten oder feststellen, dass ein Thread beendet wurde.

  • Externe Aktionen. Eine Aktion, deren Ergebnis von der Umgebung abhängt, in der sich das Programm befindet.

  • Thread-Divergenzaktionen. Diese modellieren das Verhalten bestimmter Arten von Endlosschleifen.

Programmreihenfolge und Synchronisationsreihenfolge

Diese beiden Ordnungen ( JLS 17.4.3 und JLS 17.4.4 ) bestimmen die Ausführung von Anweisungen in Java

Die Programmreihenfolge beschreibt die Reihenfolge der Anweisungsausführung innerhalb eines einzelnen Threads.

Die Synchronisationsreihenfolge beschreibt die Reihenfolge der Anweisungsausführung für zwei durch eine Synchronisation verbundene Anweisungen:

  • Eine Entsperraktion auf dem Monitor wird synchronisiert - mit allen nachfolgenden Sperraktionen auf diesem Monitor.

  • Ein Schreibvorgang in eine flüchtige Variable wird mit allen nachfolgenden Lesevorgängen derselben Variablen durch einen beliebigen Thread synchronisiert .

  • Eine Aktion, die einen Thread startet (dh der Aufruf von Thread.start() ), synchronisiert sich mit der ersten Aktion in dem Thread, den er startet (dh dem Aufruf der run() Methode des Threads).

  • Die Standardinitialisierung der Felder wird mit der ersten Aktion in jedem Thread synchronisiert . (Eine Erklärung hierzu finden Sie in der JLS.)

  • Die abschließende Aktion in einem Thread wird mit jeder Aktion in einem anderen Thread, die die Beendigung erkennt, synchronisiert . isTerminated() die Rückgabe eines join() Aufrufs oder ein isTerminated() Aufruf, der true zurückgibt.

  • Wenn ein Thread einen anderen Thread unterbricht, wird der Interruptaufruf im ersten Thread mit dem Punkt synchronisiert, an dem ein anderer Thread feststellt, dass der Thread unterbrochen wurde.

Passiert vor der Bestellung

Diese Reihenfolge ( JLS 17.4.5 ) bestimmt, ob ein Speicherschreiben garantiert für ein nachfolgendes Lesen des Speichers sichtbar ist.

Genauer gesagt, ein Lesen einer Variablen v garantiert, dass ein Schreiben in v genau dann beobachtet wird, wenn das write(v) vor dem read(v) geschieht UND es kein intervenierendes Schreiben in v . Wenn es dazwischenliegende Schreibvorgänge gibt, werden beim read(v) die Ergebnisse eher als bei den früheren angezeigt.

Die Regeln, die die zufällige Reihenfolge definieren, lauten wie folgt:

  • Happens-Before-Regel # 1 - Wenn x und y Aktionen des gleichen Threads sind und x vor y in der Programmreihenfolge steht , passiert x vor y.

  • Happens-Before-Regel Nr. 2 - Es gibt eine Vor- Ereignis-Kante vom Ende eines Konstruktors eines Objekts bis zum Beginn eines Finalizers für dieses Objekt.

  • Happens-Before-Regel Nr. 3 - Wenn eine Aktion x mit einer nachfolgenden Aktion y synchronisiert wird , passiert x vor y.

  • Passiert vor Regel 4 - Wenn x passiert - bevor y und y passiert - bevor z dann x passiert - bevor z.

Darüber hinaus werden verschiedene Klassen in den Java-Standardbibliotheken als definierende Vorgängerbeziehungen definiert. Sie können dies so interpretieren, dass es irgendwie passiert, ohne dass Sie genau wissen müssen, wie die Garantie erfüllt wird.

Geschieht, bevor die Argumentation auf einige Beispiele angewendet wird

Wir werden einige Beispiele vorstellen, um zu demonstrieren, wie die Anwendung angewendet wird, bevor geprüft wird, ob Schreibvorgänge für nachfolgende Lesevorgänge sichtbar sind.

Single-Threaded-Code

Wie zu erwarten, sind Schreibvorgänge immer für nachfolgende Lesevorgänge in einem Singlethread-Programm sichtbar.

public class SingleThreadExample {
    public int a, b;
    
    public int add() {
       a = 1;         // write(a)
       b = 2;         // write(b)
       return a + b;  // read(a) followed by read(b)
    }
}

Durch Happens-Before-Regel Nr. 1:

  1. Die write(a) geschieht vor der write(b) .
  2. Die write(b) geschieht vor der read(a) .
  3. Die Aktion read(a) geschieht vor der Aktion read(a) .

Durch Happens-Before-Regel Nr. 4:

  1. write(a) passiert - bevor write(b) UND write(b) passiert - bevor read(a) IMPLIES write(a) passiert - bevor read(a) .
  2. write(b) geschieht vor dem read(a) UND read(a) geschieht vor dem read(b) IMPLIES write(b) geschieht vor dem read(b) .

Zusammenfassen:

  1. Die write(a) -heat-before- read(a) Beziehung read(a) bedeutet, dass die Anweisung a + b garantiert den korrekten Wert von a erkennt.
  2. Die write(b) -Ohre- read(b) Beziehung read(b) bedeutet, dass die Anweisung a + b garantiert den korrekten Wert von b .

Verhalten von 'flüchtig' in einem Beispiel mit 2 Threads

Wir werden den folgenden Beispielcode verwenden, um einige Implikationen des Speichermodells für `volatil zu untersuchen.

public class VolatileExample {
    private volatile int a;
    private int b;         // NOT volatile
    
    public void update(int first, int second) {
       b = first;         // write(b)
       a = second;         // write-volatile(a)
    }

    public int observe() {
       return a + b;       // read-volatile(a) followed by read(b)
    }
}

Betrachten Sie zunächst die folgende Abfolge von Anweisungen, die 2 Threads betreffen:

  1. Eine einzelne Instanz von VolatileExample wird erstellt. nennen sie es ve ,
  2. ve.update(1, 2) wird in einem Thread aufgerufen und
  3. ve.observe() wird in einem anderen Thread aufgerufen.

Durch Happens-Before-Regel Nr. 1:

  1. Die write(a) geschieht vor der volatile-write(a) .
  2. Die volatile-read(a) Aktion volatile-read(a) geschieht vor der read(b) Aktion read(b) .

Durch Happens-Before-Regel Nr. 2:

  1. Die volatile-write(a) im ersten Thread geschieht vor der volatile-read(a) im zweiten Thread.

Durch Happens-Before-Regel Nr. 4:

  1. Die write(b) -Aktion im ersten Thread geschieht vor der read(b) -Aktion im zweiten Thread.

Mit anderen Worten, für diese bestimmte Sequenz wird garantiert, dass der 2. Thread die Aktualisierung der nichtflüchtigen Variablen b sieht, die vom ersten Thread vorgenommen wird. Es sollte jedoch auch klar sein, dass, wenn die Zuweisungen in der update umgekehrt sind oder die observe() -Methode die Variable b vor a liest, die Ereigniskette vorher gebrochen würde. Die Kette wäre auch gebrochen, wenn volatile-read(a) im zweiten Thread nicht auf volatile-write(a) im ersten Thread folgt.

Wenn die Kette unterbrochen wird, kann nicht garantiert werden, dass observe() den korrekten Wert von b sieht.

Flüchtig mit drei Threads

Nehmen wir an, wir fügen dem vorherigen Beispiel einen dritten Thread hinzu:

  1. Eine einzelne Instanz von VolatileExample wird erstellt. nennen sie es ve ,
  2. update zwei Threads aufrufen:
    • ve.update(1, 2) wird in einem Thread aufgerufen.
    • ve.update(3, 4) wird im zweiten Thread aufgerufen.
  3. ve.observe() wird anschließend in einem dritten Thread aufgerufen.

Um dies vollständig zu analysieren, müssen wir alle möglichen Verschachtelungen der Anweisungen in Thread eins und Thread zwei berücksichtigen. Stattdessen betrachten wir nur zwei davon.

Szenario 1 - annehmen , dass update(1, 2) voraus update(3,4) erhalten wir diese Reihenfolge:

write(b, 1), write-volatile(a, 2)     // first thread
write(b, 3), write-volatile(a, 4)     // second thread
read-volatile(a), read(b)             // third thread

In diesem Fall ist es leicht zu erkennen, dass es eine ununterbrochene Vor-Ereignis- Kette vom write(b, 3) zum read(b) . Darüber hinaus gibt es kein Schreiben an b . In diesem Szenario wird garantiert, dass b für den dritten Thread den Wert 3 .

Szenario 2 - Nehmen Sie an, dass sich update(1, 2) und update(3,4) überlappen und die ations wie folgt verschachtelt sind:

write(b, 3)                           // second thread
write(b, 1)                           // first thread
write-volatile(a, 2)                  // first thread
write-volatile(a, 4)                  // second thread
read-volatile(a), read(b)             // third thread

Während es nun eine Vor-Ereignis- Kette von write(b, 3) zu read(b) gibt, gibt es eine intervenierende write(b, 1) die vom anderen Thread ausgeführt wird. Dies bedeutet, dass wir nicht sicher sein können, welchen Wert read(b) sehen wird.

(Nebenbei: Dies zeigt, dass wir uns nicht auf volatile um die Sichtbarkeit nichtflüchtiger Variablen sicherzustellen, außer in sehr begrenzten Situationen.)

So vermeiden Sie, das Speichermodell verstehen zu müssen

Das Speichermodell ist schwer zu verstehen und nur schwer anzuwenden. Dies ist nützlich, wenn Sie über die Richtigkeit von Multithreadcode nachdenken müssen, diese Argumentation jedoch nicht für jede Multithread-Anwendung, die Sie schreiben, erforderlich ist.

Wenn Sie beim Schreiben von simultanem Code in Java die folgenden Prinzipien anwenden, können Sie die Notwendigkeit, vor einer Argumentation vorzugehen, weitgehend vermeiden.

  • Verwenden Sie möglichst unveränderliche Datenstrukturen. Eine ordnungsgemäß implementierte unveränderliche Klasse ist Thread-sicher und führt nicht zu Thread-Sicherheitsproblemen, wenn Sie sie mit anderen Klassen verwenden.

  • Verstehen und vermeiden Sie "unsichere Veröffentlichung".

  • Verwenden Sie primitive Mutexe oder Lock , um den Zugriff auf den Status in veränderlichen Objekten zu synchronisieren, die threadsicher sein müssen 1 .

  • Verwenden Sie Executor / ExecutorService oder das Fork-Join-Framework, anstatt zu versuchen, Management-Threads direkt zu erstellen.

  • Verwenden Sie die `java.util.concurrent-Klassen, die erweiterte Sperren, Semaphore, Latches und Barrieren bereitstellen, anstatt direkt mit wait / notify / notifyAll zu arbeiten.

  • Verwenden Sie die java.util.concurrent Versionen von Karten, Sets, Listen, Warteschlangen und Deques anstelle der externen Synchronisierung von nicht gleichzeitig ablaufenden Sammlungen.

Das allgemeine Prinzip besteht darin, zu versuchen, die eingebauten Parallelitätsbibliotheken von Java zu verwenden, anstatt die Parallelität "zu rollen". Sie können sich darauf verlassen, dass sie funktionieren, wenn Sie sie richtig verwenden.


1 - Nicht alle Objekte müssen threadsicher sein. Wenn zum Beispiel ein Objekt oder Objekte fadenbeschränkt sind (dh es ist nur für einen Thread zugänglich), ist die Thread-Sicherheit nicht relevant.



Modified text is an extract of the original Stack Overflow Documentation
Lizenziert unter CC BY-SA 3.0
Nicht angeschlossen an Stack Overflow