1. Problem kodu synchronicznego
Wyobraźmy sobie: masz program, który musi pobrać dane z internetu albo odczytać duży plik. Piszesz coś w rodzaju:
String data = readFromFile("bigfile.txt");
System.out.println("Dane: " + data);
Wszystko byłoby dobrze, ale jeśli plik jest duży albo sieć wolna, program po prostu zawiesza się na linii odczytu. Użytkownik patrzy na „zawieszony” interfejs, serwer nie może obsługiwać innych żądań, a programista... smutnieje.
Taka sytuacja to blokada: wątek (na przykład główny wątek twojej aplikacji) jest zmuszony czekać, aż operacja się zakończy. A jeśli takich operacji jest dużo — witajcie lagi i niska wydajność.
To tak, jakbyś przyszedł do kawiarni, złożył zamówienie i... musiał stać przy ladzie, aż przygotują ci kawę. A inni klienci stoją za tobą i też czekają, aż barista skończy z tobą. Nieskuteczne, prawda?
Asynchroniczność: jak ratuje świat
Programowanie asynchroniczne to podejście, w którym długotrwałe operacje (np. odczyt pliku, zapytanie do serwera, dostęp do bazy danych) wykonują się w wątku w tle, a główny wątek pracuje dalej: obsługuje użytkowników, przyjmuje nowe żądania, reaguje na zdarzenia.
Czyli składasz zamówienie (uruchamiasz zadanie), idziesz zająć się swoimi sprawami, a kiedy kawa jest gotowa (zadanie się zakończy), po prostu słyszysz: „Gotowe!”
W Javie przed pojawieniem się CompletableFuture nie było to zbyt wygodne. Zobaczmy, jak to się rozwijało.
2. Podejścia historyczne: Future i jego ograniczenia
W Javie 5 pojawił się interfejs Future — pierwsza próba uczynienia pracy z zadaniami asynchronicznymi choć trochę wygodniejszą. Pozwalał zlecić zadanie puli wątków i kiedyś otrzymać wynik.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Uwaga: wątek zawiśnie, dopóki zadanie się nie wykona!
Pomysł niby dobry, ale w praktyce okazało się, że Future jest jak stara skrzynka pocztowa: list wysłałeś, ale żeby sprawdzić, czy przyszedł odpór, musisz ciągle zaglądać do środka.
Nie potrafi powiadomić, gdy wynik jest gotowy, nie wspiera łańcuchów działań w stylu „zrób to, a potem tamto”, nie pozwala elegancko obsługiwać błędów. Wszystko rozbija się o ten blokujący get(), przez który asynchroniczność znów zamienia się w czekanie.
3. Pojawienie się CompletableFuture: nowy styl asynchroniczności
W Javie 8 na miejsce przestarzałego Future wszedł prawdziwy bohater asynchroniczności — CompletableFuture. Ta klasa z pakietu java.util.concurrent stała się uniwersalnym narzędziem dla wszystkich, którzy mieli dość ręcznego czekania na wyniki i chcieli pisać kod asynchroniczny ładnie, zwięźle i jasno.
CompletableFuture potrafi niemal wszystko. Umie uruchamiać zadania w innych wątkach, układać z nich łańcuchy — np. najpierw policzyć wynik, potem go przetworzyć, a następnie zrobić coś jeszcze. Łatwo łączy też kilka zadań: można poczekać na zakończenie wszystkich naraz albo tylko pierwszego, które zdąży. Błędy obsługuje elegancko — bez zbędnych try-catch. Styl pracy staje się bardziej funkcyjny: zamiast nudnych wywołań i oczekiwania pojawiają się wyraziste metody, takie jak thenApply, thenAccept i inne.
flowchart LR
A[Asynchroniczne uruchomienie zadania] --> B[Przetwarzanie wyniku]
B --> C[Następna operacja]
C --> D[Obsługa błędów]
Tak CompletableFuture zamienił asynchroniczność z ciężkiego rzemiosła w wygodne i elastyczne narzędzie, z którym kod wreszcie oddycha pełnią.
4. Najprostszy przykład: pierwszy krok w świat CompletableFuture
Zobaczmy minimalny przykład zadania asynchronicznego:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// Uruchamiamy zadanie asynchronicznie
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// Pobieramy wynik (blokuje wątek!)
try {
int result = future.get();
System.out.println("Wynik: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
Ten kod już wykonuje obliczenie w osobnym wątku — główny wątek nie jest blokowany w momencie uruchomienia zadania. Ale wywołanie get() wciąż blokuje wątek, dopóki wynik nie będzie gotowy.
A jak NIE blokować wątku?
To proste: używaj metod-callbacków wywoływanych po zakończeniu zadania:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("Wynik: " + result));
System.out.println("Nie blokuję się i mogę robić coś jeszcze!");
Wniosek:
- thenAccept — to „zapisz się na wynik”: gdy zadanie się wykona, wywołaj ten kod.
- Główny wątek nie czeka na zakończenie zadania, tylko pracuje dalej.
Wizualizacja (pseudokod zdarzeń)
[Główny wątek] --> [Uruchomienie zadania]
| |
v v
[Robi coś] [Wątek w tle liczy 2+2]
| |
v v
[Wypisuje "Nie blokuję się..."]
| |
v v
[Gdy policzone — wywoływane jest thenAccept]
5. Jak to wygląda w aplikacji?
Wyobraź sobie, że tworzysz aplikację konsolową, w której użytkownik może zlecić ładowanie danych (np. z bazy albo z serwera), a w czasie ładowania program nie „zawiesza się”, tylko nadal przyjmuje polecenia.
Przykład: symulacja długiej operacji
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("Zaczynamy ładowanie danych...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// Symulacja długiego ładowania
try {
Thread.sleep(2000); // 2 sekundy
} catch (InterruptedException e) {
return "Błąd ładowania";
}
return "Dane zostały pomyślnie załadowane!";
});
// Subskrybujemy wynik
dataFuture.thenAccept(result -> System.out.println("Wynik: " + result));
// Program dalej działa
System.out.println("Gdy dane się ładują, mogę robić coś jeszcze!");
// Aby program nie zakończył się zbyt szybko (tylko do dema!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
Co zobaczysz w konsoli:
Zaczynamy ładowanie danych...
Gdy dane się ładują, mogę robić coś jeszcze!
[po 2 sekundach]
Wynik: Dane zostały pomyślnie załadowane!
6. Przydatne niuanse
Słowo o wątkach pod maską
Gdy piszesz CompletableFuture.supplyAsync(...), zadanie domyślnie wykonuje się w tzw. ForkJoinPool — to specjalna pula wątków, której Java używa do zadań równoległych. Jeśli potrzebujesz większej kontroli (np. własnego ExecutorService), możesz przekazać go jako drugi parametr:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
Ale dla prostych zadań wystarczy standardowa pula.
Pobieranie wyniku: get(), join(), thenAccept
- get() — blokuje wątek, dopóki wynik nie będzie gotowy (rzuca wyjątki typu checked).
- join() — także blokuje, ale rzuca wyjątki typu unchecked (RuntimeException).
- thenAccept(), thenApply() i inne — NIE blokują, tylko wywołują przekazaną funkcję, gdy wynik jest gotowy.
W rzeczywistych aplikacjach asynchronicznych staraj się unikać get()/join() w głównym wątku!
7. Typowe błędy przy pierwszych krokach z CompletableFuture
Błąd nr 1: używanie get() albo join() w głównym wątku.
W ten sposób znów blokujesz program i tracisz wszystkie zalety asynchroniczności. Zamiast tego używaj thenAccept, thenApply i innych metod do obsługi wyniku.
Błąd nr 2: zapomniana obsługa błędów.
Jeśli w zadaniu asynchronicznym wystąpi wyjątek, nie „wyskoczy” on do głównego wątku. Bez obsługi przez exceptionally lub handle po prostu nie dowiesz się, że coś poszło nie tak.
Błąd nr 3: program nie doczekał końca zadania.
W przykładach demo często trzeba „przyhamować” wątek main przez Thread.sleep — inaczej program zakończy się, zanim zadanie się wykona. W prawdziwych aplikacjach (np. na serwerach webowych) to nie problem, ale w demach konsolowych — weź to pod uwagę.
Błąd nr 4: mylenie thenAccept i thenApply.
thenAccept — do „skutków ubocznych” (nic nie zwraca), thenApply — do przekształcania wyniku (zwraca nowy wynik).
Błąd nr 5: mieszanie kodu asynchronicznego i synchronicznego bez potrzeby.
Jeśli zacząłeś pisać asynchronicznie — nie ściągaj z powrotem do synchroniczności przez get()/join(), chyba że to ostateczność (np. w testach).
GO TO FULL VERSION