1. Warum ReadWriteLock nicht immer ausreicht
Problem: hohe Schreibkonkurrenz
ReadWriteLock (meistens – ReentrantReadWriteLock) funktioniert gut, wenn die meisten Threads Daten nur lesen und es wenige Schreiber gibt. In diesem Fall können mehrere Threads gleichzeitig lesen, und ein Schreibvorgang blockiert den Zugriff nur für kurze Zeit.
Das Problem tritt auf, wenn es mehr schreibende Threads gibt als erwartet oder wenn sie häufig zwischen Lesen und Schreiben wechseln. Wenn Schreiboperationen lange dauern, warten Threads länger auf die Freigabe der Sperren. Dadurch steigt die Konkurrenz auf die Daten, Lesen und Schreiben verlangsamen sich, und die Effizienz von ReadWriteLock sinkt.
Teures Umschalten der Sperren
Wenn ein Thread vom Lesemodus in den Schreibmodus (oder umgekehrt) wechselt, findet unter der Haube eine komplexe Prüfung statt: Es muss sichergestellt werden, dass niemand schreibt, dass alle Leser draußen sind, und erst dann wird das Schreiben erlaubt.
Wenn solche Wechsel häufig auftreten, führt das zu Verzögerungen. Threads reihen sich in eine Warteschlange ein, und die Leistung fällt – besonders, wenn gleichzeitig viele Lese- und Schreiboperationen stattfinden.
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
// Lesen
readLock.lock();
try {
// Daten lesen
} finally {
readLock.unlock();
}
// Schreiben
writeLock.lock();
try {
// Daten ändern
} finally {
writeLock.unlock();
}
Jedes Mal, wenn ein Thread zwischen readLock und writeLock wechselt, führt das System alle Prüfungen aus, um Konflikte zu vermeiden. Wenn es viele solcher Wechsel gibt, wird es teuer.
2. StampedLock: ein moderner Ansatz für Synchronisation
StampedLock ist ein moderner Synchronisationsmechanismus, der in Java 8 eingeführt wurde. Er vereint Ideen aus ReadWriteLock, fügt jedoch einen neuen Modus hinzu – optimistisches Lesen – und arbeitet nicht mit Sperren, sondern mit „Stempeln“ (stamps), speziellen Token, die explizit freigegeben werden müssen.
Schlüsseleigenschaften:
- Drei Modi: write lock (exklusives Schreiben), read lock (geteiltes Lesen), optimistic read (optimistisches Lesen ohne Sperre).
- Keine Reentrancy: dieselbe Sperre kann nicht erneut aus demselben Thread betreten werden.
- Hohe Performance bei vielen Lesezugriffen und seltenen Schreibvorgängen.
- Erfordert explizites Stempel-Management (stamp).
Optimistisches Lesen: tryOptimisticRead + validate
Optimistisches Lesen bedeutet, dass ein Thread Daten völlig ohne Sperre liest – in der Hoffnung, dass gerade niemand schreibt. Nach dem Lesen muss der Thread mit validate(stamp) prüfen, ob während des Lesens geschrieben wurde.
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x, y;
private final StampedLock lock = new StampedLock();
public double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
// Prüfen, ob während des Lesens geschrieben wurde
if (!lock.validate(stamp)) {
// Falls geschrieben wurde – normalen Read-Lock nehmen
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.hypot(currentX, currentY);
}
}
So funktioniert es:
- tryOptimisticRead() gibt einen Stempel (long) zurück, der „gültig“ ist, solange niemand schreibt.
- Die Werte x und y werden gelesen.
- validate(stamp) prüft, ob zwischen Beginn und Ende des Lesens ein Schreibvorgang stattfand.
- Wenn alles ok ist, verwenden wir die gelesenen Werte; wenn geschrieben wurde, nehmen wir einen normalen readLock und lesen erneut.
Das lohnt sich, wenn es sehr wenige Schreibvorgänge gibt, aber viele Lesezugriffe. In den meisten Fällen liefert validate true, und das Lesen ist nahezu kostenlos.
Szenarien „viel Lesen – wenig Schreiben“
- Daten ändern sich selten, werden aber häufig gelesen (z. B. Cache, Koordinaten, Metadaten).
- Minimale Latenz beim Lesen ist wichtig.
- Ein „Neulesen“ ist akzeptabel, falls doch geschrieben wurde.
Fallback auf Read-Lock
Wenn das optimistische Lesen fehlschlägt (validate liefert false), fällt der Thread auf einen normalen readLock zurück. Das garantiert korrekte Daten, passiert aber selten.
3. Stolpersteine von StampedLock
Keine Reentrancy
Im Unterschied zu ReentrantReadWriteLock unterstützt StampedLock keinen Wiedereinstieg. Hält ein Thread die Sperre bereits und versucht, sie erneut zu nehmen, entsteht ein Deadlock.
long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! Thread wartet auf sich selbst
Sensibilität für Interrupts
StampedLock reagiert nicht in gleicher Weise auf Thread-Interrupts wie klassische Sperren. Wird ein Thread während des Wartens auf eine Sperre unterbrochen, „wacht“ er nicht immer sofort auf. Für Aufgaben, bei denen eine schnelle Reaktion auf Interrupts wichtig ist, sollten andere Mechanismen verwendet werden.
Korrektes Freigeben von Stempeln
Jeder Aufruf von readLock(), writeLock() oder tryOptimisticRead() liefert einen eindeutigen Stempel (long). Diesen muss man an die passende unlock-Methode übergeben:
- unlockRead(stamp)
- unlockWrite(stamp)
Fehler: Werden Stempel verwechselt oder wird unlock vergessen, führt das zu Sperren-Leaks und zum Hängenbleiben des Programms.
4. Vergleich mit ReentrantReadWriteLock
| Merkmal | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| Reentrancy (Wiedereinstieg) | Ja | Nein |
| Optimistisches Lesen | Nein | Ja |
| Performance bei hoher Konkurrenz | Mittel | Hoch (bei wenigen Schreibvorgängen) |
| Explizite Sperrenverwaltung | Nein (automatisch) | Ja (Stempel) |
| Reaktion auf Interrupts | Ja | Nicht immer |
| Fairness (fair) | Ja (kann aktiviert werden) | Nein |
Fairness-Modi und Einfluss auf Starvation
- Bei ReentrantReadWriteLock kann ein „fairer“ Modus (fair) aktiviert werden, sodass Threads in Warteschlangenreihenfolge bedient werden. Das verhindert Starvation.
- Bei StampedLock gibt es keine Fairness: Threads können länger warten, wenn andere die Sperre ständig „abfangen“. Seltene Starvation ist möglich.
Zähler: LongAdder/LongAccumulator vs. AtomicLong
Problem mit AtomicLong bei hoher Konkurrenz
AtomicLong ist eine atomare Variable, die ein thread-sicheres Inkrement bereitstellt. Wenn jedoch viele Threads gleichzeitig incrementAndGet() aufrufen, „kämpfen“ sie alle um dieselbe Variable, was zu Leistungseinbußen führt.
LongAdder: gestreifte (striped) Zähler
LongAdder löst das Problem anders: Er teilt den Zähler in mehrere „Streifen“ (stripes) auf, von denen jeder eine eigene Thread-Gruppe bedient. Ein Thread erhöht einen der Streifen, und der Gesamtwert ist die Summe aller Streifen.
Vorteil:
- Bei hoher Konkurrenz kommen sich Threads kaum in die Quere.
- Die Performance ist um ein Vielfaches höher als bei AtomicLong.
import java.util.concurrent.atomic.LongAdder;
LongAdder adder = new LongAdder();
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
adder.increment();
}
};
Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("Endwert: " + adder.sum());
LongAccumulator
LongAccumulator ist die verallgemeinerte Version von LongAdder, bei der eine beliebige Akkumulationsfunktion (z. B. Maximum, Minimum etc.) angegeben werden kann.
import java.util.concurrent.atomic.LongAccumulator;
LongAccumulator max = new LongAccumulator(Long::max, Long.MIN_VALUE);
max.accumulate(10);
max.accumulate(42);
max.accumulate(7);
System.out.println("Maximum: " + max.get()); // 42
Striped-Locks und Reduktion der Konkurrenz
Die Technik der striped locks teilt eine gemeinsame Ressource in mehrere unabhängige Teile (stripes), von denen jeder durch eine eigene Sperre oder Variable geschützt ist. Threads verteilen sich gleichmäßig über die Streifen, was die Konkurrenz reduziert und die Performance erhöht. Genau diesen Ansatz verwenden LongAdder und LongAccumulator.
6. Praxis: Cache mit Leseübergewicht
Aufgabe: Wir haben eine Map mit Daten und Metadaten (z. B. Zugriffszähler). Lesezugriffe sind häufig, Schreibvorgänge selten.
Implementierung mit StampedLock:
import java.util.*;
import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.atomic.LongAdder;
public class MetadataCache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final StampedLock lock = new StampedLock();
private final LongAdder hits = new LongAdder();
public V get(K key) {
long stamp = lock.tryOptimisticRead();
V value = map.get(key);
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
value = map.get(key);
} finally {
lock.unlockRead(stamp);
}
}
if (value != null) hits.increment();
return value;
}
public void put(K key, V value) {
long stamp = lock.writeLock();
try {
map.put(key, value);
} finally {
lock.unlockWrite(stamp);
}
}
public long getHits() {
return hits.sum();
}
}
- Für das Lesen wird der optimistische Modus verwendet: Wenn niemand schreibt, ist das Lesen nahezu kostenlos.
- Für das Schreiben – write lock.
- Für die Zählung der Zugriffe – LongAdder: Selbst bei hoher Konkurrenz behindern sich Inkremente kaum.
7. Profiling: LongAdder vs. AtomicLong unter Last
Test: 8 Threads mit je 1 Mio. Inkrementen
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public class CounterBenchmark {
public static void main(String[] args) throws InterruptedException {
int threads = 8;
int increments = 1_000_000;
// AtomicLong
AtomicLong atomic = new AtomicLong();
long start = System.nanoTime();
Thread[] t1 = new Thread[threads];
for (int i = 0; i < threads; i++) {
t1[i] = new Thread(() -> {
for (int j = 0; j < increments; j++) atomic.incrementAndGet();
});
t1[i].start();
}
for (Thread t : t1) t.join();
long timeAtomic = System.nanoTime() - start;
// LongAdder
LongAdder adder = new LongAdder();
start = System.nanoTime();
Thread[] t2 = new Thread[threads];
for (int i = 0; i < threads; i++) {
t2[i] = new Thread(() -> {
for (int j = 0; j < increments; j++) adder.increment();
});
t2[i].start();
}
for (Thread t : t2) t.join();
long timeAdder = System.nanoTime() - start;
System.out.printf("AtomicLong: %d ms, LongAdder: %d ms%n", timeAtomic / 1_000_000, timeAdder / 1_000_000);
}
}
Typisches Ergebnis:
AtomicLong: 2500 ms, LongAdder: 200 ms
Fazit: Bei hoher Konkurrenz ist LongAdder um ein Vielfaches schneller als AtomicLong.
8. Häufige Fehler bei der Verwendung von StampedLock und LongAdder
Fehler Nr. 1: unlockRead/unlockWrite nicht aufgerufen. Wird der Stempel nicht freigegeben, warten andere Threads ewig. Verwenden Sie immer try/finally!
Fehler Nr. 2: Versuch von Reentrancy. StampedLock unterstützt keinen Wiedereinstieg. Nehmen Sie die Sperre nicht zweimal aus demselben Thread.
Fehler Nr. 3: falsche Verwendung von validate. Ohne Prüfung von validate nach tryOptimisticRead können inkonsistente Daten entstehen.
Fehler Nr. 4: Einsatz von AtomicLong bei hoher Konkurrenz. AtomicLong ist gut für 1–2 Threads, wird aber bei 8+ Threads zum Engpass. Verwenden Sie LongAdder.
Fehler Nr. 5: striped Locks vergessen. Wenn Sie ein eigenes Striped-Lock implementieren, stellen Sie sicher, dass Threads gleichmäßig auf die Streifen verteilt werden, sonst werden einige überlastet, während andere untätig sind.
Fehler Nr. 6: Fairness von StampedLock erwarten. StampedLock garantiert keine Reihenfolge der Thread-Bedienung. In seltenen Fällen ist Starvation möglich.
GO TO FULL VERSION