1. Klasa ReentrantLock: elastyczna blokada
Słowo kluczowe synchronized świetnie nadaje się do podstawowych przypadków: szybko i prosto można chronić metodę lub blok kodu. Czasem jednak chcemy czegoś więcej:
- Jawnie sterować blokadą (np. spróbować ją przejąć, a jeśli się nie uda — nie czekać).
- Rozdzielać prawa do „odczytu” i „zapisu” zasobu.
- Przerywać oczekiwanie na blokadę.
- Diagnozować, kto i kiedy przejął lub zwolnił blokadę.
Dla takich zadań powstały klasy ReentrantLock i ReadWriteLock. Dają więcej kontroli i możliwości niż stary, dobry synchronized.
Czym to właściwie jest?
ReentrantLock to klasa implementująca interfejs Lock. Działa podobnie do synchronized, ale z dodatkowymi możliwościami. Główna różnica — sterowanie blokadą staje się jawne: samodzielnie wywołujesz metody lock() i unlock().
Ciekawostka — słowo reentrant oznacza, że wątek może przejąć tę samą blokadę wielokrotnie, nie powodując zakleszczenia. To przydatne, gdy metoda wywołuje samą siebie rekurencyjnie lub pracuje ze wspólną blokadą w łańcuchu wywołań.
Składnia użycia
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(); // Przejmujemy blokadę
try {
value++;
} finally {
lock.unlock(); // Koniecznie zwalniamy blokadę!
}
}
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
Zwróć uwagę:
Wywołania lock() i unlock() zawsze należy otaczać konstrukcją try...finally. Jeśli zapomnisz wywołać unlock(), żaden inny wątek nie wejdzie do chronionego bloku — powstanie „wieczny korek”.
Możliwości ReentrantLock
Próba przejęcia:
Można spróbować przejąć blokadę, ale nie czekać w nieskończoność:
if (lock.tryLock()) {
try {
// Pracujemy
} finally {
lock.unlock();
}
} else {
// Nie udało się przejąć — robimy coś innego
}
Oczekiwanie z timeoutem:
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
// Przejęto w 100 ms
}
Sprawdzenie, czy blokada jest przejęta:
if (lock.isLocked()) { ... }
Diagnostyka kolejki oczekujących, „sprawiedliwość” blokady i inne udogodnienia.
3. Przykład: inkrementacja licznika z ReentrantLock
Rozwińmy naszą aplikację konsolową (np. symulujemy obsługę zamówień z różnych wątków). Porównajmy, jak wygląda praca z synchronized oraz z ReentrantLock.
Przykład z synchronized
public class OrderCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Analogiczny przykład z 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();
}
}
}
Jaki z tego pożytek?
- Można spróbować przejąć blokadę i nie czekać w nieskończoność (tryLock()).
- Można zaimplementować złożoną logikę: np. przejmować kilka blokad w określonej kolejności (istotne dla złożonych struktur danych).
- Można „odblokować” w innym miejscu (ale trzeba robić to bardzo ostrożnie — zawsze pamiętać o unlock()!).
4. ReadWriteLock: blokada do odczytu i zapisu
Co to jest?
ReadWriteLock to nie tylko blokada, lecz „inteligentny” rozdzielacz dostępu. Jego podstawową implementacją jest ReentrantReadWriteLock, który dzieli blokady na dwie kategorie: do odczytu i do zapisu.
Gdy wątki tylko czytają dane i nikt nic nie zmienia, mogą spokojnie pracować razem — odczyt nie przeszkadza odczytowi. Gdy jednak ktoś postanowi wprowadzić zmianę, wszyscy pozostali muszą poczekać: zapis dopuszcza wyłącznie jednego uczestnika i wymaga „ciszy”.
Takie podejście jest szczególnie użyteczne tam, gdzie odczytów jest dużo, a zmian mało — na przykład w katalogu produktów, który użytkownicy stale przeglądają, a aktualizują jedynie od czasu do czasu.
Składnia użycia
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();
}
}
}
Przykład użycia w naszej aplikacji
Załóżmy, że mamy bazę zamówień, którą wszystkie wątki czytają (np. wyszukiwanie zamówienia), ale od czasu do czasu pojawiają się nowe zamówienia (operacja zapisu).
import java.util.*;
import java.util.concurrent.locks.*;
public class OrderDatabase {
private final List<String> orders = new ArrayList<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Dodawanie zamówienia (wymaga writeLock)
public void addOrder(String order) {
rwLock.writeLock().lock();
try {
orders.add(order);
} finally {
rwLock.writeLock().unlock();
}
}
// Pobranie kopii wszystkich zamówień (można czytać równolegle)
public List<String> getOrders() {
rwLock.readLock().lock();
try {
// Zwracamy kopię, aby uniknąć wyścigu
return new ArrayList<>(orders);
} finally {
rwLock.readLock().unlock();
}
}
}
Co się dzieje?
- Dopóki nikt nie pisze, nawet tysiąc wątków może jednocześnie czytać listę zamówień.
- Gdy tylko jeden wątek zaczyna dodawać zamówienie — odczyty są blokowane, aby nie otrzymać niespójnych danych.
5. Porównanie: kiedy czego używać?
| Scenariusz | synchronized | ReentrantLock | ReadWriteLock |
|---|---|---|---|
| Prosta synchronizacja | ✔ | ✔ | ✖ (nadmiarowe) |
| Potrzebny timeout/próba przejęcia | ✖ | ✔ | ✔ |
| Dużo operacji odczytu, mało zapisu | ✖ | ✖ | ✔ (znaczny zysk) |
| Potrzebna diagnostyka/statystyki | ✖ | ✔ | ✔ |
| Rekurencyjna blokada | ✔ | ✔ (reentrantność) | ✔ |
Wniosek:
- Do prostych przypadków — używaj synchronized.
- Gdy potrzebna elastyczność — ReentrantLock.
- Do scenariuszy „często czytamy, rzadko piszemy” — ReadWriteLock.
6. Wizualizacja: schemat działania ReadWriteLock
flowchart LR
subgraph Odczyt
T1[Wątek 1] -- Odczyt --> Orders
T2[Wątek 2] -- Odczyt --> Orders
T3[Wątek 3] -- Odczyt --> Orders
end
subgraph Zapis
T4[Wątek 4] -- Zapis (addOrder) --> Orders
end
Orders[Lista zamówień]
style Orders fill:#f9f,stroke:#333,stroke-width:2px
Dopóki żaden wątek nie pisze, wszyscy mogą czytać równolegle. Gdy tylko pojawia się zapis, pozostałe wątki czekają na zwolnienie writeLock.
7. Cechy implementacji i niuanse
„Sprawiedliwość” (fairness)
W ReentrantLock i ReentrantReadWriteLock można włączyć tryb „sprawiedliwy” (fair mode): wątki są obsługiwane w kolejności przybycia, a nie na zasadzie „kto pierwszy, ten lepszy”. Zapobiega to „głodzeniu” wątków, ale może obniżyć wydajność.
Lock fairLock = new ReentrantLock(true); // true — tryb fair
ReadWriteLock fairRWLock = new ReentrantReadWriteLock(true);
Potencjalne pułapki
- Zapomniany unlock: Jeśli nie wywołasz unlock(), powstanie wieczna blokada. Zawsze używaj try...finally.
- Wyjątki wewnątrz blokady: Nawet jeśli w bloku kodu wystąpi wyjątek, blokada musi zostać zwolniona!
- Nadmierne użycie ReadWriteLock: Dla małych kolekcji lub gdy prawie zawsze jest tylko zapis — ReadWriteLock ma niewielki sens, a kod staje się bardziej skomplikowany.
8. Typowe błędy
Błąd nr 1: zapomniano wywołać unlock()
Najczęstszy i podstępny błąd — zapomnieć wywołać unlock() po przejęciu blokady. W efekcie — wieczna blokada, wątki „wiszą”. Zawsze używaj try...finally, nawet jeśli wydaje się, że „tu nic się nie zepsuje”.
Błąd nr 2: używanie ReadWriteLock tam, gdzie nie jest potrzebny
Jeśli prawie nie masz równoległych odczytów, a zapis jest częsty, ReadWriteLock tylko skomplikuje kod i obniży wydajność. Używaj go tylko tam, gdzie naprawdę jest wielu równoczesnych czytelników.
Błąd nr 3: przejęto kilka blokad w różnej kolejności
Jeśli Twój kod przejmuje kilka Lock (np. dla kilku obiektów), zawsze rób to w tej samej kolejności we wszystkich wątkach. W przeciwnym razie możesz otrzymać deadlock — wątki będą czekać na siebie w nieskończoność.
Błąd nr 4: próba zastąpienia synchronized przez ReentrantLock „ot tak”
Nie warto bezrefleksyjnie zamieniać wszędzie synchronized na Lock — nie zawsze przyspiesza to program i może pogorszyć czytelność kodu.
Błąd nr 5: zapomniano o reentrantności
Jeśli ten sam wątek wywołuje lock() kilka razy z rzędu — to normalne dla ReentrantLock, ale pamiętaj, że unlock() trzeba wywołać tyle samo razy!
GO TO FULL VERSION