1. Dlaczego ReadWriteLock nie zawsze wystarcza
Problem wysokiej konkurencji na zapis
ReadWriteLock (najczęściej – ReentrantReadWriteLock) dobrze działa, gdy większość wątków jedynie odczytuje dane, a piszących jest mało. W takim przypadku kilka wątków może czytać jednocześnie, a zapis blokuje dostęp tylko na krótki moment.
Problem pojawia się, gdy wątków wykonujących zapis jest więcej, niż oczekiwano, lub często przełączają się między odczytem i zapisem. Jeśli operacje zapisu trwają długo, wątki zaczynają dłużej czekać na zwolnienie blokad. W efekcie konkurencja o dostęp do danych rośnie, odczyt i zapis zwalniają, a efektywność ReadWriteLock spada.
Kosztowne przełączanie blokad
Gdy wątek przechodzi z trybu odczytu do trybu zapisu (lub odwrotnie), pod spodem odbywa się złożona weryfikacja: trzeba upewnić się, że nikt inny nie pisze, że wszyscy czytelnicy wyszli, i dopiero wtedy pozwolić na zapis.
Jeśli takie przejścia zdarzają się często, powoduje to opóźnienia. Wątki zaczynają stać w kolejce, a wydajność spada – zwłaszcza gdy jednocześnie jest dużo operacji odczytu i zapisu.
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
// Odczyt
readLock.lock();
try {
// czytamy dane
} finally {
readLock.unlock();
}
// Zapis
writeLock.lock();
try {
// zmieniamy dane
} finally {
writeLock.unlock();
}
Za każdym razem, gdy wątek przechodzi między readLock i writeLock, system wykonuje wszystkie sprawdzenia, aby uniknąć konfliktów. Jeśli takich przejść jest dużo – to kosztowne.
2. StampedLock: nowoczesne podejście do synchronizacji
StampedLock to nowoczesny mechanizm synchronizacji, który pojawił się w Javie 8. Łączy idee ReadWriteLock, ale dodaje nowy tryb – optymistyczny odczyt, a także działa nie na blokadach, lecz na „znacznikach” (stamps) – specjalnych tokenach, które trzeba jawnie zwalniać.
Kluczowe cechy:
- Trzy tryby: write lock (wyłączny zapis), read lock (współdzielony odczyt), optimistic read (optymistyczny odczyt bez blokady).
- Brak reentrantności: nie można wejść w blokadę ponownie z tego samego wątku.
- Wysoka wydajność przy dużej liczbie odczytów i rzadkich zapisach.
- Wymaga jawnego zarządzania znacznikami (stamp).
Optymistyczny odczyt: tryOptimisticRead + validate
Optymistyczny odczyt to tryb, w którym wątek czyta dane w ogóle bez blokady, licząc na to, że w tym momencie nikt nie pisze. Po odczycie wątek musi sprawdzić, czy nie było zapisu w trakcie czytania, za pomocą metody validate(stamp).
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;
// Sprawdzamy, czy nie było zapisu w trakcie odczytu
if (!lock.validate(stamp)) {
// Jeśli był zapis – bierzemy zwykły read lock
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.hypot(currentX, currentY);
}
}
Jak to działa:
- tryOptimisticRead() zwraca znacznik (long), który „jest ważny”, dopóki nikt nie pisze.
- Czytamy wartości x i y.
- validate(stamp) sprawdza, czy nie było zapisu między początkiem a końcem odczytu.
- Jeśli wszystko jest OK – używamy odczytanych wartości; jeśli był zapis – bierzemy zwykły readLock i czytamy ponownie.
To opłacalne, gdy zapisów jest bardzo mało, a odczytów dużo. W większości przypadków validate zwróci true, a odczyt będzie niemal darmowy.
Scenariusze „dużo odczytów – mało zapisów”
- Dane rzadko się zmieniają, ale są często odczytywane (np. cache, współrzędne, metadane).
- Ważne jest zminimalizowanie opóźnień odczytu.
- Można „przeczytać ponownie” dane, jeśli jednak był zapis.
Powrót do read lock
Jeśli optymistyczny odczyt się nie powiódł (validate zwrócił false), wątek „cofa się” do zwykłego readLock. Gwarantuje to poprawność danych, ale zdarza się rzadko.
3. Pułapki StampedLock
Brak reentrantności
W odróżnieniu od ReentrantReadWriteLock, StampedLock nie wspiera ponownego wejścia. Jeśli wątek już trzyma blokadę i spróbuje wziąć ją ponownie – będzie deadlock.
long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! Wątek czeka na samego siebie
Uwaga na przerwania
StampedLock nie reaguje na przerwania wątków tak samo jak klasyczne blokady. Jeśli wątek został przerwany w czasie oczekiwania na blokadę, nie zawsze „budzi się” od razu. Do zadań, w których ważna jest szybka reakcja na przerwanie, używaj innych mechanizmów.
Poprawne zwalnianie znaczników
Każde wywołanie readLock(), writeLock() lub tryOptimisticRead() zwraca unikalny znacznik (long). Trzeba go koniecznie przekazać do odpowiedniej metody unlock:
- unlockRead(stamp)
- unlockWrite(stamp)
Błąd: Jeśli pomylisz znaczniki lub zapomnisz wywołać unlock – dostaniesz wyciek blokad i zawieszenie programu.
4. Porównanie z ReentrantReadWriteLock
| Charakterystyka | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| Reentrancy (powtórne wejście) | Tak | Nie |
| Optymistyczny odczyt | Nie | Tak |
| Wydajność przy wysokiej konkurencji | Średnia | Wysoka (gdy mało zapisów) |
| Jawne zarządzanie blokadą | Nie (automatycznie) | Tak (znaczniki) |
| Reakcja na przerwania | Tak | Nie zawsze |
| Sprawiedliwość (fairness) | Tak (można włączyć) | Nie |
Tryby sprawiedliwości i wpływ na zagłodzenie
- W ReentrantReadWriteLock można włączyć „sprawiedliwy” tryb (fair), aby wątki były obsługiwane w kolejności. Zapobiega to zagłodzeniu (starvation).
- W StampedLock sprawiedliwości nie ma: wątki mogą czekać dłużej, jeśli inne stale „przechwytują” blokadę. Rzadkie zagłodzenie jest możliwe.
5. Liczniki: LongAdder/LongAccumulator vs AtomicLong
Problem z AtomicLong przy wysokiej konkurencji
AtomicLong to zmienna atomowa zapewniająca bezpieczny wątkowo inkrement. Jednak przy dużej liczbie wątków jednocześnie wywołujących incrementAndGet() wszystkie „walczą” o jedną zmienną, co prowadzi do spadku wydajności.
LongAdder: pasmowe (striped) liczniki
LongAdder rozwiązuje problem inaczej: rozbija licznik na kilka „pasów” (stripes), z których każdy obsługuje swoją grupę wątków. Wątek zwiększa jeden z pasów, a wartość końcowa to suma wszystkich pasów.
Zaleta:
- Przy wysokiej konkurencji wątki niemal sobie nie przeszkadzają.
- Wydajność jest wielokrotnie wyższa niż w 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("Wartość końcowa: " + adder.sum());
LongAccumulator
LongAccumulator to uogólniona wersja LongAdder, w której można podać dowolną funkcję akumulacji (np. maksimum, minimum itd.).
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("Maksimum: " + max.get()); // 42
Pasmowe (striped) blokady i zmniejszenie konkurencji
Technika striped locks rozbija wspólny zasób na kilka niezależnych części (stripes), z których każda jest chroniona własną blokadą lub zmienną. Wątki rozkładają się równomiernie na pasy, co zmniejsza konkurencję i zwiększa wydajność. Właśnie to podejście wykorzystują LongAdder i LongAccumulator.
6. Praktyka: cache z przewagą odczytów
Zadanie: mamy mapę (Map) z danymi i metadane (np. licznik odwołań). Odczyty są częste, zapisy – rzadkie.
Implementacja ze 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();
}
}
- Do odczytu używany jest tryb optymistyczny: jeśli nikt nie pisze – odczyt jest niemal darmowy.
- Do zapisu – write lock.
- Do zliczania odwołań – LongAdder: nawet przy wysokiej konkurencji inkrementy sobie nie przeszkadzają.
7. Profilowanie LongAdder vs AtomicLong pod obciążeniem
Test: 8 wątków po 1 mln inkrementów
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);
}
}
Typowy wynik:
AtomicLong: 2500 ms, LongAdder: 200 ms
Wniosek: Przy wysokiej konkurencji LongAdder jest wielokrotnie szybszy niż AtomicLong.
8. Typowe błędy przy użyciu StampedLock i LongAdder
Błąd nr 1: zapomniano wywołać unlockRead/unlockWrite. Jeśli nie zwolnisz znacznika, inne wątki będą czekać w nieskończoność. Zawsze używaj try/finally!
Błąd nr 2: próba reentrantności. StampedLock nie wspiera ponownego wejścia. Nie bierz blokady dwa razy z tego samego wątku.
Błąd nr 3: nieprawidłowe użycie validate. Jeśli nie sprawdzisz validate po tryOptimisticRead, możesz otrzymać niespójne dane.
Błąd nr 4: używanie AtomicLong przy wysokiej konkurencji. AtomicLong jest dobry dla 1–2 wątków, ale przy 8+ wątkach staje się „wąskim gardłem”. Używaj LongAdder.
Błąd nr 5: zapomniano o pasmowych blokadach. Jeśli implementujesz własny striped-lock, upewnij się, że wątki rozkładają się równomiernie na pasy, w przeciwnym razie część pasów będzie przeciążona, a inne – będą się nudzić.
Błąd nr 6: oczekiwanie sprawiedliwości od StampedLock. StampedLock nie gwarantuje kolejności obsługi wątków. W rzadkich przypadkach możliwe jest zagłodzenie.
GO TO FULL VERSION