DEV Community

AnkitDevCode
AnkitDevCode

Posted on

Java Virtual Threads — Quick Guide

Java Virtual Threads — Quick Guide

Java 21+ · Spring Boot 3.2+ · Project Loom

A concise, production-focused guide to Java Virtual Threads — what they are, how to enable them, when to use them, and the real-world pitfalls that can silently hurt performance.


01 · What Are Virtual Threads

Before Project Loom, there is only one type of threads in Java, which is called platform thread in Project Loom. Platform threads are typically mapped 1:1 to kernel threads scheduled by the operating system. In Project Loom, virtual threads are introduced as a new type of threads.

Virtual threads are typically user-mode threads scheduled by the Java runtime rather than the operating system. Virtual threads are mapped M:N to kernel threads.

Platform and virtual threads are both represented using java.lang.Thread

  • Extremely lightweight compared to platform (OS) threads.
  • Millions of virtual threads can be created safely.
  • Allow developers to write simple, blocking-style code while remaining highly scalable.

How to create virtual threads?

The first approach to create virtual threads is using the Thread.ofVirtual method.

In the code below, a new virtual thread is created and started. The return value is an instance of java.lang.Thread object.

var thread = Thread.ofVirtual().name("My virtual thread")
    .start(() -> System.out.println("I'm running"))
Enter fullscreen mode Exit fullscreen mode

The second approach is using Thread.startVirtualThread(Runnable task) method. This is the same as calling Thread.ofVirtual().start(task).

The third approach is using ThreadFactory.

var factory = Thread.ofVirtual().factory();
var thread = factory.newThread(() -> System.out.println("Create in factory"));
Enter fullscreen mode Exit fullscreen mode

How to check if a thread is virtual?

The new isVirtual() method in java.lang.Thread returns true is this thread is a virtual thread.

Can virtual threads be non-daemon threads?

No. Virtual threads are always daemon threads. So they cannot prevent JVM from terminating. Calling setDaemon(false) on a virtual thread will throw an IllegalArgumentException exception.

Should virtual threads be pooled?

No. Virtual threads are light-weight. There is no need to pool them.

Can virtual threads support thread-local variables?

Yes. Virtual threads support both thread-local variables (ThreadLocal) and inheritable thread-local variables (InheritableThreadLocal).

Can virtual threads support thread-local variables?

Yes. Virtual threads support both thread-local variables (ThreadLocal) and inheritable thread-local variables (InheritableThreadLocal).

Can ExecutorService use virtual threads?

An ExecutorService can start a virtual thread for each task. This kind of ExecutorServices can be created using Executors.newVirtualThreadPerTaskExecutor() or Executors.newThreadPerTaskExecutor(ThreadFactory threadFactory) methods. The number of virtual threads created by the Executor is unbounded.

In the code below, a new ExecutorService is created to use virtual threads. 10000 tasks are submitted to this ExecutorService.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
  IntStream.range(0, 10_00).forEach(i -> executor.submit(() -> {
    Thread.sleep(Duration.ofSeconds(1));
    return i;
  }));
}
Enter fullscreen mode Exit fullscreen mode

Blocking comparison

OS Thread blocks

  • RestTemplate blocks an OS thread
  • Thread is idle during I/O
  • Under load → thread exhaustion

Virtual Thread blocks

  • JVM suspends the virtual thread
  • Carrier thread is released immediately
  • Scales safely under high concurrency

Why we need virtual threads?


Enable in Spring Boot

One property. No code changes.

# application.yml
server:
  servlet:
    threads:
      virtual-threads-enabled: true
Enter fullscreen mode Exit fullscreen mode

Requirements

  • Java 21+ (final, not preview)
  • Spring Boot 3.2+

What changes

  • Each HTTP request runs on a fresh virtual thread
  • Controllers, services, RestTemplate → unchanged

What virtual-threads-enabled: true Actually Does in Spring

It replaces Tomcat’s entire servlet thread pool with a virtual-thread-per-request executor. Every incoming HTTP request is immediately assigned a new virtual thread. There is no fixed pool size — Tomcat doesn’t cap anything. The JVM manages it all.

This means your entire request lifecycle — from the moment the request hits the DispatcherServlet to the moment the response is written — runs on a virtual thread. Every blocking call inside that chain (RestTemplate, JDBC, Thread.sleep()) is automatically cheap because it’s already on a virtual thread.


The Manual Offload Approach

Tomcat’s default OS thread pool handles accept and dispatch, then the work is explicitly handed off to a virtual thread executor. For example, CompletableFuture.supplyAsync() offloads work to the virtual thread executor. The OS thread that accepted the request is released immediately.

Spring MVC knows how to handle a returned CompletableFuture:

  • It suspends servlet processing
  • It resumes when the future completes

The key point: Spring MVC does not block the servlet thread waiting for the future. It registers a callback internally and frees the thread immediately.


Service and Client Layers Remain Unchanged

The service and client layers stay completely unchanged in both approaches.


Virtual Threads Adoption Strategy in Spring Boot

Context

The existing Spring Boot microservice handles incoming HTTP requests using Spring MVC (Servlet stack) and communicates with multiple downstream services using blocking clients such as RestTemplate and JDBC.

Key constraints and characteristics:

  • The current implementation cannot be changed or rewritten.
  • The codebase contains:
    • synchronized blocks and methods
    • Heavy reliance on ThreadLocal (SecurityContext, MDC, request attributes)
  • The service performs I/O-heavy aggregation across multiple downstream services.
  • Scalability issues arise due to thread blocking under load.

The goal is to improve concurrency and scalability using Java Virtual Threads, without breaking existing behavior or introducing subtle runtime risks.

Two approaches are available in Spring Boot:

  1. Property-based virtual threads (spring.threads.virtual.enabled=true)
  2. Manual offloading to a virtual-thread executor using CompletableFuture

Option 1: Property-Based Virtual Threads (Global Enablement)

Description

Enabling:

(spring.threads.virtual.enabled=true)

replaces Tomcat's servlet thread pool with a virtual-thread-per-request executor.

Each HTTP request:

  • Is assigned a fresh virtual thread
  • Runs entirely on that virtual thread (filters → controllers → services → response)
  • Executes blocking calls cheaply (RestTemplate, JDBC, Thread.sleep)

Benefits

  • Zero code changes
  • Uniform behavior across the entire application
  • Automatic scalability for blocking I/O

Risks and Limitations

Pinning Risk

  • synchronized blocks pin carrier threads
  • Pinning is invisible and global
  • Concurrent access can exhaust the small carrier thread pool
[Virtual Thread]

↓

synchronized block  ← carrier pinned

↓

blocking I/O
Enter fullscreen mode Exit fullscreen mode

If N concurrent requests enter pinned sections, N carrier threads are required. With only ~CPU-count carriers available, the application can stall.

ThreadLocal Assumptions Break

  • Virtual threads are short-lived
  • No thread reuse across requests
  • ThreadLocal data does not persist beyond a single request

This breaks assumptions made by existing code that was written for pooled OS threads.

ThreadLocal Abuse: If your project stores massive objects in ThreadLocal, you might run into memory issues because you could suddenly have 100,000 threads instead of 200.

Lack of Control

  • No isolation boundary
  • No way to selectively exclude endpoints or code paths
  • Fixing issues requires rewriting synchronized and ThreadLocal-dependent code

Option 2: Manual Offload to Virtual Threads (Selective Adoption)

Description

  • Tomcat continues to use its default OS-thread servlet pool
  • Existing code runs unchanged on OS threads
  • I/O-heavy logic is explicitly offloaded using:

CompletableFuture.supplyAsync(task, virtualThreadExecutor)

Spring MVC:

  • Natively supports CompletableFuture return types
  • Suspends request processing
  • Releases the servlet thread immediately
  • Resumes when the future completes

ThreadLocal Considerations

Offloading creates a hard thread boundary.

Context such as:

  • SecurityContext
  • MDC tracing data

is not propagated automatically and must be captured and restored manually.

This boundary is explicit and controlled.


Benefits

  • Preserves existing assumptions (synchronized, ThreadLocal)
  • Avoids carrier-thread pinning in legacy code
  • Allows targeted use of virtual threads only where beneficial
  • Enables incremental migration
  • Clear isolation between OS-thread and virtual-thread execution

Trade-offs

  • Slightly more boilerplate
  • Requires explicit context propagation
  • Virtual thread usage must be consciously applied

Consequences

Positive

  • Improved scalability for I/O-heavy endpoints
  • No need to refactor existing synchronized code
  • Predictable runtime behavior
  • Clear migration path

Negative

  • Additional boilerplate for context propagation
  • Requires discipline to maintain offload boundaries

Final Verdict

The property-based virtual thread approach is suitable only for codebases that are already virtual-thread-friendly.

For this system, manual offloading is the safest and most effective strategy, delivering the benefits of virtual threads while preserving correctness and operational stability.


Pitfalls (Read Before Production)

synchronized pins carrier threads

Problem

  • Virtual thread becomes glued to the carrier
  • 9 concurrent requests + 8 carriers → deadlock

Fix: use ReentrantLock

// Pins carrier
public synchronized Product fetch(String id) {
    return restTemplate.getForObject("/p/{id}", Product.class, id);
}

// Safe
private final ReentrantLock lock = new ReentrantLock();

public Product fetch(String id) {
    lock.lock();
    try {
        return restTemplate.getForObject("/p/{id}", Product.class, id);
    } finally {
        lock.unlock();
    }
}
Enter fullscreen mode Exit fullscreen mode

ThreadLocal context loss during manual offload

What breaks

  • MDC
  • SecurityContext
  • RequestAttributes

Fix: capture & restore context

Map<String, String> mdc = MDC.getCopyOfContextMap();
SecurityContext sec = SecurityContextHolder.getContext();

return CompletableFuture.supplyAsync(() -> {
    if (mdc != null) MDC.setContextMap(mdc);
    SecurityContextHolder.setContext(sec);
    try {
        return service.doWork();
    } finally {
        MDC.clear();
        SecurityContextHolder.clearContext();
    }
}, ioExecutor);
Enter fullscreen mode Exit fullscreen mode

💡 This issue does not exist when using virtual-threads-enabled: true globally.


ThreadLocal leaks with pooled executors

Problem

  • Someone replaces the executor with a fixed pool
  • ThreadLocals leak across requests

Fix: enforce correct executor

@Bean
public Executor ioExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}
Enter fullscreen mode Exit fullscreen mode

Native (JNI) calls pin silently

Examples

  • Some JDBC drivers
  • Crypto libraries

Contain the damage

private static final ExecutorService NATIVE_POOL =
    Executors.newFixedThreadPool(10);

public Future<Result> callNative(String input) {
    return NATIVE_POOL.submit(() -> nativeLib.process(input));
}
Enter fullscreen mode Exit fullscreen mode

Enable pin logging (dev only):

-XX:+PrintVirtualThreadPinning
Enter fullscreen mode Exit fullscreen mode

MVC + WebFlux together

  • If both starters are present, Spring chooses MVC
  • No warning is shown

Rule

  • Virtual Threads → keep spring-boot-starter-web
  • Remove starter-webflux

CPU-bound work on virtual threads

Anti-pattern

  • Heavy computation
  • Image processing
  • Crypto loops

Correct split

// I/O work
CompletableFuture.supplyAsync(
    () -> restTemplate.getForObject(...),
    Executors.newVirtualThreadPerTaskExecutor());

// CPU work
CompletableFuture.supplyAsync(
    () -> heavyComputation(data),
    ForkJoinPool.commonPool());
Enter fullscreen mode Exit fullscreen mode

Final Takeaway

Virtual Threads are the best choice for blocking, I/O-heavy Spring Boot services you cannot rewrite.

They give you scalability, simplicity, and production safety — without reactive complexity.

Top comments (0)