CodeGym /Kursy /JAVA 25 SELF /ReentrantLock i ReadWriteLock: różnice, przykłady

ReentrantLock i ReadWriteLock: różnice, przykłady

JAVA 25 SELF
Poziom 52, Lekcja 2
Dostępny

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!

1
Zadanie
JAVA 25 SELF, poziom 52, lekcja 2
Niedostępne
Centralne zarządzanie zapasami magazynu 📦
Centralne zarządzanie zapasami magazynu 📦
1
Zadanie
JAVA 25 SELF, poziom 52, lekcja 2
Niedostępne
Rejestr globalnych ustawień aplikacji ⚙️
Rejestr globalnych ustawień aplikacji ⚙️
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION