Maciej Soliński
Wróć do bloga

Wątki wirtualne w Javie — więcej współbieżności, ale nie więcej procesora

Do czego służą wątki wirtualne, jak włączyć je w Spring Boot i dlaczego nadal potrzebujemy limitów zasobów.

Wątki wirtualne w Javie — więcej współbieżności, ale nie więcej procesora

Serwis odczytuje dane z bazy, odpytuje inne API i dopiero wtedy buduje odpowiedź. Przez dużą część życia żądania kod nie wykonuje obliczeń — czeka. W klasycznym modelu blokującym zajęty wątek platformowy może w tym czasie nie wykonywać użytecznej pracy.

Wątki wirtualne pozwalają obsługiwać wiele takich zadań przy mniejszym koszcie utrzymywania wątków. To szczególnie interesujące dla aplikacji, które chcą zachować prosty, sekwencyjny zapis kodu korzystającego z blokującego I/O.

Co jest wirtualne?

Wątek wirtualny jest zarządzany przez JVM i wykonuje się na wątku platformowym, nazywanym carrierem. Przy obsługiwanym blokowaniu JVM może odłączyć wątek wirtualny od carriera i wykorzystać go do innego zadania.

Wątki wirtualne stały się finalną funkcją w Javie 21. Nie należy tworzyć z nich klasycznej puli: sens tego modelu polega na uruchamianiu osobnego lekkiego wątku dla zadania. Ich cel i ograniczenia przedstawia JEP 444.

Nie przyspieszają mnożenia macierzy ani kompresji obrazu. Jeśli zadanie wykorzystuje CPU, nadal konkuruje o dostępne rdzenie. Przepustowość zadań czekających na I/O i szybkość pojedynczego obliczenia to dwa różne problemy.

Mały eksperyment w Java 21+

Poniższa kompletna klasa porównuje pulę 20 wątków z executorem wątków wirtualnych. Dwieście zadań czeka po 100 ms. sleep() zastępuje oczekiwanie na potrzeby demonstracji — nie mierzy bazy danych, HTTP ani wydajności produkcyjnego serwisu.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class VirtualThreadsDemo {
    public static void main(String[] args) throws Exception {
        long platformMs = run(Executors.newFixedThreadPool(20));
        long virtualMs = run(Executors.newVirtualThreadPerTaskExecutor());

        System.out.println("Platform threads: " + platformMs + " ms");
        System.out.println("Virtual threads:  " + virtualMs + " ms");
    }

    private static long run(ExecutorService executor) throws Exception {
        long started = System.nanoTime();
        try (executor) {
            List<Future<Integer>> results = new ArrayList<>();
            for (int i = 0; i < 200; i++) {
                results.add(executor.submit(() -> {
                    Thread.sleep(100);
                    return 1;
                }));
            }
            for (Future<Integer> result : results) {
                result.get();
            }
        }
        return (System.nanoTime() - started) / 1_000_000;
    }
}

Zapisz kod jako VirtualThreadsDemo.java i uruchom na JDK 21 lub nowszym:

java VirtualThreadsDemo.java

Oczekujemy, że niewielka pula podzieli zadania na partie, a executor wirtualny pozwoli większej liczbie zadań czekać jednocześnie. Ten eksperyment zmienia również dopuszczoną współbieżność. Nie dowodzi, że dowolna aplikacja będzie szybsza o określony mnożnik.

Kolejność prób, start JVM i obciążenie komputera wpływają na pomiar. Do rzetelnego porównania potrzebne są powtórzenia, rozgrzewka i rzeczywisty scenariusz I/O.

Jak włączyć je w Spring Boot?

W Spring Boot 3.2+ można włączyć obsługę właściwością, korzystając z Java 21+:

spring:
  threads:
    virtual:
      enabled: true
  main:
    keep-alive: true

keep-alive utrzymuje JVM przy życiu również wtedy, gdy pozostają tylko wątki daemon, co jest ważne np. w aplikacji opartej na harmonogramie. Ustawienie nie jest samo w sobie optymalizacją.

Konfiguracja wpływa na wspierane mechanizmy auto-konfiguracji; nie zamienia każdego własnego executora ani całego stosu reaktywnego na wątki wirtualne. Sprawdź zachowanie używanego serwera i własnych beanów. Punktem odniesienia jest dokumentacja SpringApplication.

synchronized: sprawdź wersję JDK

Porady dla Javy 21 często ostrzegają przed blokującymi operacjami wewnątrz synchronized, które mogą przypinać wątek wirtualny do carriera. W Javie 24 JEP 491 usunął ten powód pinningu związany z monitorami. Nie ma sensu automatycznie zastępować wszystkich monitorów przez ReentrantLock w nowszym JDK.

Nie oznacza to zniknięcia każdego przypadku pinningu; nadal trzeba uważać m.in. na odpowiednie ścieżki kodu natywnego. Długa sekcja krytyczna może też ograniczać współbieżność przez samą blokadę, nawet bez przypinania. Diagnozę należy prowadzić na wersji JDK używanej w produkcji.

Ogranicz zasób, a nie liczbę lekkich wątków

Jeśli baza ma 20 połączeń, utworzenie tysięcy wątków nie daje tysięcy równoległych zapytań. Zwiększa liczbę zadań czekających na zasób. Wątek jest lekki, ale jego dane, żądanie i odpowiedź nadal zajmują pamięć.

Potrzebne są limity operacji na zależnościach, timeouty oraz decyzja, co zrobić po osiągnięciu pojemności. Semafor może ograniczyć równoległy dostęp, ale sam nie rozwiązuje kwestii nieograniczonej kolejki oczekujących. Czasami lepiej szybko odrzucić nadmiar ruchu niż utrzymywać coraz większą kolejkę.

Wątki wirtualne są dobrym kandydatem do testu, gdy aplikacja ma wiele blokujących operacji I/O. Wynik oceniaj przy docelowym ruchu: nie tylko przez przepustowość, lecz także opóźnienia, błędy, pamięć i obciążenie zależności. Prostszy kod jest korzyścią, o ile cały system nadal mieści się w swoich limitach.