Java Virtual Threads — Quick Guide
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 Thread | Carrier (OS) Thread |
|---|---|
| JVM‑managed, lightweight thread (~1 KB) | Real operating‑system thread |
| You can create millions | Small, fixed pool (≈ CPU cores) |
| Blocking is cheap and safe | The 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
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
DispatcherServletentry 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:
- It suspends servlet processing.
- 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
synchronizedblocks/methods and heavy reliance onThreadLocal(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:
- Property‑based virtual threads (
spring.threads.virtual.enabled=true). - 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.
ThreadLocaldata 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
synchronizedorThreadLocalcan 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
| Criteria | Property‑Based (Global) | Manual Offload |
|---|---|---|
| Code changes required | None | Yes (wrap blocking calls) |
| Control over which code runs on virtual threads | None (all requests) | Selective |
Impact on existing synchronized / ThreadLocal usage | Potentially risky (pinning, loss of ThreadLocal state) | Can keep critical sections on OS threads |
| Operational simplicity | Very simple (single property) | More complex (executor management) |
| Risk of carrier‑thread exhaustion | Higher (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
CompletableFuturereturn types. - Suspends request processing, releases the servlet thread immediately, and resumes when the future completes.
- Natively supports
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
| Positive | Negative |
|---|---|
| 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.
9concurrent requests +8carriers → 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
SecurityContextRequestAttributes
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.