1. Krótko o najważniejszym
Jesteś już nieco zaznajomiony z klasami z pakietu java.util.concurrent, szczególnie z ExecutorService. To taki „menedżer zadań”: wysyłasz do niego pracę (np. przez submit()), a on sam decyduje, kiedy i którym wątkiem ją wykonać. Zazwyczaj pod spodem działa pula wątków o stałym rozmiarze, która oszczędza zasoby i nie tworzy nowego wątku dla każdego zadania.
Jednak wraz z wirtualnymi wątkami wszystko się zmienia! Teraz możesz pozwolić sobie na luksus: dla każdego zadania — osobny wątek, i nie musisz się obawiać, że JVM „pęknie” z przejedzenia.
Nowy sposób: Executors.newVirtualThreadPerTaskExecutor()
W Javie 21 pojawił się nowy sposób utworzenia ExecutorService, który uruchamia każde zadanie w osobnym wirtualnym wątku:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Kluczowa różnica:
- Stare pule wątków (Executors.newFixedThreadPool, Executors.newCachedThreadPool) ograniczały liczbę równoczesnych zadań ze względu na kosztowność wątków systemu operacyjnego.
- Nowy wirtualny Executor jest niemal nieograniczony: dla każdego zadania — własny lekki wirtualny wątek.
Prosty przykład
Wyślijmy 10 zadań do wirtualnego Executora:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // przechwytujemy zmienną dla lambdy
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
Co się dzieje?
Każde zadanie zostanie uruchomione we własnym wirtualnym wątku i zobaczysz linie w stylu:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. Masowa równoległość: tysiące zadań — żaden problem!
Aby poczuć pełnię możliwości wirtualnych wątków, spróbujmy wysłać do ExecutorService nie 10, lecz, powiedzmy, 100_000 zadań. W klasycznych pulach byłoby to jak próba wciśnięcia słonia do lodówki: JVM szybko skończyłaby pamięć albo zaczęłaby strasznie zwalniać. Z wirtualnymi wątkami — jest inaczej!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// Dla przykładu — po prostu śpimy 1 ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // Nie wypisujemy, bo byłoby zbyt wiele linii!
});
}
executor.shutdown();
}
}
Uwaga: wypisywanie 100_000 wierszy na ekran to zły pomysł: konsola „zadławi się” szybciej niż wirtualne wątki. Lepiej albo nie pisać na konsolę wcale, albo wypisywać tylko pierwszych kilka zadań.
3. Jak działa newVirtualThreadPerTaskExecutor
W skrócie: ten ExecutorService tworzy nowy wirtualny wątek dla każdego zadania, które do niego wysyłasz. W odróżnieniu od stałej puli, nie ma tu kolejki zadań ani sztywnych ograniczeń liczby jednoczesnych wątków (poza limitami twojej JVM i sprzętu).
Architektonicznie:
- Wirtualne wątki są „mapowane” na niewielką pulę rzeczywistych wątków systemowych (carrier).
- JVM samodzielnie decyduje, kiedy i który wirtualny wątek uruchomić, wstrzymać i wznowić.
- Jeśli wątek się blokuje (np. podczas czytania pliku albo oczekiwania na sieć), JVM może „zamrozić” wirtualny wątek i zwolnić wątek nośny (carrier) dla innych zadań.
4. Przykład: obsługa wyników z Future
ExecutorService zwraca obiekt typu Future, jeśli zadanie zwraca wynik. Wszystko działa tak samo jak przy zwykłych wątkach:
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // Czekamy na wynik
executor.shutdown();
}
}
Wszystko znajome: można wysyłać zadania zwracające wartość, czekać na wynik przez get(), a wyjątki są obsługiwane standardowo.
5. Jak poprawnie zakończyć pracę Executor
Bardzo ważne jest, aby nie zapominać o zakończeniu pracy ExecutorService, żeby program nie zawisł (nawet jeśli wątki są wirtualne, a nie „prawdziwe”).
shutdown() i awaitTermination
executor.shutdown(); // Mówimy: nie przyjmujemy więcej zadań
executor.awaitTermination(1, TimeUnit.MINUTES); // Czekamy na zakończenie wszystkich zadań (maksymalnie 1 minuta)
Dlaczego to ważne?
Jeśli nie wywołasz shutdown(), to wirtualne wątki mogą dalej żyć i program nie zakończy się nawet po wykonaniu main(). To typowy błąd początkujących.
6. Przydatne niuanse
Porównanie: wirtualny Executor vs klasyczna pula wątków
| Klasyczna pula (newFixedThreadPool) | Wirtualny Executor (newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| Liczba wątków | Ograniczona rozmiarem puli | Jeden wirtualny wątek na zadanie, niemal bez limitu |
| Zadania w kolejce | Tak, jeśli wszystkie wątki są zajęte | Z reguły nie: zadanie od razu dostaje wątek |
| Koszt wątku | Wysoki (stos, zasoby systemu operacyjnego) | Bardzo niski (planowanie po stronie JVM) |
| Skalowalność | Ograniczona | Niemal nieograniczona |
| Do czego się nadaje | Zadania CPU-bound, ograniczony paralelizm | Zadania I/O-bound, masowy paralelizm |
Integracja z serwerami WWW
Współczesne serwery WWW (np. Tomcat, Jetty, Undertow) zaczynają już wspierać wirtualne wątki. Oznacza to, że można obsługiwać każde żądanie HTTP w osobnym wirtualnym wątku, nie bojąc się „zadławić” przy napływie użytkowników.
Zaleta: nie trzeba wymyślać złożonych schematów asynchronicznych z callbackami i CompletableFuture; kod staje się prostszy — można pisać zwykły, blokujący kod, a aplikacja i tak się skaluje.
Masowe testowanie i symulacja obciążenia
Wirtualne wątki świetnie nadają się do testów, w których trzeba „zasymulować” tysiące jednoczesnych użytkowników, żądań lub operacji. Na przykład test wysyłający 10_000 równoległych żądań do serwera, każde w swoim wirtualnym wątku.
Równoległe przetwarzanie plików i połączeń sieciowych
Jeśli aplikacja pracuje z dużą liczbą plików lub połączeń sieciowych, możesz obsługiwać każde połączenie w osobnym wirtualnym wątku, bez martwienia się o ręczne zarządzanie pulami.
7. Typowe błędy przy pracy z wirtualnymi Executorami
Błąd nr 1: zapomniano wywołać shutdown(). Jeśli nie zamkniesz Executora, program się nie zakończy — wirtualne wątki wciąż będą oczekiwać nowych zadań. W razie potrzeby dodaj awaitTermination(...).
Błąd nr 2: używanie wirtualnych wątków do ciężkich obliczeń. Wirtualne wątki nie przyspieszają zadań w pełni obciążających CPU. Dla zadań CPU-bound lepiej użyć stałej puli (Executors.newFixedThreadPool) i starannie dobrać jej rozmiar.
Błąd nr 3: ignorowanie wyjątków wewnątrz zadań. Jeśli zadanie rzuci wyjątek, nie trafi on do wątku głównego — obsługuj go przez Future (metoda get()) albo przez try/catch wewnątrz lambdy.
Błąd nr 4: mylenie starej i nowej składni/wersji JDK. Upewnij się, że używasz właściwej wersji JDK (Java 21+) i że IDE jest skonfigurowane do obsługi wirtualnych wątków. Konkretna metoda — Executors.newVirtualThreadPerTaskExecutor().
Błąd nr 5: poleganie na ThreadLocal do przekazywania kontekstu. Wirtualne wątki często są tworzone i niszczone; ThreadLocal może zachowywać się inaczej, niż oczekujesz. Do przekazywania kontekstu używaj ScopedValue (Scoped Values; więcej szczegółów — w następnej lekcji).
GO TO FULL VERSION