Obsah článku / Contents
Virtual Threads vs WebFlux: Prečo to nie je tak jednoznačné, ako si myslíte

Virtual Threads vs WebFlux: Nie je to čiernobiele

Od Java 21 sa na konferenciách, LinkedIne aj v tímových diskusiách opakuje rovnaký názor: "Virtual Threads vyriešili všetko, WebFlux je mŕtvy."

Nie je to pravda. A tento článok vysvetlí prečo.

Oba prístupy riešia ten istý problém — ako obsluhovať tisíce súčasných požiadaviek bez toho, aby server padol na kolená. Ale robia to fundamentálne iným spôsobom a každý má situácie, kde je lepšou voľbou.

Rýchly prehľad: Ako fungujú?

Virtual Threads (Project Loom, Java 21+)

Tradičné vlákno:
┌──────────────────┐
│   OS Thread       │  ← 1 MB stack, drahý context switch
│   (platform)      │  ← Počet limitovaný OS (~2000-5000)
└──────────────────┘

Virtual Thread:
┌──────────────────┐
│  Virtual Thread   │  ← ~Few KB stack, lacný
│  (JVM managed)    │  ← Počet: milióny
│       │           │
│  ┌────▼────┐      │
│  │ Carrier │      │  ← Keď VT blokuje (I/O), JVM ho "unmountne"
│  │ Thread  │      │     a carrier thread obslúži iný VT
│  └─────────┘      │
└──────────────────┘

Píšete klasický imperatívny kód (Thread.sleep(), blocking I/O), ale JVM za vás rieši efektivitu. Keď virtual thread narazí na blocking operáciu, JVM ho odpojí od carrier threadu a ten obslúži iné virtual thread.

WebFlux (Project Reactor)

Event Loop (Netty):
┌──────────────────────────────────┐
│  Event Loop Thread (1-N cores)   │
│                                  │
│  Request A ──▶ Handler ──▶ DB   │
│  Request B ──▶ Handler ──▶ API  │  ← Všetko non-blocking
│  Request C ──▶ Handler ──▶ DB   │  ← Žiadne čakanie
│                                  │
│  Callback ◀── DB Response A     │
│  Callback ◀── API Response B    │
└──────────────────────────────────┘

Píšete reaktívny kód s Mono<T> a Flux<T>. Nikdy neblokujete. Všetko je event-driven. Málo vlákien obsluhuje veľa requestov.

Porovnanie v číslach

MetrikaVirtual Threads (Tomcat)WebFlux (Netty)
Concurrent users: 100PorovnateľnéPorovnateľné
Concurrent users: 1 000PorovnateľnéMierne lepšie
Concurrent users: 10 000Veľmi dobréLepšie throughput
Concurrent users: 100 000+DegradáciaStále stabilné
Memory per connection~few KB~few hundred bytes
DebuggingŠtandardné nástrojeNočná mora
Stack tracesČitateľnéNečitateľné
Learning curveNízkaVysoká

Kedy zvoliť Virtual Threads

1. Klasické CRUD microservices

java
// ✅ Virtual Threads: Čistý, čitateľný kód
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public OrderDto getOrder(@PathVariable Long id) {
        // Blocking call — ale JVM to rieši za vás
        Order order = orderRepository.findById(id)
            .orElseThrow(() -> new NotFoundException("Order not found"));

        // Ďalší blocking call
        Customer customer = customerService.getCustomer(order.getCustomerId());

        // Synchrónna logika
        return OrderDto.from(order, customer);
    }
}
java
// ❌ WebFlux: Zbytočná komplexita pre tento use-case
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public Mono<OrderDto> getOrder(@PathVariable Long id) {
        return orderRepository.findById(id)
            .switchIfEmpty(Mono.error(new NotFoundException("Order not found")))
            .flatMap(order ->
                customerService.getCustomer(order.getCustomerId())
                    .map(customer -> OrderDto.from(order, customer))
            );
    }
}

Pre 90% enterprise aplikácií je Virtual Thread verzia čitateľnejšia, debugovateľnejšia a rovnako výkonná.

2. Existujúca Spring MVC aplikácia

yaml
# application.yml — zapnúť Virtual Threads je jedna property
spring:
  threads:
    virtual:
      enabled: true

To je všetko. Žiadny rewrite. Žiadna migrácia. Vaša existujúca MVC appka automaticky používa virtual threads.

3. Keď tím neovláda reaktívne programovanie

java
// WebFlux debugging: Čo sa stalo?
reactor.core.Exceptions$ErrorCallbackNotImplemented:
    java.lang.NullPointerException
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
    Error has been observed at the following site(s):
        *__checkpoint ⇢ Handler ...OrderController#getOrder(Long)
        |_ ...somewhere in the reactive pipeline...

vs.

java
// Virtual Threads debugging: Jasný stack trace
java.lang.NullPointerException: Cannot invoke "Customer.getName()"
    at com.app.service.OrderService.getOrder(OrderService.java:42)
    at com.app.controller.OrderController.getOrder(OrderController.java:28)

Ak váš tím má 2 roky skúseností s Spring MVC a 0 s reaktívnym programovaním, Virtual Threads sú jasná voľba.

Kedy zvoliť WebFlux

1. Streaming a Server-Sent Events

java
// ✅ WebFlux: Natívny streaming s backpressure
@GetMapping(value = "/stream/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<EventDto>> streamEvents() {
    return eventService.getEventStream()
        .map(event -> ServerSentEvent.<EventDto>builder()
            .id(String.valueOf(event.getId()))
            .event("update")
            .data(EventDto.from(event))
            .build()
        );
}

WebFlux má natívnu podporu pre backpressure — ak klient nestíha spracovávať dáta, server automaticky spomalí. S Virtual Threads by ste museli túto logiku implementovať manuálne.

2. GraphQL Subscriptions

java
// ✅ WebFlux: GraphQL subscriptions sú reaktívne zo svojej podstaty
@DgsSubscription
public Publisher<Notification> onNotification(
        @InputArgument String userId) {

    return notificationService.getNotificationStream(userId)
        .filter(n -> n.getUserId().equals(userId));
}

GraphQL subscriptions vracajú Publisher<T> — to je reaktívny typ. WebFlux s Netty je pre tento use-case natívne prostredie. S Virtual Threads by ste museli manuálne konvertovať medzi blocking a reactive worldom.

3. Masívny počet concurrent WebSocket connections

Ak obsluhujete 100 000+ persistent WebSocket connections (chat, real-time dashboard, gaming), WebFlux s Netty je stále efektívnejší. Event loop model nemá overhead unmounting/remounting virtual threadov.

4. Komplexné asynchrónne kompozície

java
// ✅ WebFlux: Elegantná kompozícia paralelných operácií
public Mono<DashboardDto> getDashboard(String userId) {
    Mono<UserProfile> profile = userService.getProfile(userId);
    Mono<List<Order>> orders = orderService.getRecentOrders(userId);
    Mono<WalletBalance> balance = walletService.getBalance(userId);
    Mono<List<Notification>> notifications = notificationService.getUnread(userId);

    return Mono.zip(profile, orders, balance, notifications)
        .map(tuple -> DashboardDto.builder()
            .profile(tuple.getT1())
            .orders(tuple.getT2())
            .balance(tuple.getT3())
            .notifications(tuple.getT4())
            .build()
        );
}

Mono.zip() spustí všetky 4 volania paralelne a počká na všetky. S Virtual Threads to tiež ide (cez StructuredTaskScope), ale Reactor operátory sú pre tento pattern zrelšie.

Pasti Virtual Threads

Virtual Threads nie sú strieborná guľka. Tu sú problémy, na ktoré narazíte:

1. Thread Pinning (synchronized bloky)

java
// ❌ PROBLÉM: synchronized blokuje carrier thread
public synchronized String getFromCache(String key) {
    // Virtual thread je "pinned" — carrier thread čaká
    return cache.get(key);
}

// ✅ RIEŠENIE: Použiť ReentrantLock
private final ReentrantLock lock = new ReentrantLock();

public String getFromCache(String key) {
    lock.lock();
    try {
        return cache.get(key);
    } finally {
        lock.unlock();
    }
}

Ak váš kód (alebo knižnice, ktoré používate) obsahujú synchronized bloky, virtual threads sa "prilepia" na carrier thread a stratíte výhodu. JDBC drivery a niektoré connection pools mali tento problém — skontrolujte verzie.

2. Connection Pool exhaustion

java
// ❌ PROBLÉM: 100 000 virtual threads, ale len 50 DB connections
// Výsledok: 99 950 threadov čaká na connection

// ✅ RIEŠENIE: Semaphore ako ochrana
private static final Semaphore DB_LIMITER = new Semaphore(50);

public Order getOrder(Long id) {
    DB_LIMITER.acquire();
    try {
        return orderRepository.findById(id).orElseThrow();
    } finally {
        DB_LIMITER.release();
    }
}

Virtual Threads umožňujú vytvoriť milióny vlákien, ale váš database connection pool má stále len 50-200 spojení. Bez limitovania zahltíte databázu.

3. ThreadLocal memory leak

java
// ❌ PROBLÉM: ThreadLocal s miliónom virtual threadov = memory leak
private static final ThreadLocal<ExpensiveObject> TL =
    ThreadLocal.withInitial(ExpensiveObject::new);

// ✅ RIEŠENIE: Použiť ScopedValue (Java 21+)
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

Pasti WebFlux

1. Spring Security @PreAuthorize

java
// ❌ PROBLÉM: @PreAuthorize nefunguje s reaktívnymi typmi v parametroch
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/users")
public Flux<UserDto> getUsers() {
    // Funguje — ale len pre jednoduché role-based kontroly
    return userService.getAllUsers();
}

// ❌ PROBLÉM: Komplexné expression-based kontroly
@PreAuthorize("@authService.canAccess(#id)")
@GetMapping("/orders/{id}")
public Mono<OrderDto> getOrder(@PathVariable Long id) {
    // authService.canAccess() musí vrátiť Mono<Boolean>?
    // Nie — @PreAuthorize očakáva synchronný boolean!
    // Musíte buď:
    // 1. Zavolať .block() v authService (anti-pattern)
    // 2. Prepísať security logiku do reaktívneho reťazca
    return orderService.getOrder(id);
}

V praxi to vyzerá takto:

java
// Workaround: Security logika priamo v reaktívnom reťazci
@GetMapping("/orders/{id}")
public Mono<OrderDto> getOrder(@PathVariable Long id) {
    return ReactiveSecurityContextHolder.getContext()
        .map(ctx -> ctx.getAuthentication())
        .flatMap(auth -> {
            // Manuálna autorizácia v reaktívnom reťazci
            if (!authService.canAccessOrder(auth, id)) {
                return Mono.error(new AccessDeniedException("Forbidden"));
            }
            return orderService.getOrder(id);
        });
}

Strata elegancie. Namiesto jednoduchej anotácie máte manuálny security kód rozsypaný po kontroléroch.

java
// ✅ Virtual Threads: @PreAuthorize funguje normálne
@PreAuthorize("@authService.canAccess(#id)")
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id) {
    return orderService.getOrder(id);
    // authService.canAccess() je synchronná metóda
    // Všetko funguje out-of-the-box
}

2. Stratený SecurityContext pri novom Mono reťazci

Toto je jedna z najzákernejších pastí WebFluxu. ReactiveSecurityContextHolder ukladá autentifikáciu do Reactor Context, ktorý sa propaguje cez reaktívny reťazec. Ale akonáhle vytvoríte nový, odpojený Mono — kontext sa stratí.

java
// ❌ PROBLÉM: SecurityContext sa stratí pri novom Mono
@GetMapping("/orders/{id}")
public Mono<OrderDto> getOrder(@PathVariable Long id) {
    return orderService.getOrder(id)
        .flatMap(order -> {
            // Chceme enrichnúť order dátami z iného servisu
            // Vytvoríme nový Mono — a SecurityContext je preč!
            Mono<EnrichmentData> enrichment = enrichmentService.enrich(order);

            return enrichment.map(data -> {
                // Tu už ReactiveSecurityContextHolder.getContext()
                // vráti EMPTY — autentifikácia sa stratila
                return OrderDto.from(order, data);
            });
        });
}
java
// ❌ PROBLÉM: Scheduler prepne vlákno, context sa nepropaguje
@GetMapping("/report")
public Mono<ReportDto> getReport() {
    return ReactiveSecurityContextHolder.getContext()
        .flatMap(ctx -> {
            String username = ctx.getAuthentication().getName();
            return reportService.generate(username);
        })
        .subscribeOn(Schedulers.boundedElastic());
        // ^ Scheduler prepne na iné vlákno
        // SecurityContext sa nepreniesol!
}
java
// ✅ WORKAROUND: Manuálna propagácia cez Reactor Context
@GetMapping("/orders/{id}")
public Mono<OrderDto> getOrder(@PathVariable Long id) {
    return ReactiveSecurityContextHolder.getContext()
        .flatMap(securityContext -> {
            Authentication auth = securityContext.getAuthentication();
            // Explicitne preniesť auth do každého sub-reťazca
            return orderService.getOrder(id)
                .flatMap(order -> enrichmentService.enrich(order, auth)
                    .map(data -> OrderDto.from(order, data))
                );
        });
}

V imperatívnom svete (Virtual Threads) tento problém neexistuje — SecurityContextHolder je ThreadLocal a funguje automaticky v celom request scope:

java
// ✅ Virtual Threads: SecurityContext je vždy dostupný
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id) {
    // SecurityContextHolder.getContext() funguje kdekoľvek
    Order order = orderService.getOrder(id);
    EnrichmentData data = enrichmentService.enrich(order);
    return OrderDto.from(order, data);
    // Žiadne manuálne prenášanie kontextu
}

3. Blocking knižnice v reactive pipeline (pokračovanie)

java
// ❌ PROBLÉM: Volanie blocking kódu v WebFlux
@GetMapping("/report")
public Mono<ReportDto> generateReport() {
    return Mono.fromCallable(() -> {
        // Toto blokuje event loop thread!
        byte[] pdf = pdfGenerator.generate();  // blocking I/O
        String url = s3Client.upload(pdf);      // blocking I/O
        return new ReportDto(url);
    }).subscribeOn(Schedulers.boundedElastic()); // ← Nutný workaround
}

Ak čo i len jedna knižnica v reťazci je blocking (JDBC, niektoré SDK, PDF generátory), musíte ju obaľovať do Schedulers.boundedElastic(). V praxi má väčšina reálnych projektov minimálne 2-3 blocking závislosti.

4. Testovanie je bolestivé

java
// Virtual Threads: Normálny test
@Test
void shouldGetOrder() {
    Order order = orderService.getOrder(1L);
    assertEquals("Pizza", order.getName());
}

// WebFlux: StepVerifier ceremony
@Test
void shouldGetOrder() {
    StepVerifier.create(orderService.getOrder(1L))
        .assertNext(order -> assertEquals("Pizza", order.getName()))
        .verifyComplete();
}

5. @Scheduled — keď cron job stretne reaktívny svet

@Scheduled tasky bežia na obyčajnom thread poole — nie v reaktívnom kontexte. V WebFlux projekte to vytvára nepríjemný impedance mismatch.

java
// ❌ PROBLÉM: @Scheduled je synchronný, ale vaše servisy vracajú Mono
@Scheduled(fixedRate = 60_000)
public void cleanupExpiredSessions() {
    // sessionService vracia Mono — ale @Scheduled je synchronný!

    // Varianta A: .block() — anti-pattern, ale funguje
    sessionService.deleteExpired().block();

    // Varianta B: .subscribe() — chyby sa ticho prehltia!
    sessionService.deleteExpired().subscribe();
    // Ak deleteExpired() zlyhá, nikto sa nedozvie...
}

Ešte horšie — SecurityContext v scheduled tasku neexistuje:

java
// ❌ PROBLÉM: Scheduled task nemá žiadny SecurityContext
@Scheduled(cron = "0 0 2 * * *")
public void generateDailyReport() {
    // ReactiveSecurityContextHolder.getContext() → EMPTY
    // Kto ste? Neviem. Nemáte autentifikáciu.
    reportService.generateAsAdmin().block(); // Kto je "admin"?
}

A .subscribe() bez čakania na výsledok prináša ďalší problém — prekrývanie tickov:

java
// ❌ PROBLÉM: .subscribe() nečaká na dokončenie
@Scheduled(fixedRate = 30_000)
public void syncInventory() {
    inventoryService.syncAll()
        .subscribe(
            result -> log.info("Synced: {}", result),
            error -> log.error("Sync failed", error)  // Ľahko zabudnúť
        );
    // Metóda skončí HNEĎ — nečaká na výsledok
    // Ak nasledujúci tick príde skôr než dobehne predošlý → chaos
}

S Virtual Threads tieto problémy neexistujú:

java
// ✅ Virtual Threads: Klasický @Scheduled, žiadne problémy
@Scheduled(fixedRate = 60_000)
public void cleanupExpiredSessions() {
    sessionService.deleteExpired(); // Synchronné, blocking, funguje
}

@Scheduled(cron = "0 0 2 * * *")
public void generateDailyReport() {
    // Nastavíme systémovú identitu pre scheduled tasky
    SecurityContextHolder.getContext()
        .setAuthentication(systemAuthentication);
    reportService.generateAsAdmin(); // Funguje, čaká na výsledok
}

Rozhodovacia matica

FaktorVirtual ThreadsWebFlux
CRUD REST API✅ Jasná voľbaOverkill
Existujúca MVC app✅ Jedna property❌ Plný rewrite
GraphQL Queries/Mutations✅ JednoduchéZbytočné
GraphQL SubscriptionsMožné, ale neprirodzené✅ Natívne
SSE / StreamingManuálne✅ Natívny backpressure
100k+ WebSocket conn.Možné✅ Efektívnejšie
Spring Security✅ Plná podporaObmedzená
Debugging✅ ŠtandardnéBolestivé
Tím: Java seniori✅ Hneď produktívniLearning curve
Blocking knižnice✅ Bez problémovSchedulers workaround
@Scheduled / Cron jobs✅ Natívne.block() alebo .subscribe()
CPU-bound operácie❌ Nie je výhoda❌ Nie je výhoda

Záver: Nie je to "buď-alebo"

Najlepšia odpoveď na otázku "Virtual Threads alebo WebFlux?" je: záleží na use-case.

Zvoľte Virtual Threads ak budujete klasickú business aplikáciu, máte existujúci Spring MVC stack, alebo váš tím nemá skúsenosti s reaktívnym programovaním. Pre 90% enterprise aplikácií je to správna voľba v 2026.

Zvoľte WebFlux ak potrebujete streaming, GraphQL subscriptions, masívny počet persistent connections alebo sofistikované reaktívne pipeline s backpressure. Pre tieto use-casy je WebFlux stále lepší nástroj.

A ak máte microservice architektúru? Použite oba. CRUD služby na Virtual Threads, streaming/notification služby na WebFlux. Žiadne pravidlo nehovorí, že celá platforma musí bežať na jednom frameworku.

Všetky články