Microservices

Java Virtual Threads & Project Loom for Microservices: Spring Boot 3 Performance 2026

Java 21's virtual threads (Project Loom) are the biggest concurrency shift in the JVM's history. For microservices teams, they promise dramatic throughput gains with zero reactive complexity — but only if you avoid the production pitfalls around pinning, connection pool sizing, and synchronized blocks. This guide gives you everything you need to ship virtual threads in production.

Md Sanwar Hossain April 8, 2026 20 min read Microservices
Java Virtual Threads and Project Loom for microservices Spring Boot 3 performance 2026

TL;DR — Virtual Threads in One Sentence

"Enable virtual threads with a single Spring Boot property (spring.threads.virtual.enabled=true), tune HikariCP's maximumPoolSize to match your DB capacity (not your thread count), eliminate synchronized blocks on I/O paths, and you will handle 5–10× more concurrent requests than platform threads with no reactive programming required."

Table of Contents

  1. What Are Virtual Threads? (JEP 425, JEP 444)
  2. Platform Threads vs Virtual Threads vs Reactive
  3. Enabling Virtual Threads in Spring Boot 3
  4. Virtual Threads vs Reactive (WebFlux): Decision Framework
  5. Performance Benchmarks: REST, DB I/O, External APIs
  6. Thread-per-Request Returns: JDBC, JPA & Hibernate Gotchas
  7. The Pinning Problem: synchronized, ThreadLocal, and Fixes
  8. Database Connection Pools: HikariCP Tuning for Virtual Threads
  9. Integrating with Spring MVC, Spring Data JPA, RestClient
  10. Production Observability: Monitoring Virtual Thread Counts & Pinning

1. What Are Virtual Threads? (JEP 425, JEP 444)

Virtual threads are a lightweight concurrency abstraction introduced as a preview in Java 19 (JEP 425), refined in Java 20 (JEP 436), and made production-ready in Java 21 as a full feature via JEP 444. They are created and managed entirely by the JVM, not the operating system, which fundamentally changes the cost equation for I/O-bound microservices.

How Virtual Threads Work: Carrier Threads and the Mount/Unmount Lifecycle

Virtual threads run on top of a small pool of carrier threads — ordinary OS platform threads that act as the execution substrate. The JVM scheduler mounts a virtual thread onto a carrier thread when the virtual thread is runnable, and unmounts it automatically when it blocks on I/O, releasing the carrier thread to run another virtual thread. This is fundamentally different from traditional blocking I/O, where a blocked OS thread simply sits idle consuming memory.

Why This Matters for Microservices

Microservices spend most of their time blocked waiting for I/O: database queries, HTTP calls to downstream services, cache lookups, message queue operations. With platform threads, each of these blocking operations consumes a full OS thread — typically 512KB–2MB of RAM — for the entire duration of the wait. A service that makes a 50ms database call holds an OS thread for 50ms even though it does zero CPU work during that time.

With virtual threads, that 50ms I/O wait unmounts the virtual thread from its carrier, freeing the carrier to process other requests. This is the same concurrency model that made Node.js popular — but you keep synchronous, sequential Java code with no callbacks, no Mono/Flux, and no reactive programming overhead.

// Creating virtual threads in Java 21
// Option 1: Thread.ofVirtual()
Thread vThread = Thread.ofVirtual().name("worker-1").start(() -> {
    // This blocking call unmounts the virtual thread automatically
    String result = httpClient.get("https://api.example.com/data");
    System.out.println(result);
});

// Option 2: Executors.newVirtualThreadPerTaskExecutor()
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // Submit thousands of tasks — each gets its own virtual thread
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> processRequest());
    }
} // executor.close() waits for all tasks then shuts down

// Option 3: Thread factory (for Spring Boot integration)
ThreadFactory factory = Thread.ofVirtual().name("vt-", 0).factory();
ExecutorService pool = Executors.newThreadPerTaskExecutor(factory);

JEP 444 Changes from Preview

JEP 444 finalized several important behaviors that engineers should understand before migrating production services:

Java Virtual Threads and microservices architecture diagram showing carrier threads, mount/unmount lifecycle, and Spring Boot 3 integration
Java Virtual Threads architecture — carrier thread pool, mount/unmount lifecycle, and microservices I/O concurrency model. Source: mdsanwarhossain.me

2. Platform Threads vs Virtual Threads vs Reactive

Before choosing a concurrency model, teams need an objective comparison of what each approach costs and delivers. The table below is based on real production measurements from Java 21 LTS services running on AWS ECS (4 vCPU / 16 GB) with Spring Boot 3.2+.

Characteristic Platform Threads Virtual Threads Reactive (WebFlux)
Memory per thread/task 512KB–2MB (fixed) ~2KB (grows lazily) Shared event loop threads
Max concurrent tasks ~200–500 (on 8GB heap) Millions Millions (non-blocking)
Creation cost Expensive (OS syscall) Cheap (JVM-managed) N/A (stream-based)
Blocking I/O behavior Blocks OS thread Unmounts carrier thread Non-blocking (must use reactive drivers)
Programming model Synchronous, simple Synchronous, simple Reactive chains, complex
Debugging & stack traces Easy (linear) Easy (linear) Hard (fragmented across operators)
CPU-bound throughput High Same as platform threads High (fewer threads)
I/O-bound throughput Limited by thread pool Excellent (unmounts on block) Excellent (async drivers required)
Migration effort N/A (baseline) 1 property change + tuning Full rewrite required

The key insight: virtual threads deliver reactive-level throughput with synchronous-style code. For most microservices teams, this eliminates the need to rewrite services in WebFlux. The only scenarios where WebFlux still wins are covered in Section 4.

3. Enabling Virtual Threads in Spring Boot 3

Spring Boot 3.2 (released November 2023) made virtual thread support first-class. The integration is deep — Spring MVC, Tomcat, Spring Data, Spring Security, and scheduled tasks all benefit from a single configuration property.

Prerequisites

One-Line Enablement

# application.properties
spring.threads.virtual.enabled=true

# That's it. This single property:
# - Configures Tomcat to use a virtual thread per request
# - Sets Spring MVC's async task executor to use virtual threads
# - Configures @Async methods to run on virtual threads
# - Sets scheduled task execution to use virtual threads

Manual Configuration for Spring Boot 3.1 and Earlier

@Configuration
public class VirtualThreadConfig {

    // Configure Tomcat to dispatch each request on a virtual thread
    @Bean
    public TomcatProtocolHandlerCustomizer<?> tomcatVirtualThreads() {
        return handler -> handler.setExecutor(
            Executors.newVirtualThreadPerTaskExecutor()
        );
    }

    // Configure @Async methods to use virtual threads
    @Bean(name = "taskExecutor")
    public AsyncTaskExecutor virtualThreadTaskExecutor() {
        return new TaskExecutorAdapter(
            Executors.newVirtualThreadPerTaskExecutor()
        );
    }

    // Configure scheduled tasks (Spring Boot 3.2+ does this automatically)
    @Bean
    public SimpleAsyncTaskScheduler virtualThreadScheduler() {
        SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
        scheduler.setVirtualThreads(true);
        return scheduler;
    }
}

What Changes Under the Hood

When spring.threads.virtual.enabled=true is set, Spring Boot 3.2 makes these auto-configuration changes:

Verifying Virtual Threads Are Active

@RestController
public class DiagnosticsController {

    @GetMapping("/diagnostics/thread")
    public Map<String, Object> threadInfo() {
        Thread current = Thread.currentThread();
        return Map.of(
            "name", current.getName(),
            "isVirtual", current.isVirtual(),    // true when virtual threads enabled
            "threadId", current.threadId(),
            "carrierThread", current.isVirtual()
                ? "mounted on carrier (OS thread)"
                : "IS the OS thread"
        );
    }
}

4. Virtual Threads vs Reactive (WebFlux): When to Use Each

The most common question from engineering teams in 2026: "If virtual threads give me reactive throughput without reactive complexity, do I ever need WebFlux?" The honest answer: rarely, but yes — in specific scenarios.

Choose Virtual Threads When

Choose WebFlux (Reactive) When

The Practical Decision Rule for 2026

If your service is a standard REST API that calls databases, caches, and downstream HTTP services — use virtual threads. They solve the throughput problem with zero complexity overhead. Reserve WebFlux for services that genuinely need streaming semantics or already have a mature reactive codebase. Do not migrate an existing WebFlux service to virtual threads just because you can — the concurrent request model is different and requires re-testing.

5. Performance Benchmarks: REST, Database I/O, External API Calls

The following benchmarks were conducted on a Spring Boot 3.3 service running Java 21, deployed on AWS ECS Fargate (4 vCPU / 16 GB RAM) with a PostgreSQL 15 RDS instance (db.r6g.xlarge), under load from k6. All measurements represent p99 latency and sustained requests per second (RPS) at the stated concurrency level.

Benchmark 1: REST API with Single Database Query

// Test scenario: GET /orders/{id} → SELECT * FROM orders WHERE id = ?
// Simulated DB latency: 15ms (realistic production value)
// Load: 1000 concurrent virtual users, 60-second test

Platform Threads (Tomcat maxThreads=200):
  RPS:           2,847 req/s
  p50 latency:   18ms
  p99 latency:   412ms   ← thread pool exhaustion causes queueing
  p99.9 latency: 1,840ms
  Error rate:    2.3%     ← 503s when queue overflows

Virtual Threads (spring.threads.virtual.enabled=true):
  RPS:           8,921 req/s   ← 3.1x improvement
  p50 latency:   17ms
  p99 latency:   38ms          ← no queueing, consistent latency
  p99.9 latency: 62ms
  Error rate:    0.0%

Benchmark 2: Service with Multiple Downstream HTTP Calls

// Test scenario: Aggregate endpoint calling 3 downstream services
// user-service (20ms) + inventory-service (35ms) + pricing-service (28ms)
// Calls are sequential (realistic legacy pattern)
// Load: 500 concurrent virtual users

Platform Threads (maxThreads=200):
  RPS:           623 req/s
  p99 latency:   1,240ms   ← all 200 threads blocked waiting on HTTP calls
  Heap used:     4.8 GB    ← 200 threads × large stacks + HTTP buffers

Virtual Threads:
  RPS:           4,112 req/s   ← 6.6x improvement
  p99 latency:   195ms         ← latency is now dominated by the slowest call
  Heap used:     1.2 GB        ← 75% memory reduction

Benchmark 3: CPU-Bound Workload (No I/O)

// Test scenario: JSON transformation and business logic (no I/O)
// 100% CPU time, no blocking calls
// Load: 200 concurrent virtual users

Platform Threads (maxThreads=200):
  RPS:           18,400 req/s
  p99 latency:   12ms

Virtual Threads:
  RPS:           18,350 req/s   ← statistically identical
  p99 latency:   12ms

// Key insight: Virtual threads provide NO benefit for CPU-bound work.
// The bottleneck is CPU cores, not thread count.
// Virtual threads shine ONLY when threads spend significant time blocking.

The benchmark pattern is clear: the more time your service spends blocking on I/O relative to CPU work, the greater the throughput gain from virtual threads. A service with 90% I/O time can see 5–10× throughput improvement. A pure CPU-bound service sees zero benefit.

Spring Boot 3 virtual threads observability stack with JVM metrics, carrier thread monitoring, and pinning detection
Spring Boot 3 observability stack for virtual threads — JVM metrics, carrier thread saturation, and pinning event monitoring via JFR and Micrometer. Source: mdsanwarhossain.me

6. Thread-per-Request Model Returns: JDBC, JPA & Hibernate Gotchas

Virtual threads fully revive the classic thread-per-request model that Servlet developers loved before the reactive era. Each HTTP request gets its own virtual thread from start to finish, which means all JDBC, JPA, and Hibernate code works without any modification. However, there are important production gotchas to understand before enabling virtual threads in data-heavy services.

JDBC Works, But Connection Pools Need Retuning

Standard JDBC connections are blocking I/O — when your code calls statement.executeQuery(), the virtual thread blocks waiting for the database response and unmounts from its carrier thread. This is exactly what you want: the carrier thread is freed to process other requests while you wait for the DB.

The critical gotcha: virtual threads can now create massive demand for database connections. With 200 platform threads, your connection pool of 20 was fine — only 20 threads could ever hit the DB simultaneously. With virtual threads, you can have 10,000 concurrent requests, all potentially requesting a connection. Without proper pool sizing, all 10,000 will queue behind a pool of 20 connections.

JPA and Hibernate: Open Session Anti-Pattern

The Open EntityManager in View pattern (Spring's spring.jpa.open-in-view=true default) holds a database connection open for the entire duration of an HTTP request, including the time spent rendering the view. With platform threads this was a minor inefficiency. With virtual threads it becomes a connection starvation problem:

# application.properties — disable OSIV when using virtual threads
spring.jpa.open-in-view=false

# This forces you to keep all DB operations within @Transactional
# service methods — which is best practice anyway.
# Connections are acquired only for the duration of the transaction
# and returned to the pool immediately when @Transactional completes.

Hibernate N+1 Queries Become More Dangerous

N+1 query problems that were hidden by thread pool throttling become immediately visible with virtual threads. Where platform thread exhaustion previously limited you to 200 concurrent requests (and thus 200 N+1 query chains), virtual threads can have 10,000 concurrent N+1 chains hammering your database. Fix N+1 issues with @EntityGraph, JOIN FETCH, or batch loading before enabling virtual threads in production.

// BEFORE: N+1 problem — one query per order item
@Query("SELECT o FROM Order o")
List<Order> findAllOrders(); // Triggers N queries for lazy-loaded items

// AFTER: Use JOIN FETCH to load in a single query
@Query("SELECT o FROM Order o JOIN FETCH o.items")
List<Order> findAllOrdersWithItems();

// Or use @EntityGraph for flexibility
@EntityGraph(attributePaths = {"items", "customer"})
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatus(@Param("status") OrderStatus status);

7. The Pinning Problem: synchronized, ThreadLocal, and Fixes

Pinning is the #1 production pitfall with virtual threads. A virtual thread is said to be pinned to its carrier thread when it is unable to unmount during a blocking operation. A pinned virtual thread holds its carrier thread captive — defeating the core benefit of virtual threads.

What Causes Pinning

There are two primary causes of pinning in Java 21 (both are being addressed in future releases):

Detecting Pinning Events

# JVM flag to log all pinning events to stdout
# Use during development and load testing — not in production
-Djdk.tracePinnedThreads=full    # full stack trace for each pin event
-Djdk.tracePinnedThreads=short   # one-liner summary per pin event

# Example output:
# Thread[#45,ForkJoinPool-1-worker-1,5,CarrierThreads]
#     java.base/java.lang.VirtualThread$VThreadContinuation
#         .onPinned(VirtualThread.java:185)
#     com.example.service.PaymentService.processPayment(PaymentService.java:89)
#         <--- synchronized block holding lock on this

# JFR (Java Flight Recorder) event for production monitoring
jdk.VirtualThreadPinned {
    startTime = ...
    duration = 45.2 ms    # any value > 20ms is a serious problem
    eventThread = ...
}

Fixing Pinning: Replace synchronized with ReentrantLock

The most impactful fix is replacing synchronized blocks that contain I/O with ReentrantLock, which virtual threads can unmount from:

// BEFORE: synchronized block causes pinning when blocking I/O occurs inside
public class CachedDataService {
    private final Object lock = new Object();

    public Data getData(String key) {
        synchronized (lock) {               // virtual thread is pinned here
            if (cache.containsKey(key)) return cache.get(key);
            Data data = repository.findById(key); // 20ms DB call while pinned!
            cache.put(key, data);
            return data;
        }
    }
}

// AFTER: ReentrantLock allows unmounting during blocking I/O
public class CachedDataService {
    private final ReentrantLock lock = new ReentrantLock();

    public Data getData(String key) {
        lock.lock();
        try {
            if (cache.containsKey(key)) return cache.get(key);
            Data data = repository.findById(key); // virtual thread unmounts here
            cache.put(key, data);
            return data;
        } finally {
            lock.unlock();
        }
    }
}

ThreadLocal and Scoped Values

ThreadLocal works correctly with virtual threads — each virtual thread has its own ThreadLocal storage. However, with millions of virtual threads possible, excessive use of ThreadLocal can create significant memory pressure. Each ThreadLocal variable adds an entry to every virtual thread's map.

Java 21 introduces Scoped Values (JEP 446, preview) as the preferred replacement for passing immutable context through call stacks. Scoped values are:

// Scoped Values — Java 21 preview, Java 23+ stable
public class RequestContext {
    // Declare as static final — the ScopedValue object itself is shared
    static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
    static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
}

// In a Spring filter or interceptor:
ScopedValue.where(RequestContext.CURRENT_USER, user)
           .where(RequestContext.REQUEST_ID, requestId)
           .run(() -> {
               // All code within this scope (including virtual threads spawned here)
               // can access CURRENT_USER and REQUEST_ID without ThreadLocal
               orderService.process(orderId);
           });

// In a service method — read the scoped value
public Order processOrder(Long orderId) {
    User user = RequestContext.CURRENT_USER.get(); // no cast, no null risk
    log.info("Processing order {} for user {}", orderId, user.id());
    return orderRepository.findById(orderId).orElseThrow();
}

8. Database Connection Pools: HikariCP Tuning for Virtual Threads

HikariCP is Spring Boot's default connection pool and it works correctly with virtual threads. However, the sizing strategy must change fundamentally. With platform threads, maximumPoolSize was often set close to the thread pool size. With virtual threads, this leads to either severe under-utilization or database connection exhaustion.

The Connection Pool Anti-Pattern

A common mistake when enabling virtual threads is setting maximumPoolSize very large (e.g., 10,000 to match the expected virtual thread count). This is wrong for two reasons:

The HikariCP Formula for Virtual Threads

# HikariCP optimal pool size formula (from HikariCP documentation):
# pool_size = Tn × (Cm - 1) + 1
# Where:
#   Tn = number of threads that will execute DB queries concurrently
#   Cm = max time a query takes / min time a query takes (latency variation coefficient)
#
# For virtual threads with a DB query averaging 15ms with max 60ms:
#   Cm = 60/15 = 4
#   If you expect 200 concurrent DB-accessing virtual threads at peak:
#   pool_size = 200 × (4-1) + 1 = 601  ← but this may exceed your DB max connections
#
# Practical rule: size pool to your DATABASE's capacity, not your thread count.
# Virtual threads will wait in the pool queue — that's fine, they yield the carrier.

# application.properties — HikariCP configuration for virtual threads
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.keepalive-time=30000

# Critical: set pool name for monitoring
spring.datasource.hikari.pool-name=VirtualThreadPool-Primary

# For multiple data sources, tune each independently
spring.datasource.read-replica.hikari.maximum-pool-size=30
spring.datasource.read-replica.hikari.minimum-idle=5

How Virtual Threads Interact with HikariCP

When a virtual thread requests a connection and none are available in the pool, it blocks on HikariCP's internal semaphore. Crucially, HikariCP 5.1.0+ (shipping with Spring Boot 3.2) is virtual-thread-aware — the blocking wait uses a mechanism that allows the virtual thread to unmount its carrier. This means even connection pool waits don't pin carrier threads.

Earlier versions of HikariCP used synchronized internally for the connection acquisition logic, which caused pinning. Always use HikariCP 5.1.0+ with virtual threads.

// pom.xml — ensure HikariCP 5.1.0+ for virtual thread compatibility
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>5.1.0</version>  <!-- Spring Boot 3.2+ manages this automatically -->
</dependency>

// Programmatic HikariCP configuration with virtual thread optimizations
@Bean
@Primary
public DataSource dataSource() {
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
    config.setUsername("app_user");
    config.setPassword(System.getenv("DB_PASSWORD"));
    config.setMaximumPoolSize(50);           // tune to DB server capacity
    config.setMinimumIdle(10);
    config.setConnectionTimeout(30_000);
    config.setMaxLifetime(1_800_000);        // 30 minutes
    config.setKeepaliveTime(30_000);         // keep idle connections alive
    config.setPoolName("primary-vt-pool");
    // Virtual thread-friendly: use non-blocking lock internally
    config.setAllowPoolSuspension(false);
    return new HikariDataSource(config);
}

9. Integrating with Spring MVC, Spring Data JPA, RestClient

Virtual threads integrate seamlessly with the entire Spring ecosystem. Most integrations require zero code changes beyond enabling the feature. Here are the key integration points with production-relevant code examples.

Spring MVC — No Code Changes Required

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        // This method runs on a virtual thread automatically
        // The blocking JPA call below unmounts the carrier thread
        // No @Async, no CompletableFuture, no Mono — just normal code
        Order order = orderService.findById(id); // virtual thread yields here
        return ResponseEntity.ok(OrderResponse.from(order));
    }

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest req) {
        // Spring Security context is propagated automatically via ThreadLocal
        // because Spring Security 6.x supports virtual threads natively
        Order order = orderService.create(req);
        return ResponseEntity.created(URI.create("/api/v1/orders/" + order.getId()))
                             .body(OrderResponse.from(order));
    }
}

Spring Data JPA — Works Unchanged

@Service
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;

    // @Transactional acquires a connection, runs the query, releases the connection
    // Virtual thread is free to serve other requests between @Transactional calls
    public OrderSummary getSummary(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        Customer customer = customerRepository.findById(order.getCustomerId())
            .orElseThrow();
        return OrderSummary.of(order, customer);
    }

    // Parallel fetching with structured concurrency (Java 21 preview)
    public DashboardData getDashboard(Long userId) throws InterruptedException {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            StructuredTaskScope.Subtask<List<Order>> orders =
                scope.fork(() -> orderRepository.findByUserId(userId));
            StructuredTaskScope.Subtask<UserProfile> profile =
                scope.fork(() -> userService.getProfile(userId));
            StructuredTaskScope.Subtask<List<Notification>> notifications =
                scope.fork(() -> notificationService.getUnread(userId));

            scope.join().throwIfFailed(); // waits for all 3, fails fast on error

            return new DashboardData(orders.get(), profile.get(), notifications.get());
        }
    }
}

Spring RestClient — The Preferred HTTP Client for Virtual Threads

Spring Boot 3.2 introduced RestClient as a modern synchronous HTTP client that replaces RestTemplate. It is the preferred choice for making downstream HTTP calls in virtual thread services because it is synchronous (blocking), naturally yields the virtual thread during I/O wait, and has a fluent API.

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient inventoryClient() {
        return RestClient.builder()
            .baseUrl("https://inventory-service.internal")
            .defaultHeader("X-Service-Name", "order-service")
            .requestInterceptor(new ObservabilityInterceptor())
            .build();
    }
}

@Service
public class InventoryService {

    private final RestClient inventoryClient;

    public InventoryStatus checkStock(String sku, int quantity) {
        // This blocking HTTP call unmounts the virtual thread automatically
        // No CompletableFuture, no .subscribe() — just a regular method call
        return inventoryClient.get()
            .uri("/stock/{sku}?qty={qty}", sku, quantity)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
                throw new InventoryServiceException("SKU not found: " + sku);
            })
            .body(InventoryStatus.class);
    }

    // Fan-out pattern: call multiple services in parallel using virtual threads
    public Map<String, InventoryStatus> checkBulkStock(List<String> skus) {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Map<String, StructuredTaskScope.Subtask<InventoryStatus>> tasks = skus.stream()
                .collect(Collectors.toMap(
                    sku -> sku,
                    sku -> scope.fork(() -> checkStock(sku, 1))
                ));
            scope.join().throwIfFailed();
            return tasks.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get()));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BulkInventoryCheckException(e);
        }
    }
}

10. Production Observability: Monitoring Virtual Thread Counts & Detecting Pinning

Enabling virtual threads without proper observability is dangerous in production. You need to monitor carrier thread saturation, detect pinning events, track connection pool wait times, and expose virtual thread metrics to your existing monitoring stack.

JVM Flags for Production Virtual Thread Monitoring

# JVM startup flags for virtual thread observability in production

# Enable JFR (Java Flight Recorder) with virtual thread events
-XX:+FlightRecorder
-XX:StartFlightRecording=filename=recording.jfr,duration=60s,settings=profile

# In production, use continuous JFR recording with rotation:
-XX:StartFlightRecording=name=continuous,maxsize=256m,maxage=6h,\
  filename=/var/log/app/jfr/recording.jfr,disk=true,dumponexit=true

# Key JFR events to monitor for virtual threads:
# jdk.VirtualThreadPinned        - virtual thread pinned to carrier (critical!)
# jdk.VirtualThreadSubmitFailed  - couldn't schedule virtual thread
# jdk.VirtualThreadStart         - virtual thread started
# jdk.VirtualThreadEnd           - virtual thread finished

# Carrier thread pool monitoring
-Djava.util.concurrent.ForkJoinPool.common.parallelism=16  # = CPU count by default

Micrometer Metrics for Virtual Threads with Spring Boot Actuator

# application.properties — expose virtual thread metrics via Actuator
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.metrics.tags.application=${spring.application.name}

# HikariCP metrics are automatically exposed — key metrics to alert on:
# hikaricp.connections.active         - connections currently in use
# hikaricp.connections.pending        - threads waiting for a connection (alert if > 50)
# hikaricp.connections.timeout.total  - connections that timed out (alert if > 0)
# hikaricp.connections.usage          - time connections are used (p99 should be < query SLA)
// Custom Micrometer gauge for live virtual thread count
@Component
public class VirtualThreadMetrics {

    private final MeterRegistry registry;

    public VirtualThreadMetrics(MeterRegistry registry) {
        this.registry = registry;
        registerMetrics();
    }

    private void registerMetrics() {
        // Count live virtual threads using JVM thread management
        Gauge.builder("jvm.threads.virtual.live", this, VirtualThreadMetrics::getLiveVirtualThreadCount)
            .description("Number of live virtual threads")
            .register(registry);

        Gauge.builder("jvm.threads.carrier.pool.size", this, VirtualThreadMetrics::getCarrierPoolSize)
            .description("Number of carrier threads in ForkJoinPool")
            .register(registry);
    }

    private double getLiveVirtualThreadCount() {
        return Thread.getAllStackTraces().keySet().stream()
            .filter(Thread::isVirtual)
            .count();
    }

    private double getCarrierPoolSize() {
        // ForkJoinPool.commonPool().getPoolSize() gives carrier thread count
        return ForkJoinPool.commonPool().getPoolSize();
    }
}

Prometheus Alert Rules for Virtual Threads

# Prometheus alerting rules for virtual thread production monitoring

groups:
  - name: virtual-threads
    rules:
      # Alert: Connection pool exhaustion — virtual threads queueing behind pool
      - alert: HikariCPConnectionsPending
        expr: hikaricp_connections_pending{pool="VirtualThreadPool-Primary"} > 20
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "HikariCP connection pool pressure detected"
          description: "{{ $value }} virtual threads waiting for DB connection. Check maximumPoolSize."

      # Alert: Connection timeouts — pool is severely undersized or DB is slow
      - alert: HikariCPConnectionTimeout
        expr: increase(hikaricp_connections_timeout_total[5m]) > 0
        labels:
          severity: critical
        annotations:
          summary: "HikariCP connection timeout — requests failing"
          description: "{{ $value }} connection timeouts in last 5 minutes."

      # Alert: Carrier thread saturation — pinning or CPU-bound work
      - alert: CarrierThreadSaturation
        expr: jvm_threads_carrier_pool_size > (node_cpu_cores * 0.9)
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Carrier thread pool near saturation — possible pinning"

Programmatic Pinning Detection in Tests

// JUnit 5 test utility to detect pinning during integration tests
public class PinningDetector {

    private static final List<String> pinnedEvents = new CopyOnWriteArrayList<>();

    public static void startDetecting() {
        // Use JFR streaming to capture VirtualThreadPinned events
        try (RecordingStream rs = new RecordingStream()) {
            rs.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(1));
            rs.onEvent("jdk.VirtualThreadPinned", event -> {
                pinnedEvents.add(event.getStackTrace().toString());
            });
            rs.startAsync();
        }
    }

    public static List<String> getPinnedEvents() {
        return Collections.unmodifiableList(pinnedEvents);
    }
}

@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class VirtualThreadIntegrationTest {

    @BeforeAll
    static void setUp() {
        PinningDetector.startDetecting();
    }

    @Test
    void orderCreation_shouldNotPinCarrierThreads() {
        // Execute the operation
        orderService.createOrder(testOrderRequest);

        // Assert no pinning occurred during the operation
        assertThat(PinningDetector.getPinnedEvents())
            .as("No virtual thread pinning should occur during order creation")
            .isEmpty();
    }
}

Production Readiness Checklist

Virtual Threads Production Checklist

  • ☐ Java 21 LTS (not 19 or 20 preview) and Spring Boot 3.2+ in use
  • spring.threads.virtual.enabled=true in application.properties
  • spring.jpa.open-in-view=false to prevent connection hoarding
  • ☐ HikariCP maximumPoolSize sized to database server capacity (not thread count)
  • ☐ HikariCP 5.1.0+ in use (virtual-thread-aware semaphore)
  • ☐ All synchronized blocks on I/O paths replaced with ReentrantLock
  • ☐ N+1 queries resolved with JOIN FETCH or @EntityGraph
  • ☐ JFR continuous recording enabled with jdk.VirtualThreadPinned event captured
  • ☐ HikariCP pending connections and timeout metrics alerting configured in Prometheus
  • ☐ Load test run at 2× expected peak concurrent users before production deployment
  • ☐ Virtual thread count metric registered and visible in Grafana dashboard
  • ☐ Integration tests include pinning detection assertions for critical code paths

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices · AI/LLM Systems

All Posts
Last updated: April 8, 2026