1. Nie wszystko, co można zrównoleglić, powinno być zrównoleglane
W Javie jest wiele sposobów na zrównoleglanie zadań. Ale „równoległość = zawsze szybciej” to jak myśleć, że jeśli dosypiesz do zupy więcej soli, będzie smaczniejsza: do pewnego momentu — tak, dalej — lepiej nie próbować.
ExecutorService świetnie się sprawdza, gdy masz jawne zadania, które trzeba uruchamiać i kontrolować: obsługa żądań, asynchroniczne ładowanie danych, niezależne obliczenia. Sam decydujesz, ile wątków będzie w puli, i zarządzasz cyklem życia zadań.
parallelStream — szybki sposób na zrównoleglanie przetwarzania kolekcji, gdy operacje są niezależne i bez efektów ubocznych. Opłacalny dla „ciężkich” kolekcji (dziesiątki tysięcy elementów i więcej).
ForkJoinPool — wybór dla zadań dobrze dzielących się na podzadania (divide & conquer): sortowanie, wyszukiwanie, agregacja dużych tablic. Używany wewnątrz parallelStream, ale można nim zarządzać bezpośrednio.
Nie stosuj równoległości „na wszelki wypadek”. Jeśli zadanie jest małe, narzuty na planowanie, przełączanie kontekstu i synchronizację mogą zjeść cały zysk.
Przykład: kiedy równoległość nie jest potrzebna
List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
.mapToInt(x -> x)
.sum(); // Równoleglanie dla 5 liczb — overkill!
2. Bezpieczeństwo wątkowe: unikamy wspólnych modyfikowalnych danych
W świecie równoległym głównym zagrożeniem są wyścigi danych (race conditions). Jeśli kilka wątków zmienia jedną zmienną, wynik może być nieprzewidywalny.
- Unikaj wspólnych zmiennych modyfikowalnych. Nawet wyrażenie takie jak counter++ nie jest atomowe.
- Używaj kolekcji thread-safe i operacji atomowych. Na przykład ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
- Bez efektów ubocznych w strumieniach równoległych. Nie modyfikuj zewnętrznych struktur z parallelStream.
Przykład złego kodu
List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // NIEBEZPIECZNE!
Tutaj result.add() nie jest bezpieczne wątkowo. Skutek — utracone elementy lub wyjątki.
Jak zrobić to poprawnie?
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
3. Wydajność: więcej wątków nie zawsze znaczy lepiej
Drobnych zadań nie opłaca się zrównolegliać. Jeśli praca trwa milisekundy, uruchomienie równoległe często tylko spowolni wykonanie z powodu narzutów.
Mierz wydajność. Do szybkich pomiarów nada się System.nanoTime():
long start = System.nanoTime();
// ... twój kod ...
long end = System.nanoTime();
System.out.println("Czas wykonania: " + (end - start) + " ns");
Do poważnych mikrobenchmarków używaj JMH (Java Microbenchmark Harness).
Przykład: porównanie strumienia sekwencyjnego i równoległego
List<Integer> bigList = IntStream.range(0, 1_000_000)
.boxed().collect(Collectors.toList());
long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();
System.out.println("Sekwencyjnie: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("Równolegle: " + (t3 - t2) / 1_000_000 + " ms");
Wypróbuj na swoim komputerze — zysk widać przy naprawdę dużych kolekcjach i ciężkich operacjach.
4. Obsługa błędów: nie ignoruj wyjątków w wątkach
Future i obsługa wyjątków
Jeśli uruchomiłeś zadanie przez ExecutorService.submit(), wyjątki nie „przeniosą się” automatycznie — trzeba je obsłużyć przez Future.get():
Future<Integer> future = executor.submit(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Ups!");
return 42;
});
try {
Integer result = future.get(); // może rzucić ExecutionException
} catch (ExecutionException e) {
System.err.println("Błąd w zadaniu: " + e.getCause());
}
ForkJoin i obsługa błędów
W ForkJoinPool wyjątki są „zapakowane” w zadanie. Przy wywołaniu join()/get() wypłyną:
ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
int result = pool.invoke(task);
} catch (Exception e) {
System.err.println("Błąd w ForkJoin: " + e);
}
Pamiętaj o obsłudze InterruptedException
Wiele metod (na przykład Future.get(), Thread.sleep()) może rzucić InterruptedException. Nie „połykaj” go — reaguj poprawnie: ustawiaj znacznik przerwania albo kończ zadanie.
5. Debugowanie i testowanie kodu równoległego
Logowanie i debugowanie
Błędy w kodzie równoległym są podstępne i często ujawniają się niestabilnie. Loguj z podaniem wątku: Thread.currentThread().getName(). To pomaga zrozumieć, kto i kiedy wykonuje kod.
W trudnych przypadkach użyj debuggera z obsługą wielowątkowości (na przykład IntelliJ IDEA). Tymczasowe Thread.sleep() czasem pomagają „złapać” rzadki wyścig danych.
Testowanie scenariuszy wielowątkowych
Wydziel osobne testy dla operacji równoległych i używaj narzędzi oczekiwania na warunki, na przykład Awaitility. Uruchamiaj takie testy wielokrotnie: niektóre problemy wychodzą dopiero przy 100‑nym lub 1000‑nym uruchomieniu.
6. Przydatne niuanse i wskazówki
Czytelność i utrzymywalność: pisz zrozumiały kod równoległy
- Dokumentuj. Komentuj złożone fragmenty i wybór narzędzi.
- Używaj wysokopoziomowych abstrakcji. Preferuj ExecutorService, parallelStream, ForkJoinPool zamiast ręcznego zarządzania wątkami.
- Unikaj „magii”. Nie komplikuj synchronizacji, jeśli można prościej.
Tabela: kiedy jakiego narzędzia użyć
| Scenariusz | Zalecane narzędzie |
|---|---|
| Wiele niezależnych zadań | |
| Przetwarzanie dużej kolekcji | parallelStream lub ForkJoin |
| Zadanie „dziel i rządź” | |
| Proste zadanie asynchroniczne | |
| Wiele małych zadań | Strumień sekwencyjny |
| Zadania z efektami ubocznymi | Tylko kolekcje thread-safe! |
„Przykazania” programisty równoległego
- Nie twórz wspólnych zmiennych modyfikowalnych — jeśli nie masz pewności, że są thread-safe.
- Nie zrównoleglaj dla samej równoległości: oceń potencjalny zysk.
- Nie zapominaj zamykać pul: shutdown()/shutdownNow().
- Nie używaj parallelStream do operacji z efektami ubocznymi.
- Nie zapominaj obsługiwać wyjątków z Future i ForkJoinTask.
- Nie „połykaj” InterruptedException — poprawnie kończ zadanie.
7. Typowe błędy w programowaniu równoległym
Błąd nr 1: Zrównoleglanie małych zadań. Początkujący często zrównoleglają wszystko, nawet gdy praca trwa mikrosekundy. Efekt — wolniej z powodu narzutów.
Błąd nr 2: Efekty uboczne w strumieniach. W parallelStream nie wolno modyfikować zmiennych zewnętrznych ani kolekcji — dostaniesz wyścigi danych i nieprzewidywalne błędy.
Błąd nr 3: Ignorowanie wyjątków. Jeśli nie obsłużysz błędów z Future.get() lub ForkJoinTask, nie dowiesz się, dlaczego zadanie się nie wykonało.
Błąd nr 4: Zapomniane shutdown() w ExecutorService. Bez jawnego zamknięcia aplikacja może „zawisnąć” przy wyjściu.
Błąd nr 5: Używanie kolekcji nie-thread-safe. Pisanie z wielu wątków do zwykłego ArrayList to prosta droga do błędów.
Błąd nr 6: „Połykanie” InterruptedException. Jeśli wątek został przerwany — uszanuj to i zakończ pracę poprawnie.
Błąd nr 7: Zbyt skomplikowana logika synchronizacji. Nadmierne bloki synchronized prowadzą do deadlock/livelock. Preferuj abstrakcje wyższego poziomu.
GO TO FULL VERSION