Späť na blog

Java Virtual Threads vs Reactive: Kedy Zahodiť WebFlux za Project Loom

|
| java, virtual-threads, project-loom, webflux, reactive, spring-boot, performance

Chcel som, aby virtual threads boli strieborny naboj; boli len lepsim nozom. “Prepísali sme na WebFlux kvôli škálovateľnosti.” Teraz máte 10x zložitejší kód a developerov, ktorí ho nevedia debugovať.

Java 21 Virtual Threads sľubujú rovnakú škálovateľnosť s bežným blocking kódom. Je WebFlux konečne obsolete?

Testované na: Java 21, Spring Boot 3.2, WebFlux 6.1, PostgreSQL 16, 10k concurrent connections

Problém Škálovateľnosti

Platform Threads (Tradičné)

// Každý request = jeden OS thread
// OS thread = ~1MB stack + kernel overhead
// 10,000 concurrent requestov = 10GB RAM len pre stacky

@GetMapping("/blocking")
public String blocking() {
    // Thread blokovaný čakajúc na DB
    return repository.findById(1).getName();  // Thread pool: max 200 threads
}
// Výsledok: max 200 concurrent requestov, zvyšok vo fronte

Reactive (WebFlux)

// Non-blocking, event-driven
// Málo threadov zvláda veľa requestov

@GetMapping("/reactive")
public Mono<String> reactive() {
    return repository.findById(1)
        .map(Entity::getName);  // Non-blocking
}
// Výsledok: 10,000+ concurrent requestov možných
// Cena: Zložitý kód, ťažké debugovanie

Virtual Threads (Java 21)

// Ľahké thready, spravované JVM
// Blocking kód, non-blocking vykonávanie

@GetMapping("/virtual")
public String virtual() {
    // Vyzerá blocking, ale JVM odmontuje thread počas čakania
    return repository.findById(1).getName();  // Jednoduché!
}
// Výsledok: 10,000+ concurrent requestov s blocking kódom

Ako Virtual Threads Fungujú

Platform Thread vs Virtual Thread

Platform Thread:
┌─────────────────────────────────┐
│ OS Kernel Thread                │ ~1MB stack
│   ├── Tvoj kód                  │
│   └── Blokovaný na I/O (čaká)   │ Plytvanie!
└─────────────────────────────────┘

Virtual Thread:
┌─────────────────────────────────┐
│ Carrier Thread (platform)       │ Zdieľaný pool
│   ├── VT1: vykonáva kód         │
│   ├── VT2: vykonáva kód         │
│   └── ...                       │
└─────────────────────────────────┘
        ↓ Keď VT blokuje na I/O
┌─────────────────────────────────┐
│ VT odmontovaný z carrier        │
│ Carrier beží iný VT             │ Žiadne plytvanie!
│ VT namontovaný späť po I/O      │
└─────────────────────────────────┘

Povolenie Virtual Threads

// Spring Boot 3.2+
// application.yml
spring:
  threads:
    virtual:
      enabled: true

// Alebo programaticky
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreads() {
    return handler -> handler.setExecutor(
        Executors.newVirtualThreadPerTaskExecutor()
    );
}

Benchmark Setup

Testovacia Aplikácia

@RestController
public class BenchmarkController {

    @Autowired
    private JdbcTemplate jdbc;

    // Simuluje typické API: DB query + nejaké spracovanie
    @GetMapping("/api")
    public Response handle() {
        // 50ms DB query
        var data = jdbc.queryForObject(
            "SELECT pg_sleep(0.05), current_timestamp",
            (rs, i) -> rs.getTimestamp(2)
        );

        // 10ms CPU práca
        doSomeProcessing();

        return new Response(data);
    }
}

Load Test

// k6_benchmark.js
import http from 'k6/http';

export const options = {
    scenarios: {
        concurrent_users: {
            executor: 'constant-vus',
            vus: 10000,  // 10k concurrent
            duration: '5m',
        },
    },
};

export default function () {
    http.get('http://localhost:8080/api');
}

Výsledky: 10k Concurrent Connections

Platform Threads (Tomcat default: 200 threads)

Konfigurácia:
  server.tomcat.threads.max: 200

Výsledky:
  Throughput:       180 req/s
  Latency p50:      850ms
  Latency p99:      4,200ms
  Errors:           12% (connection timeout)
  Memory:           1.2GB

Problém: 200 threads, 10k requestov = 9,800 vo fronte

WebFlux + R2DBC

Konfigurácia:
  WebFlux s Netty
  R2DBC PostgreSQL driver

Výsledky:
  Throughput:       8,500 req/s
  Latency p50:      58ms
  Latency p99:      180ms
  Errors:           0.1%
  Memory:           850MB

Zložitosť: ~3x viac kódu, Mono/Flux všade

Virtual Threads + JDBC

Konfigurácia:
  spring.threads.virtual.enabled: true
  Štandardný JDBC (blocking driver)

Výsledky:
  Throughput:       7,200 req/s
  Latency p50:      62ms
  Latency p99:      195ms
  Errors:           0.1%
  Memory:           920MB

Zložitosť: Rovnaká ako tradičný blocking kód!

Súhrnná Tabuľka

MetrikaPlatform ThreadsWebFluxVirtual Threads
Throughput180 req/s8,500 req/s7,200 req/s
Latency p994,200ms180ms195ms
Zložitosť kóduJednoducháZložitáJednoduchá
DebugovanieĽahkéŤažkéĽahké
Memory1.2GB850MB920MB

Kedy Čo Použiť

Virtual Threads Vyhrávajú

// I/O-bound workloady s jednoduchou logikou
// Tradičné blocking knižnice (JDBC, RestTemplate)

@Service
public class OrderService {

    public Order createOrder(OrderRequest request) {
        // Všetky blocking volania, ale VT ich robí efektívne
        var inventory = inventoryClient.check(request);  // HTTP call
        var payment = paymentClient.charge(request);     // HTTP call
        var order = orderRepository.save(request);       // DB call
        emailService.send(order);                        // SMTP
        return order;
    }
}

WebFlux Vyhráva

// Streaming dát
// Backpressure požiadavky
// Už máte reactive ekosystém

@GetMapping(value = "/stream", produces = TEXT_EVENT_STREAM_VALUE)
public Flux<Event> stream() {
    return eventRepository
        .findAllAsStream()
        .delayElements(Duration.ofMillis(100))
        .onBackpressureBuffer(1000);
}

// Server-Sent Events, WebSocket s backpressure
// Virtual Threads toto nevedia elegantne

Platform Threads Vyhrávajú

// CPU-bound workloady
// Nízka konkurencia (< 200 concurrent requestov)
// Keď jednoduchosť je najdôležitejšia

@GetMapping("/compute")
public Result heavyComputation() {
    // CPU-intensive, nie I/O
    // Virtual threads tu nepomôžu
    return compute.intensive().operation();
}

Virtual Thread Gotchas

1. Pinning: synchronized Bloky

// ZLE: VT sa pripne na carrier thread
public synchronized void pinnedMethod() {
    database.query();  // Carrier thread blokovaný!
}

// DOBRE: Použi ReentrantLock
private final Lock lock = new ReentrantLock();

public void unpinnedMethod() {
    lock.lock();
    try {
        database.query();  // VT sa môže odmontovať
    } finally {
        lock.unlock();
    }
}

2. Ťažké Používanie Thread-Local

// Thread-locals fungujú ale môžu sa akumulovať
// Každý VT dostane vlastnú kópiu

// Zváž ScopedValue (Java 21 preview) pre request context
ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

ScopedValue.runWhere(CURRENT_USER, user, () -> {
    // user dostupný v tomto scope
    processRequest();
});

3. Pool-based Knižnice

// Niektoré knižnice poolujú thready interne
// Nemusia benefitovať z virtual threads

// Skontroluj kompatibilitu knižníc:
// - HikariCP: Funguje dobre
// - Apache HttpClient 5: Dobrá VT podpora
// - OkHttp: Funguje ale pinning v niektorých prípadoch
// - Lettuce (Redis): Reactive, nepotrebuje VT

Migračná Cesta

Z Platform Threads

# Krok 1: Povoľ virtual threads
spring:
  threads:
    virtual:
      enabled: true

# Krok 2: Zväčši connection pool
spring:
  datasource:
    hikari:
      maximum-pool-size: 100  # Potrebných viac connections

# Krok 3: Testuj pod záťažou
# Krok 4: Monitoruj pinning

Z WebFlux

// Postupná migrácia je možná
// Ponechaj reactive pre streaming endpointy

// Konvertuj jednoduché endpointy:
// Pred (WebFlux)
@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable Long id) {
    return userRepository.findById(id);
}

// Po (Virtual Threads + JDBC)
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

Monitoring Virtual Threads

JFR Events

# Nahrávaj virtual thread eventy
java -XX:StartFlightRecording=name=vt,settings=profile \
     -jar app.jar

# Analyzuj pinning eventy
jfr print --events jdk.VirtualThreadPinned recording.jfr

Prometheus Metriky

// Micrometer + VT metriky
@Bean
MeterBinder virtualThreadMetrics() {
    return registry -> {
        Gauge.builder("jvm.threads.virtual.count", () ->
            Thread.getAllStackTraces().keySet().stream()
                .filter(Thread::isVirtual)
                .count()
        ).register(registry);
    };
}

Checklist

## Migrácia na Virtual Threads

### Predpoklady
- [ ] Java 21+
- [ ] Spring Boot 3.2+
- [ ] Skontroluj kompatibilitu knižníc (JDBC drivers, HTTP clients)

### Konfigurácia
- [ ] Povoľ spring.threads.virtual.enabled
- [ ] Zväčši connection pool
- [ ] Prezri synchronized bloky (konvertuj na Lock)

### Testovanie
- [ ] Load test s cieľovou konkurenciou
- [ ] Monitoruj pinning (JFR eventy)
- [ ] Porovnaj latency/throughput s baseline

### Ponechaj Reactive Pre
- [ ] Streaming endpointy (SSE, WebSocket)
- [ ] Backpressure požiadavky
- [ ] Už reactive závislosti (R2DBC, WebClient)

Záver

Virtual Threads v Java 21 menia hru:

  1. Rovnaká škálovateľnosť ako WebFlux s blocking kódom
  2. Jednoduchšie debugovanie - stack traces fungujú normálne
  3. Ľahká migrácia - jedna konfiguračná zmena
  4. WebFlux stále vyhráva pre streaming a backpressure

Pre väčšinu CRUD API: Virtual Threads > WebFlux.


Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Java Virtual Threads vs Reactive: Kedy Zahodiť WebFlux za Project Loom". https://www.michal-drozd.com/sk/blog/java-virtual-threads-vs-reactive/ (Publikované 27. augusta 2025).