Java Atomare Variablen
Lock-freie threadsichere Operationen in Java mit java.util.concurrent.atomic — Zähler, Referenzen und compare-and-set.
volatile macht einzelne Lese- oder Schreiboperationen threadsicher. Es kann counter++ nicht threadsicher machen — das sind drei Operationen. Das Paket java.util.concurrent.atomic schließt diese Lücke. Seine Klassen kapseln einen einzelnen Wert und stellen Operationen wie increment-and-get und compare-and-set als einzelne atomare Instruktionen bereit — kein Lock, kein synchronized-Block, nur ein CPU-Primitiv (compare-and-swap, kurz CAS), das die JVM bis auf Maschinenebene kompiliert.
Atomics sind das richtige Werkzeug für eine überraschend große Anzahl von Multithreading-Mustern: Zähler, Sequenznummern, flag-artiger Zustand und jedes „einen neuen unveränderlichen Snapshot veröffentlichen"-Idiom. Sie sind unter Contention schneller als synchronized und deutlich einfacher als ein manueller Lock um ein einzelnes Feld.
Die Familie
Das Paket enthält acht häufig verwendete Klassen:
| Klasse | Kapselt | Gängige Operationen |
|---|---|---|
AtomicInteger | int | get, set, incrementAndGet, addAndGet, compareAndSet |
AtomicLong | long | dieselben wie oben, auf long |
AtomicBoolean | boolean | get, set, compareAndSet |
AtomicReference<V> | V (beliebige Objektreferenz) | get, set, compareAndSet, updateAndGet |
AtomicIntegerArray | int[] | atomare Ops pro Index |
AtomicLongArray | long[] | atomare Ops pro Index |
AtomicReferenceArray<V> | V[] | atomare Ops pro Index |
LongAdder / LongAccumulator | long | Zähler bei hoher Contention |
Die ersten vier werden in 99 % der Fälle benötigt.
AtomicInteger — der richtige Zähler
Der Ersatz für „volatile int plus ++":
AtomicInteger counter = new AtomicInteger(); // starts at 0
counter.incrementAndGet(); // ++counter, atomic
counter.getAndIncrement(); // counter++, atomic
counter.addAndGet(5); // counter += 5, atomic
counter.set(42); // counter = 42, atomic
int n = counter.get(); // read
counter.compareAndSet(42, 100); // if (counter == 42) counter = 100; return whether it changedincrementAndGet ist der richtige Ansatz für einen einfachen Zähler; intern ist es eine CAS-Schleife, die die CPU in einer einzigen Instruktion auf modernem x86 ausführt (LOCK XADD). Die gesamte Operation ist eine einzelne Speichertransaktion auf Busebene — deutlich günstiger als das Erwerben eines unstrittigen synchronized-Locks.
compareAndSet(expected, new) ist der Baustein für nahezu alles andere. Es schreibt new atomar nur dann, wenn der aktuelle Wert expected ist, und gibt zurück, ob der Schreibvorgang stattgefunden hat. Damit lässt sich jede atomare Aktualisierung eines einzelnen Felds realisieren:
AtomicInteger max = new AtomicInteger(Integer.MIN_VALUE);
void recordMax(int v) {
int cur;
do {
cur = max.get();
if (v <= cur) return; // nothing to do
} while (!max.compareAndSet(cur, v)); // retry if someone else updated
}Die CAS-Schleife ist das Standardmuster: lesen, berechnen, versuchen zu schreiben, bei Konflikt wiederholen. So ist incrementAndGet implementiert; so schreibt man jede zusammengesetzte Aktualisierung eines einzelnen Felds.
Java 8 vereinfachte die Schleife:
max.updateAndGet(cur -> Math.max(cur, v)); // CAS loop hiddenupdateAndGet, accumulateAndGet und getAndUpdate nehmen eine Funktion und führen die CAS-Schleife für einen durch. Verwende sie, wenn sie passen.
AtomicReference<V> — der richtige Weg, ein Objekt zu tauschen
Wenn der gemeinsame Zustand mehr als ein Primitiv ist — eine Konfigurations-Map, ein gecachter Snapshot, ein unveränderlicher Holder — ermöglicht AtomicReference das atomare Austauschen des gesamten Objekts:
AtomicReference<Config> currentConfig = new AtomicReference<>(initialConfig);
void reload() {
Config c = readConfigFromDisk(); // expensive, lock-free
currentConfig.set(c); // publish atomically
}
Config get() { return currentConfig.get(); }Der Trick: Der Inhalt von Config muss unveränderlich sein (oder nach dem Veröffentlichen nicht mehr angefasst werden). Der atomare Swap veröffentlicht einen fertigen Wert; wenn andere Threads danach die Interna des Werts mutieren, ist die Sicherheit verloren. Dies ist das unveränderliche Snapshot-Muster, auf dem die meisten nebenläufigen Caches, Routing-Tabellen und „globale Konfigurations"-Objekte basieren.
updateAndGet auf einer Referenz ist ebenfalls äußerst nützlich:
AtomicReference<List<String>> log = new AtomicReference<>(List.of());
void append(String line) {
log.updateAndGet(old -> {
var copy = new ArrayList<>(old);
copy.add(line);
return List.copyOf(copy); // immutable snapshot
});
}Jeder Leser erhält eine konsistente unveränderliche Liste. Schreiber wetteifern; die CAS-Schleife wiederholt die wenigen, die das Rennen verlieren. Günstig bei geringer Contention, langsam aber korrekt bei hoher.
LongAdder — der Zähler bei hoher Contention
Unter starker Contention wird AtomicLong.incrementAndGet zum Engpass — jeder Thread hämmert auf dieselbe Speicheradresse, und die CPU muss die Bus-Transaktionen serialisieren. LongAdder löst dies, indem es mehrere interne Zähler verwaltet, einen pro CPU, und sie beim Lesen summiert:
LongAdder requestCount = new LongAdder();
void onRequest() { requestCount.increment(); } // append-only, no contention
long snapshot() { return requestCount.sum(); } // sums every cell — not atomic but eventually consistentVerwende LongAdder, wenn:
- Der Zähler von vielen Threads gleichzeitig inkrementiert wird (z. B. Anfragemetriken in einem Webserver).
- Du ihn selten liest (alle paar Sekunden für ein Dashboard).
Verwende AtomicLong, wenn:
- Das Inkrementieren selten oder single-threaded ist.
- Du einen sofortigen, genauen Lesewert benötigst.
LongAdder ist einer der schnellsten nebenläufigen Zähler überhaupt — der Kompromiss ist, dass sum() nicht atomar mit nebenläufigen Inkrementen ist. Für den typischen Metrik-Reporting-Anwendungsfall ist das in Ordnung.
Was Atomics nicht sind
Atomics skalieren auf ein einzelnes Feld. Sie lassen sich nicht über mehrere Felder hinweg zusammensetzen:
AtomicInteger a = new AtomicInteger();
AtomicInteger b = new AtomicInteger();
a.incrementAndGet(); // atomic on its own
b.incrementAndGet(); // atomic on its own
// but the pair is NOT atomic — another thread can see new a, old bWenn eine Invariante mehrere Felder umspannt ("a == b + 1 immer"), wird ein Lock benötigt (oder ein einzelnes Atomic auf einem Holder-Objekt, das beide enthält).
Atomics helfen auch nicht bei der Sichtbarkeit nicht verwandter Felder. Das Schreiben in ein Atomic veröffentlicht andere Felder nicht so wie volatile. Mache jene anderen Felder volatile (oder final, oder schreibe sie durch das Atomic).
compareAndExchange und die neue API (Java 9+)
Java 9 fügte compareAndExchange hinzu (gibt den aktuellen Wert zurück, nicht nur ein boolean):
int prev = counter.compareAndExchange(expected, newVal);
if (prev == expected) { // we won
...
} else { // somebody else got there first
// prev is the actual current value
}Java 9 fügte auch die VarHandle-API hinzu, die schwaches CAS, geordneten Zugriff usw. für Low-Level-Nebenläufigkeitsbibliotheken bereitstellt. Man braucht sie sehr selten; hier wird der Name erwähnt, damit er bekannt ist.
Ein ausgearbeitetes Beispiel: Zähler und Snapshot
Das folgende Programm vergleicht vier Zähler: unsynchronisiert, volatile, AtomicInteger und LongAdder. Alle vier werden von 8 Threads mit jeweils 100.000 Inkrementen belastet.
Was aus dem Lauf zu mitnehmen ist:
plainundvolatilehaben beide Aktualisierungen verloren — manchmal dramatisch (ein Endwert weit unter dem erwarteten800.000).volatilebehebt das Sichtbarkeitsproblem, abern++sind immer noch drei Operationen. Das ist das Wichtigste, das man übervolatilewissen muss: es macht zusammengesetzte Aktualisierungen nicht atomar.AtomicIntegerlieferte bei jedem Lauf exakt den erwarteten Zählerstand. Die Kosten pro Inkrement betrugen wenige Nanosekunden — deutlich höher alsn++auf einem einfachenint(ein oder zwei), aber keine Lock-Akquisitionen und kein Thread-Blocking. Unter Contention ist es deutlich schneller alssynchronized.LongAdderwar der schnellste Zähler unter der 8-Thread-Last — er streut Schreibvorgänge auf separate Zellen pro CPU, sodass die Threads nicht auf einer einzigen Cache-Line konkurrieren. Der Kompromiss ist, dasssum()nicht atomar mitincrement()ist (ein Leser kann einen leicht veralteten Gesamtwert sehen), was für Metriken und Zähler, bei denen keine sofortige Genauigkeit erforderlich ist, genau das Richtige ist.- Die CAS-Schleife für den maximalen Wert hat den größten gesehenen Wert über alle Stichproben hinweg aufgezeichnet. Die Schleife ist das allgemeine Muster: den aktuellen Wert lesen, den gewünschten neuen Wert berechnen, versuchen zu schreiben; wenn jemand anderes zuerst geschrieben hat, schlägt der CAS fehl und man wiederholt. Die meisten
updateAndGet- undaccumulateAndGet-Aufrufe sind diese Schleife mit verstecktem Boilerplate. AtomicReference<List<String>>erzeugte einen unveränderlichen Snapshot des Logs. Jeder Schreiber baute eine neue unveränderliche Kopie und versuchte sie zu veröffentlichen; unter Contention könnten zwei Schreiber beide eine Kopie erstellen, und der CAS eines schlägt fehl — dieser Thread wiederholt, liest die frisch aktualisierte Liste und führt zusammen. Das Muster ist unter starker Contention verschwenderisch (viele weggeworfene Kopien), aber ideal für „leselastige, gelegentlich neu erstellte" Snapshots.
Was kommt als Nächstes
Das nächste Kapitel, Java Locks, beginnt die Geschichte von java.util.concurrent.locks — das Lock-Interface, warum es neben synchronized existiert, und die Fähigkeiten (tryLock, lockInterruptibly, Condition), die es hinzufügt und die der intrinsische Monitor nicht hat.