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 |
|---|---|---|
|
Mapy, cache, częsty dostęp | Wysoka wydajność, brak globalnego locka |
|
List, rzadkie zmiany, częsty odczyt | Szybki odczyt, wolne modyfikacje |
|
Set, rzadkie zmiany, częsty odczyt | Podobnie jak lista na Copy-On-Write |
|
Kolejka, FIFO | Szybka, nieblokująca, kolejki zadań |
|
Map z sortowaniem (NavigableMap) | Odpowiednik TreeMap bezpieczny dla wątków |
|
Set z sortowaniem | Odpowiednik TreeSet bezpieczny dla wątków |
|
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.
GO TO FULL VERSION