Java Virtual Threads — Quick Guide

Published: (February 1, 2026 at 07:12 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

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 was only one type of thread in Java: the platform thread (a 1:1 mapping to an OS kernel thread).

Project Loom introduces virtual threads as a second, lightweight thread type.

  • Platform threads – scheduled by the operating system, 1:1 with kernel threads.
  • Virtual threads – scheduled by the JVM in user mode, M:N mapped to kernel threads.

Both are represented by java.lang.Thread.

Key Characteristics

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

The Mental Model

Virtual ThreadCarrier (OS) Thread
JVM‑managed, lightweight thread (~1 KB)Real operating‑system thread
You can create millionsSmall, fixed pool (≈ CPU cores)
Blocking is cheap and safeThe truly scarce resource

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?


02 · Enable in Spring Boot

One property. No code changes.

# application.yml
server:
  servlet:
    threads:
      virtual-threads-enabled: true

Requirements

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

What Changes

  • Each HTTP request runs on a fresh virtual thread.
  • Controllers, services, RestTemplate, etc. remain unchanged.

What virtual-threads-enabled: true Actually Does

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 the threads.

Consequences:

  • The whole request lifecycle (from DispatcherServlet entry to response write‑back) runs on a virtual thread.
  • Every blocking call inside that chain (RestTemplate, JDBC, Thread.sleep()) is cheap because it already runs on a virtual thread.

The Manual Offload Approach

Tomcat’s default OS thread pool handles accept and dispatch, then explicitly hands work off to a virtual‑thread executor (e.g., via CompletableFuture.supplyAsync). The OS thread that accepted the request is released immediately.

Spring MVC knows how to handle a returned CompletableFuture:

  1. It suspends servlet processing.
  2. It resumes when the future completes.

Key point: Spring MVC does not block the servlet thread while waiting for the future; it registers a callback internally and frees the thread right away.

Service and client layers remain unchanged in both approaches.


03 · Virtual Threads Adoption Strategy in Spring Boot

Context

The existing Spring Boot microservice:

  • Handles incoming HTTP requests with Spring MVC (Servlet stack).
  • Calls multiple downstream services using blocking clients (RestTemplate, JDBC).

Constraints:

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

Goal: improve concurrency and scalability with 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 required.
  • 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

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 and not reused across requests.
  • ThreadLocal data does not persist beyond a single request.

Code that assumes thread reuse (e.g., caching data in a ThreadLocal) may malfunction.

Lack of Control
  • No isolation boundary – you cannot selectively exclude endpoints or code paths.
  • Fixing issues may require a full application restart because the change is global.

Option 2: Manual Offloading to a Virtual‑Thread Executor

Description

Keep Tomcat’s OS thread pool for accept/dispatch, but explicitly offload blocking work to a virtual‑thread executor, e.g.:

CompletableFuture.supplyAsync(() -> {
    // blocking I/O, JDBC, RestTemplate, etc.
}, Executors.newVirtualThreadPerTaskExecutor());

Spring MVC already knows how to handle a returned CompletableFuture:

  • It suspends servlet processing.
  • Registers a callback that resumes when the future completes.

Benefits

  • Fine‑grained control – only the parts that truly need it are offloaded.
  • Critical sections that rely on synchronized or ThreadLocal can stay on OS threads, avoiding pinning and ThreadLocal issues.

Drawbacks

  • Requires code changes at the boundaries where blocking occurs.
  • More invasive than the property‑based approach; you must audit and wrap all blocking calls.

Choosing an Adoption Path

CriteriaProperty‑Based (Global)Manual Offload
Code changes requiredNoneYes (wrap blocking calls)
Control over which code runs on virtual threadsNone (all requests)Selective
Impact on existing synchronized / ThreadLocal usagePotentially risky (pinning, loss of ThreadLocal state)Can keep critical sections on OS threads
Operational simplicityVery simple (single property)More complex (executor management)
Risk of carrier‑thread exhaustionHigher (global pinning)Lower (only offloaded parts)

Recommendation

If the codebase has minimal synchronized blocks and does not rely heavily on ThreadLocal state, the property‑based approach offers the fastest path to scalability.

If the application heavily uses synchronized sections, ThreadLocal‑based security contexts, or other thread‑affinity patterns, a manual offload strategy is safer, allowing you to isolate virtual‑thread usage to truly blocking I/O paths.


Quick Reference

# Enable virtual threads globally (Spring Boot 3.2+)
server:
  servlet:
    threads:
      virtual-threads-enabled: true
// Manual offload example
Executor vtExecutor = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture result = CompletableFuture.supplyAsync(() -> {
    // Blocking call (e.g., RestTemplate, JDBC)
    return restTemplate.getForObject(url, String.class);
}, vtExecutor);

Takeaway: Virtual threads can dramatically improve the scalability of blocking‑heavy Spring Boot services, but you must weigh the simplicity of global enablement against the potential pitfalls of carrier‑thread pinning and ThreadLocal misuse. Choose the strategy that aligns with your codebase’s concurrency characteristics and operational constraints.

UI‑Res 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, and resumes when the future completes.

ThreadLocal Considerations

Off‑loading 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

PositiveNegative
Improved scalability for I/O‑heavy endpoints.Additional boilerplate for context propagation.
No need to refactor existing synchronized code.Requires discipline to maintain off‑load boundaries.
Predictable runtime behavior.
Clear migration path.

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.


03 · 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();
    }
}

ThreadLocal Context Loss During Manual Offload

What breaks

  • MDC
  • SecurityContext
  • RequestAttributes

Fix: capture & restore context

Map 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);

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


ThreadLocal Leaks with Pooled Executors

Problem

  • Replacing the executor with a fixed pool causes ThreadLocals to leak across requests.

Fix: enforce correct executor

@Bean
public Executor ioExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

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 callNative(String input) {
    return NATIVE_POOL.submit(() -> nativeLib.process(input));
}

Enable pin logging (dev only)

-XX:+PrintVirtualThreadPinning

MVC + WebFlux Together

If both starters are present, Spring chooses MVC and 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());

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.

Back to Blog

Related posts

Read more »

SPRING BOOT EXCEPTION HANDLING

Java & Spring Boot Exception Handling Notes 1. What is Exception? Exception = unwanted situation that breaks normal flow of program. Goal of exception handling...

Switch on RUST

My path from Java to Rust: changing the technology stack Hello, my name is Garik, and today I want to share with you the story of how I decided to change the t...