Anleitung zum flüchtigen Schlüsselwort in Java

1. Übersicht

Wenn keine erforderlichen Synchronisierungen erforderlich sind, können der Compiler, die Laufzeit oder die Prozessoren alle Arten von Optimierungen anwenden. Obwohl diese Optimierungen die meiste Zeit von Vorteil sind, können sie manchmal subtile Probleme verursachen.

Caching und Neuordnung gehören zu den Optimierungen, die uns in gleichzeitigen Kontexten überraschen können. Java und die JVM bieten viele Möglichkeiten zur Steuerung der Speicherreihenfolge, und das flüchtige Schlüsselwort ist eine davon.

In diesem Artikel konzentrieren wir uns auf dieses grundlegende, aber oft missverstandene Konzept in der Java-Sprache - das flüchtige Schlüsselwort. Zuerst beginnen wir mit einigen Hintergrundinformationen zur Funktionsweise der zugrunde liegenden Computerarchitektur und machen uns dann mit der Speicherreihenfolge in Java vertraut.

2. Gemeinsame Multiprozessor-Architektur

Prozessoren sind für die Ausführung von Programmanweisungen verantwortlich. Daher müssen sie sowohl Programmanweisungen als auch erforderliche Daten aus dem RAM abrufen.

Da CPUs in der Lage sind, eine erhebliche Anzahl von Befehlen pro Sekunde auszuführen, ist das Abrufen aus dem RAM für sie nicht so ideal. Um diese Situation zu verbessern, verwenden Prozessoren Tricks wie Out-of-Order-Ausführung, Verzweigungsvorhersage, spekulative Ausführung und natürlich Caching.

Hier kommt folgende Speicherhierarchie ins Spiel:

Wenn verschiedene Kerne mehr Anweisungen ausführen und mehr Daten bearbeiten, füllen sie ihre Caches mit relevanteren Daten und Anweisungen. Dies wird die Gesamtleistung auf Kosten der Einführung von Cache-Kohärenz-Herausforderungen verbessern .

Einfach ausgedrückt sollten wir zweimal darüber nachdenken, was passiert, wenn ein Thread einen zwischengespeicherten Wert aktualisiert.

3. Wann ist flüchtig zu verwenden

Um die Cache-Kohärenz näher zu erläutern, leihen wir uns ein Beispiel aus dem Buch Java Concurrency in Practice aus:

public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }

Die TaskRunner- Klasse verwaltet zwei einfache Variablen. In seiner Hauptmethode erstellt es einen weiteren Thread, der sich mit der Ready- Variablen dreht, solange sie falsch ist. Wenn die Variable wird wahr, wird der Faden einfach die Drucknummer variabel.

Viele können erwarten, dass dieses Programm nach einer kurzen Verzögerung einfach 42 druckt. In der Realität kann die Verzögerung jedoch viel länger sein. Es kann sogar für immer hängen oder sogar Null drucken!

Die Ursache für diese Anomalien ist das Fehlen einer ordnungsgemäßen Sichtbarkeit und Neuordnung des Speichers . Lassen Sie uns sie genauer bewerten.

3.1. Speichersichtbarkeit

In diesem einfachen Beispiel haben wir zwei Anwendungsthreads: den Hauptthread und den Leserthread. Stellen wir uns ein Szenario vor, in dem das Betriebssystem diese Threads auf zwei verschiedenen CPU-Kernen plant, wobei:

  • Der Hauptthread hat eine Kopie der Ready- und Number- Variablen im Core-Cache
  • Der Reader-Thread endet auch mit seinen Kopien
  • Der Hauptthread aktualisiert die zwischengespeicherten Werte

Auf den meisten modernen Prozessoren werden Schreibanforderungen nicht sofort nach ihrer Ausgabe angewendet. Tatsächlich neigen Prozessoren dazu, diese Schreibvorgänge in einem speziellen Schreibpuffer in die Warteschlange zu stellen . Nach einer Weile wenden sie diese Schreibvorgänge auf einmal auf den Hauptspeicher an.

Mit allem , was wird gesagt, wenn der Haupt - Thread aktualisiert die Anzahl und bereit Variablen, gibt es keine Garantie , was der Leser Thread sehen kann. Mit anderen Worten, der Reader-Thread kann den aktualisierten Wert sofort oder mit einer gewissen Verzögerung oder überhaupt nicht sehen!

Diese Speichersichtbarkeit kann zu Lebendigkeitsproblemen in Programmen führen, die auf Sichtbarkeit angewiesen sind.

3.2. Neuordnung

Um die Sache noch schlimmer zu machen, kann der Leserthread diese Schreibvorgänge in einer anderen Reihenfolge als der tatsächlichen Programmreihenfolge sehen . Zum Beispiel, da wir zuerst die Aktualisierung Anzahl Variable:

public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }

Wir können erwarten, dass der Reader-Thread 42 druckt. Es ist jedoch tatsächlich möglich, Null als gedruckten Wert zu sehen!

Die Neuordnung ist eine Optimierungstechnik zur Leistungsverbesserung. Interessanterweise können verschiedene Komponenten diese Optimierung anwenden:

  • Der Prozessor kann seinen Schreibpuffer in einer anderen Reihenfolge als der Programmreihenfolge leeren
  • Der Prozessor kann eine Ausführungstechnik außerhalb der Reihenfolge anwenden
  • Der JIT-Compiler kann durch Neuordnung optimieren

3.3. flüchtige Speicherreihenfolge

Um sicherzustellen, dass Aktualisierungen von Variablen vorhersehbar auf andere Threads übertragen werden, sollten wir den flüchtigen Modifikator auf diese Variablen anwenden :

public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }

Auf diese Weise kommunizieren wir mit der Laufzeit und dem Prozessor, um keine Anweisungen für die flüchtige Variable neu zu ordnen . Prozessoren wissen auch, dass sie Aktualisierungen dieser Variablen sofort löschen sollten.

4. flüchtige und Thread-Synchronisation

Für Multithread-Anwendungen müssen wir einige Regeln für ein konsistentes Verhalten sicherstellen:

  • Gegenseitiger Ausschluss - Es wird jeweils nur ein Thread einen kritischen Abschnitt ausführt
  • Sichtbarkeit - Änderungen, die ein Thread an den freigegebenen Daten vorgenommen hat, sind für andere Threads sichtbar, um die Datenkonsistenz zu gewährleisten

Synchronisierte Methoden und Blöcke bieten beide oben genannten Eigenschaften auf Kosten der Anwendungsleistung.

volatile ist ein sehr nützliches Schlüsselwort, da es dazu beitragen kann, den Sichtbarkeitsaspekt der Datenänderung sicherzustellen, ohne natürlich einen gegenseitigen Ausschluss zu gewährleisten . Daher ist es nützlich, wenn mehrere Threads einen Codeblock parallel ausführen, aber die Sichtbarkeitseigenschaft sicherstellen müssen.

5. Passiert vor der Bestellung

The memory visibility effects of volatile variables extend beyond the volatile variables themselves.

To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).

5.1. Piggybacking

Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:

public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }

Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.

Durch Verwendung dieser Semantik können wir nur einige der Variablen in unserer Klasse als flüchtig definieren und die Sichtbarkeitsgarantie optimieren.

6. Fazit

In diesem Tutorial haben wir mehr über das flüchtige Schlüsselwort und seine Funktionen sowie die Verbesserungen erfahren, die ab Java 5 daran vorgenommen wurden.

Wie immer finden Sie die Codebeispiele auf GitHub.