1. Die Klasse ReentrantLock: flexible Sperre
Das Schlüsselwort synchronized eignet sich hervorragend für Basisszenarien: Man kann schnell und einfach eine Methode oder einen Codeblock schützen. Manchmal braucht man jedoch mehr:
- Die Sperre explizit steuern (z. B. versuchen, sie zu erwerben, und falls das nicht gelingt – nicht warten).
- Lese- und Schreibzugriffe auf eine Ressource trennen.
- Das Warten auf eine Sperre unterbrechen.
- Diagnostizieren, wer die Sperre wann erworben oder freigegeben hat.
Für solche Aufgaben wurden die Klassen ReentrantLock und ReadWriteLock entworfen. Sie geben mehr Kontrolle und Möglichkeiten als das altbewährte synchronized.
Was ist das eigentlich?
ReentrantLock ist eine Klasse, die das Interface Lock implementiert. Sie funktioniert ähnlich wie synchronized, bietet jedoch zusätzliche Möglichkeiten. Der wesentliche Unterschied: Die Sperrensteuerung erfolgt explizit – Sie rufen die Methoden lock() und unlock() selbst auf.
Interessant dabei: Das Wort reentrant bedeutet, dass ein Thread dieselbe Sperre mehrfach hintereinander erwerben kann, ohne eine gegenseitige Blockierung zu verursachen. Das ist nützlich, wenn eine Methode sich rekursiv aufruft oder in einer Aufrufkette mit einer gemeinsamen Sperre arbeitet.
Verwendungssyntax
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int value = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Sperre erfassen
try {
value++;
} finally {
lock.unlock(); // Sperre unbedingt freigeben!
}
}
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
Beachten Sie:
Aufrufe von lock() und unlock() sollten stets von einem try...finally begleitet werden. Wenn Sie unlock() vergessen, kann kein anderer Thread den geschützten Bereich betreten – es kommt zu einer endlosen Blockierung.
Möglichkeiten von ReentrantLock
Erwerbsversuch:
Man kann versuchen, die Sperre zu erwerben, ohne endlos zu warten:
if (lock.tryLock()) {
try {
// Arbeiten
} finally {
lock.unlock();
}
} else {
// Konnte nicht sperren – etwas anderes tun
}
Warten mit Timeout:
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
// Innerhalb von 100 ms gesperrt
}
Prüfen, ob die Sperre gehalten wird:
if (lock.isLocked()) { ... }
Diagnose der Warteschlange, „Fairness“ der Sperre und weitere Extras.
3. Beispiel: Zählerinkrement mit ReentrantLock
Erweitern wir unsere Konsolenanwendung (z. B. simulieren wir die Bearbeitung von Bestellungen aus verschiedenen Threads). Vergleichen wir die Arbeit mit synchronized und mit ReentrantLock.
Beispiel mit synchronized
public class OrderCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Analoges Beispiel mit ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class OrderCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Was ist der Vorteil?
- Man kann versuchen, die Sperre zu erwerben, ohne endlos zu warten (tryLock()).
- Komplexe Logik ist möglich: etwa mehrere Sperren in einer bestimmten Reihenfolge zu erwerben (relevant für komplexe Datenstrukturen).
- Man kann von einem anderen Ort „entsperren“ (aber das nur mit großer Vorsicht – stets an unlock() denken!).
4. ReadWriteLock: Sperre für Lesen und Schreiben
Was ist das?
ReadWriteLock ist nicht nur ein Lock, sondern ein intelligenter Zugriffsverteiler. Die wichtigste Implementierung ist ReentrantReadWriteLock, und sie teilt Sperren in zwei Kategorien: für Lesen und für Schreiben.
Wenn Threads nur lesen und niemand etwas ändert, können sie problemlos gleichzeitig arbeiten – Lesen behindert Lesen nicht. Sobald jedoch jemand eine Änderung vornimmt, müssen alle anderen warten: Schreiben ist exklusiv und verlangt Ruhe.
Dieser Ansatz ist besonders nützlich, wenn es viele Lesezugriffe und wenige Änderungen gibt – zum Beispiel in einem Produktkatalog, den Benutzer ständig ansehen, aber nur selten aktualisieren.
Verwendungssyntax
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ProductCatalog {
private final Map<String, String> products = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public void addProduct(String id, String name) {
rwLock.writeLock().lock();
try {
products.put(id, name);
} finally {
rwLock.writeLock().unlock();
}
}
public String getProduct(String id) {
rwLock.readLock().lock();
try {
return products.get(id);
} finally {
rwLock.readLock().unlock();
}
}
}
Beispielverwendung in unserer Anwendung
Angenommen, wir haben eine Bestelldatenbank, die von allen Threads gelesen wird (z. B. zur Suche nach Bestellungen), aber von Zeit zu Zeit kommen neue Bestellungen hinzu (Schreiboperation).
import java.util.*;
import java.util.concurrent.locks.*;
public class OrderDatabase {
private final List<String> orders = new ArrayList<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Bestellung hinzufügen (erfordert writeLock)
public void addOrder(String order) {
rwLock.writeLock().lock();
try {
orders.add(order);
} finally {
rwLock.writeLock().unlock();
}
}
// Kopie aller Bestellungen abrufen (parallel lesbar)
public List<String> getOrders() {
rwLock.readLock().lock();
try {
// Kopie zurückgeben, um Race Conditions zu vermeiden
return new ArrayList<>(orders);
} finally {
rwLock.readLock().unlock();
}
}
}
Was passiert hier?
- Solange niemand schreibt, können selbst Tausende Threads die Bestellliste gleichzeitig lesen.
- Sobald ein Thread beginnt, eine Bestellung hinzuzufügen, werden Lesezugriffe blockiert, um keine inkonsistenten Daten zu erhalten.
5. Vergleich: Wann was verwenden?
| Szenario | synchronized | ReentrantLock | ReadWriteLock |
|---|---|---|---|
| Einfache Synchronisation | ✔ | ✔ | ✖ (überflüssig) |
| Timeout/Erwerbsversuch benötigt | ✖ | ✔ | ✔ |
| Viele Lesevorgänge, wenig Schreiben | ✖ | ✖ | ✔ (deutlicher Zugewinn) |
| Diagnose/Statistiken erforderlich | ✖ | ✔ | ✔ |
| Rekursive Sperre | ✔ | ✔ (Reentranz) | ✔ |
Fazit:
- Für einfache Fälle – verwenden Sie synchronized.
- Für Flexibilität – ReentrantLock.
- Für Szenarien „oft lesen, selten schreiben“ – ReadWriteLock.
6. Visualisierung: Funktionsschema von ReadWriteLock
flowchart LR
subgraph Lesen
T1[Thread 1] -- Lesen --> Orders
T2[Thread 2] -- Lesen --> Orders
T3[Thread 3] -- Lesen --> Orders
end
subgraph Schreiben
T4[Thread 4] -- Schreiben (addOrder) --> Orders
end
Orders[Bestellliste]
style Orders fill:#f9f,stroke:#333,stroke-width:2px
Solange kein Thread schreibt, können alle gleichzeitig lesen. Sobald eine Schreiboperation auftaucht, warten die übrigen Threads auf den Abschluss von writeLock.
7. Implementierungsdetails und Feinheiten
„Fairness“
Bei ReentrantLock und ReentrantReadWriteLock kann man einen „fairen“ Modus (fair mode) aktivieren: Threads werden in Warteschlangenreihenfolge bedient statt nach dem Motto „wer zuerst kommt, mahlt zuerst“. Das verhindert Starvation, kann aber die Leistung senken.
Lock fairLock = new ReentrantLock(true); // true – fairer Modus
ReadWriteLock fairRWLock = new ReentrantReadWriteLock(true);
Potenzielle Fallstricke
- Vergessenes unlock: Wenn unlock() nicht aufgerufen wird, entsteht eine dauerhafte Blockierung. Verwenden Sie immer try...finally.
- Ausnahmen innerhalb der Sperre: Auch wenn im geschützten Codeblock eine Ausnahme auftritt, muss die Sperre freigegeben werden!
- Übermäßiger Einsatz von ReadWriteLock: Für kleine Collections oder wenn fast immer nur geschrieben wird, bringt ReadWriteLock wenig, und der Code wird komplexer.
8. Typische Fehler
Fehler Nr. 1: unlock() vergessen
Der häufigste und tückischste Fehler ist, nach dem Erwerb der Sperre unlock() zu vergessen. Ergebnis: dauerhafte Blockierung, Threads „hängen“. Verwenden Sie immer try...finally, selbst wenn es scheint, dass „hier nichts schiefgehen kann“.
Fehler Nr. 2: ReadWriteLock dort einsetzen, wo es nicht nötig ist
Wenn Sie kaum paralleles Lesen haben, Schreiben aber häufig ist, macht ReadWriteLock den Code nur komplizierter und kann die Leistung senken. Setzen Sie es nur dort ein, wo wirklich viele gleichzeitige Leser vorhanden sind.
Fehler Nr. 3: Mehrere Sperren in unterschiedlicher Reihenfolge erworben
Wenn Ihr Code mehrere Locks erwirbt (z. B. für mehrere Objekte), tun Sie das in allen Threads stets in derselben Reihenfolge. Andernfalls droht ein deadlock – Threads warten dann ewig aufeinander.
Fehler Nr. 4: synchronized „einfach so“ durch ReentrantLock ersetzen
Ersetzen Sie nicht gedankenlos überall synchronized durch Lock – das beschleunigt ein Programm nicht zwingend und kann den Code weniger lesbar machen.
Fehler Nr. 5: Reentranz vergessen
Wenn derselbe Thread lock() mehrfach hintereinander aufruft, ist das für ReentrantLock normal; denken Sie aber daran, unlock() ebenso oft aufzurufen!
GO TO FULL VERSION