CodeGym /Kursy /JAVA 25 SELF /Kolekcje thread-safe: ConcurrentHashMap i inne

Kolekcje thread-safe: ConcurrentHashMap i inne

JAVA 25 SELF
Poziom 53 , Lekcja 2
Dostępny

1. Dlaczego zwykłe kolekcje nie nadają się do wielowątkowości

Przypomnijmy, jak pracowaliśmy z kolekcjami w naszej aplikacji głównej (np. pokoju czatu):

List<String> messages = new ArrayList<>();
messages.add("Cześć!");
messages.add("Jak się masz?");

W jednowątkowym programie wszystko działa świetnie. Ale jeśli kilka wątków jednocześnie będzie dodawać, usuwać lub czytać elementy z tej samej kolekcji – witaj w świecie wyścigów danych (race conditions), niespójnych stanów i zagadkowych błędów.

Na przykład jeden wątek dodaje element, drugi usuwa, trzeci iteruje – i nagle dostajemy ConcurrentModificationException, a czasem nawet ArrayIndexOutOfBoundsException lub po prostu „uszkodzoną” kolekcję.

Klasyka gatunku:

List<String> list = new ArrayList<>();
Runnable writer = () -> {
    for (int i = 0; i < 1000; i++) {
        list.add("msg-" + i);
    }
};
Runnable reader = () -> {
    for (String msg : list) {
        // ...
    }
};
// Uruchamiamy writer i reader w różnych wątkach – gwarantowane błędy!

Wniosek: Zwykłe kolekcje (ArrayList, HashMap, HashSet itd.) NIE są thread-safe. Nie wolno ich używać z wielu wątków bez dodatkowej synchronizacji (synchronized, blokady itp.).

2. Jakie są kolekcje thread-safe w Javie

Java nie zostawia cię samemu sobie. Do zadań wielowątkowych w pakiecie java.util.concurrent jest cała gama kolekcji, które można bezpiecznie używać z wielu wątków.

Najważniejsze kolekcje thread-safe:

Kolekcja Zastosowanie Cechy
ConcurrentHashMap
Mapy, cache, częsty dostęp Wysoka wydajność, brak globalnego locka
CopyOnWriteArrayList
List, rzadkie zmiany, częsty odczyt Szybki odczyt, wolne modyfikacje
CopyOnWriteArraySet
Set, rzadkie zmiany, częsty odczyt Podobnie jak lista na Copy-On-Write
ConcurrentLinkedQueue
Kolejka, FIFO Szybka, nieblokująca, kolejki zadań
ConcurrentSkipListMap
Map z sortowaniem (NavigableMap) Odpowiednik TreeMap bezpieczny dla wątków
ConcurrentSkipListSet
Set z sortowaniem Odpowiednik TreeSet bezpieczny dla wątków
BlockingQueue
Kolejki z blokowaniem (pule wątków) Interfejs, wiele implementacji

Ważne! Stary dobry Collections.synchronizedList(list) i podobne to nie do końca to samo, co nowoczesne kolekcje z java.util.concurrent. Więcej – poniżej.

3. ConcurrentHashMap: twój przyjaciel w świecie wielowątkowości

ConcurrentHashMap<K, V> to w gruncie rzeczy ten sam HashMap, tylko „podrasowany” do wielowątkowości. Pozwala kilku wątkom jednocześnie bezpiecznie czytać i zapisywać dane bez blokowania całej mapy.

W zwykłym HashMap, aby zapewnić bezpieczeństwo względem wątków, trzeba założyć blokadę na całą strukturę – i natychmiast robi się z tego wąskie gardło: dopóki jeden wątek pracuje, pozostałe czekają.

ConcurrentHashMap rozwiązuje to sprytniej. We wcześniejszych wersjach mapa była podzielona na segmenty z oddzielnymi blokadami, w nowszych implementacjach wykorzystywane są lekkie operacje atomowe (CAS) na poziomie poszczególnych bucketów. Dzięki temu wątki mogą spokojnie pracować równolegle, o ile nie dotykają tych samych danych.

Przykład użycia ConcurrentHashMap

import java.util.concurrent.ConcurrentHashMap;

public class ChatStats {
    private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();

    public void increment(String user) {
        // Atomowo zwiększamy wartość
        userMessageCount.merge(user, 1, Integer::sum);
    }

    public int getCount(String user) {
        return userMessageCount.getOrDefault(user, 0);
    }
}

Co jest tutaj ważne:

  • Można wywoływać metody z różnych wątków – wszystko będzie poprawne.
  • Metoda merge jest atomowa: jeśli kilka wątków jednocześnie zwiększa licznik, wynik będzie prawidłowy.
  • Do odczytu nie jest potrzebna dodatkowa synchronizacja.

Czym ConcurrentHashMap jest lepszy od synchronizedMap?

Map<String, String> map = Collections.synchronizedMap(new HashMap<>());

Gdy używasz synchronizedMap, każda operacja – odczyt, zapis lub usunięcie – blokuje całą mapę. Dopóki jeden wątek pracuje na danych, pozostałe muszą czekać.

ConcurrentHashMap działa znacznie zgrabniej: pozwala wielu wątkom jednocześnie czytać, a nawet modyfikować dane, jeśli nie odwołują się do tych samych obszarów mapy (bucketów). W rezultacie w realnych systemach wielowątkowych pokazuje istotnie lepszą wydajność – czasem różnica sięga rzędu dziesiątek razy.

4. CopyOnWriteArrayList i CopyOnWriteArraySet

CopyOnWriteArrayList i CopyOnWriteArraySet to specjalne kolekcje, które przy każdej zmianie (np. przy wywołaniu add() lub remove()) tworzą nową kopię całej tablicy. Za to odczyt z nich odbywa się bez jakiejkolwiek synchronizacji i jest w pełni bezpieczny dla wątków.

Wyobraź sobie, że masz listę gości na imprezę. Za każdym razem, gdy ktoś przychodzi lub wychodzi, przepisujesz listę od nowa i rozdajesz wszystkim świeże kopie. Trochę rozrzutne, ale nikt się nie pogubi, kto jest aktualnie na miejscu.

Kiedy to naprawdę ma sens

  • Odczyty są częste, a zmiany rzadkie.
  • Klasyczny przypadek – lista nasłuchiwaczy zdarzeń: handlerów przybywa rzadko, za to zdarzenia spływają stale.

Przykład: nasłuchiwacze czatu

import java.util.concurrent.CopyOnWriteArrayList;

public class ChatRoom {
    private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();

    public void addListener(ChatListener listener) {
        listeners.add(listener);
    }

    public void removeListener(ChatListener listener) {
        listeners.remove(listener);
    }

    public void sendMessage(String message) {
        // Bezpieczne względem wielowątkowości, nawet jeśli ktoś właśnie się zapisuje/wyrejestrowuje
        for (ChatListener listener : listeners) {
            listener.onMessage(message);
        }
    }
}

Ważne cechy:

  • Iteracja po CopyOnWriteArrayList nigdy nie rzuci ConcurrentModificationException.
  • Zmiany (add/remove) są kosztowne czasowo i pamięciowo (kopiowana jest cała tablica!).
  • Nie należy stosować do dużych kolekcji z częstymi modyfikacjami.

5. Inne kolekcje thread-safe

ConcurrentLinkedQueue

ConcurrentLinkedQueue to nieblokująca kolejka działająca w trybie FIFO. Pozwala wielu wątkom jednocześnie bezpiecznie dodawać i pobierać elementy bez użycia jawnych blokad. Często stosowana do przekazywania zadań między wątkami – szybko i bez „zatorów”.

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // zwróci null, jeśli kolejka jest pusta

ConcurrentSkipListMap i ConcurrentSkipListSet

  • Odpowiedniki TreeMap i TreeSet bezpieczne dla wątków.
  • Elementy są zawsze posortowane.
  • Używane, gdy istotne jest utrzymanie porządku kluczy.
import java.util.concurrent.ConcurrentSkipListMap;

ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b

BlockingQueue i jego implementacje

  • Interfejs kolejki wspierającej operacje blokujące (czekanie, aż pojawi się/zwolni miejsce).
  • Implementacje: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue itd.
  • Używane w pulach wątków, we wzorcu „producent–konsument”.
import java.util.concurrent.ArrayBlockingQueue;

ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // Zablokuje, jeśli kolejka jest pełna
String t = blockingQueue.take(); // Zablokuje, jeśli kolejka jest pusta

6. Przykłady: bezpieczne operacje na kolekcjach

Przykład 1: Wątkowo bezpieczna mapa do zliczania wiadomości

import java.util.concurrent.ConcurrentHashMap;

ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();

// Wątek 1
messageCount.put("Anna", 1);
// Wątek 2
messageCount.put("Anna", messageCount.getOrDefault("Anna", 0) + 1); // Nieatomowe!

// Poprawnie (atomowo):
messageCount.merge("Anna", 1, Integer::sum);

Przykład 2: Iteracja po CopyOnWriteArrayList

import java.util.concurrent.CopyOnWriteArrayList;

CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Anton");
users.add("Maria");

for (String user : users) {
    System.out.println(user);
    users.remove(user); // Nie wyrzuci ConcurrentModificationException!
}
System.out.println(users); // []

Przykład 3: Kolejka zadań między wątkami

import java.util.concurrent.ConcurrentLinkedQueue;

ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

// Wątek-producent
queue.add("task-1");

// Wątek-konsument
String task = queue.poll(); // null, jeśli pusto

7. Przydatne niuanse

Kiedy (i po co) używać kolekcji thread-safe

Zastosowanie kolekcji thread-safe jest uzasadnione, gdy:

  • Ta sama kolekcja jest współdzielona między kilkoma wątkami.
  • Nie chcesz ręcznie synchronizować każdej operacji.
  • Ważne jest unikanie wyścigów danych i błędów spójności.

Typowe scenariusze:

  • Cache w systemie wielowątkowym (np. ConcurrentHashMap do przechowywania sesji użytkowników).
  • Kolejki zadań między wątkami (ConcurrentLinkedQueue, BlockingQueue).
  • Listy nasłuchiwaczy zdarzeń (CopyOnWriteArrayList).
  • Wielowątkowe przetwarzanie danych (np. styl MapReduce).

Ograniczenia i pułapki

  • Operacje na wielu elementach nie są atomowe. Konstrukcja if (!map.containsKey(k)) map.put(k, v) nie jest atomowa. Używaj putIfAbsent, computeIfAbsent, merge.
  • CopyOnWriteArrayList jest nieefektywna przy częstych zmianach. Przy dużych rozmiarach i częstych add/remove narzuty rosną lawinowo.
  • Iteracja po ConcurrentHashMap jest „słaba”. Przebieg daje słabo spójny obraz: możesz nie zobaczyć części równoległych zmian.
  • Kolekcje thread-safe nie rozwiązują wszystkich problemów synchronizacji. Jeśli logika dotyka kilku kolekcji/zmiennych naraz, potrzebna będzie zewnętrzna synchronizacja (synchronized, locki, klasy atomowe).

8. Typowe błędy przy pracy z kolekcjami thread-safe

Błąd nr 1: Oczekiwanie magii od kolekcji thread-safe. „Skoro kolekcja jest thread-safe, można robić, co się chce i nie myśleć o synchronizacji”. Niestety, sekwencje z kilku operacji (sprawdzenie + dodanie) nie są atomowe. Korzystaj ze specjalizowanych metod: putIfAbsent, compute, merge.

Błąd nr 2: Używanie CopyOnWriteArrayList do dużych i często zmienianych kolekcji. Nadaje się do list nasłuchiwaczy, ale przy 10 000+ elementów i częstych zmianach poniesiesz duże koszty pamięci i czasu.

Błąd nr 3: ConcurrentModificationException przy używaniu zwykłych kolekcji. Iterujesz po ArrayList lub HashMap, a inny wątek zmienia kolekcję – trafiasz na ConcurrentModificationException. Używaj kolekcji specjalizowanych lub ręcznie blokuj dostęp.

Błąd nr 4: Zapominanie o atomowości złożonych operacji. Jeśli trzeba zmienić kilka kolekcji naraz lub wykonać serię powiązanych działań – kolekcje thread-safe nie pomogą. Stosuj zewnętrzną synchronizację lub logikę transakcyjną.

Błąd nr 5: Błędy podczas iteracji po ConcurrentHashMap. Iteracja jest słabo spójna: nie można używać iteratora jako „migawki” stanu mapy. Aby uzyskać spójną migawkę, skopiuj dane do oddzielnej struktury.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION