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
| Metrika | Virtual Threads (Tomcat) | WebFlux (Netty) |
|---|---|---|
| Concurrent users: 100 | Porovnateľné | Porovnateľné |
| Concurrent users: 1 000 | Porovnateľné | Mierne lepšie |
| Concurrent users: 10 000 | Veľmi dobré | Lepšie throughput |
| Concurrent users: 100 000+ | Degradácia | Stále stabilné |
| Memory per connection | ~few KB | ~few hundred bytes |
| Debugging | Štandardné nástroje | Nočná mora |
| Stack traces | Čitateľné | Nečitateľné |
| Learning curve | Nízka | Vysoká |
Kedy zvoliť Virtual Threads
1. Klasické CRUD microservices
// ✅ 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);
}
}// ❌ 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
# application.yml — zapnúť Virtual Threads je jedna property
spring:
threads:
virtual:
enabled: trueTo 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
// 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.
// 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
// ✅ 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
// ✅ 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
// ✅ 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)
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ 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:
// 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.
// ✅ 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í.
// ❌ 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);
});
});
}// ❌ 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!
}// ✅ 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:
// ✅ 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)
// ❌ 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é
// 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.
// ❌ 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:
// ❌ 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:
// ❌ 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ú:
// ✅ 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
| Faktor | Virtual Threads | WebFlux |
|---|---|---|
| CRUD REST API | ✅ Jasná voľba | Overkill |
| Existujúca MVC app | ✅ Jedna property | ❌ Plný rewrite |
| GraphQL Queries/Mutations | ✅ Jednoduché | Zbytočné |
| GraphQL Subscriptions | Možné, ale neprirodzené | ✅ Natívne |
| SSE / Streaming | Manuálne | ✅ Natívny backpressure |
| 100k+ WebSocket conn. | Možné | ✅ Efektívnejšie |
| Spring Security | ✅ Plná podpora | Obmedzená |
| Debugging | ✅ Štandardné | Bolestivé |
| Tím: Java seniori | ✅ Hneď produktívni | Learning curve |
| Blocking knižnice | ✅ Bez problémov | Schedulers 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.