Java volatile Schlüsselwort
Das volatile-Schlüsselwort in Java garantiert Sichtbarkeit und Reihenfolge von Feldzugriffen über Threads hinweg – ohne einen Lock zu verwenden.
synchronized löst zwei Probleme gleichzeitig: gegenseitigen Ausschluss und Speichersichtbarkeit. Manchmal braucht man nur das Zweite. Ein einzelnes boolesches Flag, das ein Thread setzt und ein anderer abfragt, benötigt keinen exklusiven Zugriff — es gibt nur einen Schreiber und einen oder mehrere Leser, und die Arbeit selbst ist nicht zusammensetzbar. Es zu sperren ist übertrieben; es nicht zu sperren ist fehlerhaft. volatile ist das Schlüsselwort, das Sichtbarkeit ohne Ausschluss bietet.
Es ist ein schmales Werkzeug. Den Anwendungsfall richtig zu treffen macht es zum schnellsten threadsicheren Primitiv, das Java besitzt. Es falsch zu verwenden — etwa für zusammengesetzte Aktualisierungen wie ++ — führt zu denselben Fehlern, die synchronized beheben sollte.
Das Problem, das volatile löst
Ohne jegliche Synchronisation:
class Worker implements Runnable {
boolean stop = false; // plain field
public void run() {
while (!stop) { // hot loop
doWork();
}
}
}
Worker w = new Worker();
new Thread(w).start();
Thread.sleep(1000);
w.stop = true; // ask it to stopDieses Programm stoppt möglicherweise nie. Der Grund: Nichts in der JVM muss stop vom CPU-Cache eines Threads in den Hauptspeicher schreiben, und nichts muss die gecachte Kopie des Workers ungültig machen, wenn der Haupt-Thread schreibt. Der JIT darf !stop auch vollständig aus der Schleife herausziehen — der Compiler sieht, dass der Schleifenkörper stop nicht verändert, und speichert den Wert deshalb dauerhaft in einem Register.
Das ist der Sichtbarkeitsfehler. Die Lösung:
volatile boolean stop = false;Jetzt wird jeder Schreibvorgang auf stop in den Hauptspeicher gespült und jeder Lesevorgang liest direkt aus dem Hauptspeicher. Der JIT kann den Lesezugriff nicht herausziehen; die Schleife sieht den neuen Wert innerhalb von Mikrosekunden nach der Aktualisierung durch den Schreiber.
Was volatile garantiert
Vier Dinge:
- Sichtbarkeit. Ein Schreibvorgang auf ein
volatile-Feld ist garantiert für nachfolgende Lesevorgänge in jedem anderen Thread sichtbar. - Atomarität von Lesen und Schreiben — aber nur für das Feld selbst. Ein
volatile long- undvolatile double-Lese-/Schreibvorgang ist atomar; ohnevolatilekann ein 64-Bit-Lese-/Schreibvorgang auf einer 32-Bit-JVM zerrissen werden. - Reihenfolge (happens-before). Alles, was vor einem
volatile-Schreibvorgang im schreibenden Thread passiert ist, ist für alles sichtbar, was nach dem entsprechendenvolatile-Lesevorgang im lesenden Thread passiert. Das ist der Teil, den man in der Syntax nicht sieht, aber er ist die mächtigste Garantie. - Keine Neuanordnung über den Zugriff hinweg. Compiler und CPU können gewöhnliche Lese-/Schreibvorgänge nicht an einem
volatile-Zugriff in beide Richtungen vorbei verschieben.
Der dritte Punkt — happens-before über ein volatile-Schreib-/Lesepaar — wird manchmal als das günstigste Publikations-Primitiv bezeichnet. Schreibt man ein Feld und danach volatile boolean ready = true, so ist für jeden anderen Thread, der ready == true sieht, garantiert, dass er auch den früheren Schreibvorgang sieht. So wird viel threadsichere Initialisierung ohne Lock realisiert.
Was volatile nicht garantiert
Der häufigste Fehler:
volatile int counter = 0;
void increment() { counter++; } // STILL BROKENvolatile macht den Lesezugriff atomar und den Schreibzugriff atomar — aber counter++ ist ein Lesen-Modifizieren-Schreiben, also drei Operationen. Zwei Threads lesen beide 42, berechnen beide 43, schreiben beide 43. Ein Inkrement geht trotzdem verloren.
Für zusammengesetzte Aktualisierungen reicht volatile nicht. Man verwendet synchronized, einen Lock, oder — fast immer die richtige Antwort — einen AtomicInteger.
Was volatile außerdem nicht bietet:
- Es gewährt keinen gegenseitigen Ausschluss. Zwei Threads können gleichzeitig in ein
volatile-Feld schreiben; das Ergebnis ist „einer gewinnt, der andere geht verloren" — das ist die richtige Antwort für flagähnlichen Zustand, aber die falsche für „diese zwei Werte zusammenführen". - Es synchronisiert mehrere Felder nicht atomar zusammen. Wenn die Invariante lautet „wenn A wahr ist, muss B gleich 42 sein", können separate
volatile-Felder dazu führen, dass ein anderer Thread das neue A und das alte B sieht.
Das Publikationsmuster
Die nützlichste Anwendung von volatile außerhalb des Stop-Flag-Falls:
class LazyResource {
private Resource cached; // not volatile
private volatile boolean ready = false; // the publication flag
public Resource get() {
if (!ready) {
synchronized (this) {
if (!ready) {
cached = buildResource(); // expensive init
ready = true; // publishes cached
}
}
}
return cached;
}
}Der volatile-Schreibvorgang ready = true veröffentlicht den vorherigen Schreibvorgang auf cached. Jeder Thread, der anschließend ready == true liest, sieht garantiert das vollständig initialisierte cached. Der Lock wird nur beim ersten Aufruf beansprucht; nachfolgende Aufrufe lesen nur ready und überspringen die Synchronisation vollständig. Das ist das klassische Double-Checked-Locking-Idiom, durch volatile korrekt gemacht.
Ohne volatile auf ready ist die Optimierung kaputt — der zweite Thread kann ready == true sehen und dann cached als null lesen. Mit volatile ist das unmöglich.
(Die moderne Alternative ist einfach private final Resource cached = buildResource();, wenn eager Initialisierung in Ordnung ist, oder Supplier<Resource> lazy = Suppliers.memoize(...) aus der Bibliothek der Wahl. Handgeschriebenes Double-Checked-Locking ist in modernem Code selten; das Muster ist es wert, bekannt zu sein, weil man es lesen wird.)
Wann volatile genau richtig ist
Eine kleine Handvoll Anwendungsfälle, in denen volatile die beste Antwort ist:
- Ein Single-Writer-Statusflag, das von vielen Threads gelesen wird. Stop-Signale, „ready"-Flags, Konfigurations-Versionsnummern.
- Eine Referenz auf einen unveränderlichen Wert, der atomar ausgetauscht wird. Cache, den der Schreiber neu aufbaut und veröffentlicht; Leser sehen entweder das alte oder das neue vollständige Objekt, nie ein halb aufgebautes.
- Die Veröffentlichung des Ergebnisses einer einmaligen Initialisierung durch das Double-Checked-Locking-Muster.
- Ein Zeitstempel oder eine Sequenznummer, die ein Thread setzt und andere lesen — wo das Verpassen des allerneuesten Wertes akzeptabel ist, der Wert aber stets in sich konsistent sein muss.
Außerhalb dieser Fälle ist in der Regel ein übergeordnetes Werkzeug gefragt. Nicht clever mit volatile sein — seine Semantik ist zu dünn, um allgemeinen Zustand zu verwalten.
volatile long und volatile double auf 32-Bit-JVMs
Ein historischer Sonderfall. Auf einer 32-Bit-JVM darf ein nicht-volatileer long- oder double-Lese-/Schreibvorgang in zwei 32-Bit-Zugriffe aufgeteilt werden. Zwei Threads können einen zerrissenen Wert erzeugen — die oberen 32 Bits eines Schreibvorgangs und die unteren 32 Bits eines anderen. volatile long und volatile double sind unabhängig von der Wortgröße garantiert atomar.
Moderne JVMs laufen fast immer auf 64-Bit, und 64-Bit-JVMs machen alle Primitiven ohnehin atomar, aber die Regel steht noch in der Spezifikation. Wenn man ein long/double über Threads hinweg teilt, sollte man es als volatile markieren (oder AtomicLong/AtomicDouble verwenden).
Ein ausgearbeitetes Beispiel: der Stop-Flag-Fehler
Das folgende Programm führt den Worker zuerst mit einem einfachen boolean aus (der meistens nie stoppt) und dann mit einem volatile boolean (der prompt stoppt). Ein Watchdog bricht den fehlerhaften Fall nach 2 Sekunden ab, damit die Demo endet.
Was man aus dem Lauf mitnimmt:
- Der
PLAIN-Worker hat innerhalb des Watchdog-Fensters oft nicht gestoppt. Der Haupt-Thread schriebstop = true; der Worker-Thread bemerkte es mit seiner registergecachten Kopie vonstopnie. Mitvolatileverschwindet der Fehler. Das ist das Sichtbarkeitsproblem — jedes Multithreading-Programm hat eine Version davon. - Der
VOLATILE-Worker stoppte innerhalb von ein oder zwei Mikrosekunden nach dem Schreibvorgang. Das sind die Kosten der Speicherbarriere, die die JVM für einvolatile-Schreib-/Lesepaar emittiert — einstellige Mikrosekunden im schlimmsten Fall, oft weniger. Günstig für den richtigen Anwendungsfall. - Der
VOLATILE++-Zähler war kleiner als200.000.volatilemachtn++nicht zu einer atomaren Operation — es ist immer noch ein Lesen-Modifizieren-Schreiben, und zwei Threads können beide42lesen und beide43speichern. Das ist der häufigste Missbrauch vonvolatile. Für zusammengesetzte Aktualisierungen greift man zuAtomicInteger(nächstes Kapitel). - Die Kosten von
volatilesind gering, aber real — jeder Lese- und Schreibvorgang berührt den Hauptspeicher und emittiert eine Speicherbarriere. Für ein Flag, das einmal pro Schleifeniteration gelesen wird, ist das nichts. Für ein Feld, das in einer engen inneren Schleife gelesen wird, summieren sich die Barriere-Kosten. Findet man ein heißesvolatilein einem Profil, sollte man prüfen, ob der Lesezugriff in eine lokale Variable gezogen und der Schleifenkörper auf diesem Snapshot ausgeführt werden kann. volatileist auch der Publikationsmechanismus für eine Referenz — Daten schreiben, dann die Referenzvolatile-schreiben. Der lesende Thread, der die neue Referenz sieht, sieht garantiert alle Daten, die der Schreiber vorbereitet hat. Das ist der Baustein der Double-Checked-Locking- und Immutable-Value-Swap-Muster.
Was als nächstes kommt
Das nächste Kapitel, Java Atomic Variables, stellt AtomicInteger, AtomicLong, AtomicReference und den Rest der java.util.concurrent.atomic-Familie vor — das richtige Werkzeug für „einen Zähler aus vielen Threads inkrementieren" und jede andere lock-freie zusammengesetzte Aktualisierung.