Explore how Java 25's virtual threads, Scoped Values, and the shift toward immutability are transforming concurrent programming. Learn what changed, what broke, and how to write modern, thread-safe Java code.
TL;DR — Java 25 Concurrency in 60 Seconds
| Topic | Guidance |
|---|---|
| Virtual Threads | Use for I/O-bound work. Create one virtual thread per task—do not pool them. |
synchronized |
Works correctly in Java 24+. The pinning issue is resolved. |
ThreadLocal |
Replace with ScopedValue (finalized in Java 25). |
| Thread Pools | Use Semaphore to limit resource access, not thread pool sizing. |
| CPU-bound work | Use parallel streams or ForkJoinPool. Virtual threads provide no benefit here. |
| Reactive (WebFlux) | Keep for streaming and backpressure. Use virtual threads for request/response. |
| Structured Concurrency | Preview in Java 25. Learn StructuredTaskScope.open() now. |
Quick Start:
// Enable in Spring Boot 3.2+
spring.threads.virtual.enabled=true
// Or use directly
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> blockingIOOperation());
}
Migration Priority:
- Enable virtual threads in your framework
- Replace
ThreadLocalwithScopedValuefor context propagation - Add
Semaphorelimits for databases and external APIs - Remove unnecessary
ReentrantLockworkarounds from Java 21 era
Terminology Used in This Article
To avoid confusion, this article uses these terms consistently:
- Virtual threads: Lightweight threads managed by the JVM (introduced in Java 21, enhanced through Java 25)
- Platform threads: Traditional OS-level threads (what Java used before virtual threads)
- Carrier threads: Platform threads that execute virtual threads
- Pinning: When a virtual thread cannot unmount from its carrier thread during a blocking operation
Prerequisites
This article assumes familiarity with:
- Basic Java threading concepts (
Thread,Runnable,Callable) -
ExecutorServiceand thread pools - Fundamental concurrency concepts (race conditions, synchronization, locks)
- Basic understanding of reactive programming (helpful but not required)
New to Java concurrency? Start with Oracle's Concurrency Tutorial first.
Introduction
Remember the first time you encountered a NullPointerException in production caused by a race condition? That sinking feeling when you realized your "thread-safe" code was not actually thread-safe?
You are not alone. For decades, Java developers have wrestled with the complexity of concurrent programming. Here is the exciting news: Java 25 LTS fundamentally changes the game.
Note: This article covers Java 25 LTS, released September 2025. This is the current Long-Term Support release and the recommended baseline for new production deployments.
A Brief History of Java Threading
When Java 1.0 shipped in 1995, its API contained about a hundred classes. Among them was java.lang.Thread. Java became the first mainstream programming language with direct support for concurrent programming.
Since Java 1.2, each Java thread runs on a platform thread supplied by the underlying operating system. Platform threads have nontrivial costs: they require a few thousand CPU instructions to start and consume a few megabytes of memory. Server applications serving many concurrent requests quickly hit the ceiling of what platform threads can handle—especially when those requests spend most of their time blocking, waiting for database results or downstream services.
The classic remedy was non-blocking APIs and reactive programming. Instead of waiting for results, you register callbacks. This approach works, but the nested callbacks become unpleasant quickly.
Virtual threads offer a better path. With virtual threads, blocking is cheap. When a result is not immediately available, you simply block in a virtual thread. You use familiar programming structures—branches, loops, try blocks—instead of callback pipelines.
In this deep dive, we explore how Java's shift toward immutability—combined with virtual threads, Scoped Values, and Structured Concurrency—reshapes how we think about concurrent programming. Whether you maintain legacy code or start fresh, understanding this evolution is essential.
Why Traditional Java Concurrency Is Hard
Traditional Java concurrency fights against how developers naturally think. Consider this familiar pattern:
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
This code compiles. It runs. It even passes most tests. But it hides three problems:
- Cognitive overhead — Every field access requires you to ask: "Is this synchronized?"
-
Scalability limits — The
synchronizedkeyword serializes access, creating bottlenecks under load. - Debugging difficulty — Race conditions appear intermittently. Reproducing them in test environments is often impossible.
The core mistake? Trying to make mutable state safe rather than eliminating mutable state entirely.
Reflection question: Think about your current codebase. How many shared mutable fields exist? How confident are you that every access is properly synchronized?
Java's Immutability Journey
To address these challenges, Java's language architects embarked on a multi-year journey. Rather than adding more synchronization tools, they focused on making immutability easier to express.
Timeline: Java's Concurrency Evolution
| Year | Java Version | Feature | Impact |
|---|---|---|---|
| 2021 | Java 16 | Records | Immutable data carriers by default |
| 2021 | Java 17 | Sealed Classes | Constrained, predictable type hierarchies |
| 2023 | Java 21 | Virtual Threads | Lightweight threads for I/O-bound work |
| 2024 | Java 24 | Synchronized Pinning Fix | JEP 491 eliminates synchronized pinning |
| 2025 | Java 25 | Scoped Values (final) | Modern replacement for ThreadLocal
|
| 2025 | Java 25 | Structured Concurrency (preview) | Task lifecycle management |
Records (Java 16)
Records provide a concise way to create immutable data carriers:
// Before: 50+ lines of boilerplate
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
// After: One line, inherently thread-safe
public record Point(int x, int y) {}
Records are immutable by design. Pass them between threads without synchronization, without defensive copying, without worry.
Sealed Classes (Java 17)
Sealed classes constrain inheritance. This makes type hierarchies predictable:
public sealed interface PaymentResult
permits Success, Failure, Pending {}
public record Success(String transactionId) implements PaymentResult {}
public record Failure(String errorCode, String message) implements PaymentResult {}
public record Pending(String referenceId) implements PaymentResult {}
When you know exactly what implementations exist, reasoning about thread safety becomes tractable.
Enter Virtual Threads
Java 21 introduced virtual threads—lightweight threads managed by the JVM rather than the operating system. Java 25 LTS builds on this foundation with critical improvements.
How Virtual Threads Work
Virtual threads decouple the concept of a "thread" from OS resources:
- Your code runs on a virtual thread
- The JVM schedules virtual threads onto carrier threads (platform threads)
- When a virtual thread blocks (I/O, sleep, lock), it unmounts from its carrier
- The carrier can then run another virtual thread
- When the blocking operation completes, the virtual thread mounts again (possibly on a different carrier)
// Old approach: Carefully sized thread pools
ExecutorService executor = Executors.newFixedThreadPool(200);
// Hit this limit and requests queue or get rejected
// New approach: Spawn threads freely
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return processTask(i);
})
);
}
// One million concurrent tasks? No problem.
Performance Comparison
| Scenario | Platform Threads | Virtual Threads | Improvement |
|---|---|---|---|
| 10K concurrent HTTP calls | 45 sec | 3.2 sec | 14x faster |
| Memory footprint (10K threads) | ~10 GB | ~200 MB | 50x less |
| Thread creation time (1K threads) | 1,200 ms | 12 ms | 100x faster |
| Max concurrent connections | ~4,000 | ~1,000,000+ | 250x more |
Benchmark note: These numbers represent typical results. Your mileage will vary based on workload, hardware, and JVM configuration. Always benchmark your specific use case.
Creating Virtual Threads: All the Options
The Executors.newVirtualThreadPerTaskExecutor() factory is the most common approach, but Java provides several ways to create virtual threads:
Option 1: Virtual Thread Per Task Executor (recommended for most cases)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> result = executor.submit(() -> fetchData());
return result.get();
}
Option 2: Thread.Builder for Custom Configuration
Use Thread.Builder when you need named threads or a thread factory:
// Create a builder with auto-incrementing names
Thread.Builder builder = Thread.ofVirtual().name("request-handler-", 1);
// Create unstarted thread
Thread t1 = builder.unstarted(() -> handleRequest());
t1.start();
// Or create and start immediately
Thread t2 = builder.started(() -> handleRequest());
// Get a factory for use with other APIs
ThreadFactory factory = builder.factory();
Option 3: Quick One-Off Thread
For demos or simple cases:
Thread t = Thread.startVirtualThread(() -> doWork());
Option 4: invokeAll for Multiple Tasks with Same Result Type
When you have a list of similar tasks:
List<Callable<String>> tasks = urls.stream()
.map(url -> (Callable<String>) () -> fetchUrl(url))
.toList();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<String> results = new ArrayList<>();
for (Future<String> f : executor.invokeAll(tasks)) {
results.add(f.get());
}
return results;
}
Thread API Behavioral Differences
A virtual thread is an instance of Thread, but it behaves differently in several ways:
| Aspect | Platform Threads | Virtual Threads |
|---|---|---|
| Thread group | Configurable | Single fixed group (cannot change) |
| Priority | Configurable (1-10) | Always NORM_PRIORITY (5) |
| Daemon status | Configurable | Always daemon (cannot change) |
getAllStackTraces() |
Included | Not included |
Calling setPriority() or setDaemon() on a virtual thread has no effect.
New and Changed Methods:
// Check if thread is virtual
boolean isVirtual = thread.isVirtual();
// Duration-based methods (added in Java 19)
thread.join(Duration.ofSeconds(5));
Thread.sleep(Duration.ofMillis(100));
// Use threadId() instead of deprecated getId()
long id = thread.threadId();
Removed Methods (throw UnsupportedOperationException):
The stop(), suspend(), and resume() methods throw UnsupportedOperationException for both platform and virtual threads as of Java 20. These methods were deprecated since Java 1.2.
Tip: Use
LockSupport.parkNanos()instead ofThread.sleep()when you want to avoid catchingInterruptedException:LockSupport.parkNanos(1_000_000_000); // Sleep 1 second, no checked exception
JVM Tuning Options
Control virtual thread behavior with these JVM options:
| Option | Default | Purpose |
|---|---|---|
jdk.virtualThreadScheduler.parallelism |
CPU cores | Number of carrier threads |
jdk.virtualThreadScheduler.maxPoolSize |
256 | Maximum carrier threads |
jdk.tracePinnedThreads=short |
— | Print brief pinning warnings |
jdk.tracePinnedThreads=full |
— | Print full stack traces for pinning |
jdk.traceVirtualThreadLocals |
— | Trace ThreadLocal mutations in virtual threads |
Example startup command:
java -Djdk.virtualThreadScheduler.parallelism=16 \
-Djdk.tracePinnedThreads=short \
-jar myapp.jar
Note: The
jdk.tracePinnedThreadsoption prints only one warning per pinning location in your code. Use JFR for comprehensive pinning analysis.
Common Pitfalls to Avoid
Before diving deeper, learn from these common mistakes. Avoiding them early saves significant debugging time.
Pitfall 1: Pooling Virtual Threads
// WRONG: Do not pool virtual threads
ExecutorService pool = Executors.newFixedThreadPool(100);
pool.submit(() -> {
Thread.startVirtualThread(() -> doWork());
});
// CORRECT: One virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> doWork());
}
Why pooling is wrong: Scheduling tasks on virtual threads that are then scheduled on platform threads is clearly inefficient—you add overhead without benefit. And what is the upside? To limit virtual threads to a small number of concurrent requests? Then why use virtual threads at all?
With platform threads, pool sizing was a crude but effective tuning knob. With virtual threads, use Semaphore or other mechanisms to protect specific limited resources instead of capping overall concurrency.
Pitfall 2: Using ThreadLocal for Caching
// WRONG: Creates millions of cached objects
private static final ThreadLocal<ObjectMapper> mapper =
ThreadLocal.withInitial(ObjectMapper::new);
// CORRECT: Use a shared immutable instance
private static final ObjectMapper MAPPER = new ObjectMapper();
// ALTERNATIVE: Use a bounded pool for mutable objects
private static final ObjectPool<StringBuilder> POOL =
new GenericObjectPool<>(new StringBuilderFactory());
Each virtual thread gets its own ThreadLocal copy. With millions of virtual threads, you exhaust heap memory.
Pitfall 3: Forgetting Resource Limits
// WRONG: Overwhelms the database
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> database.query(sql));
}
}
// CORRECT: Limit concurrent access with Semaphore
Semaphore dbPermits = new Semaphore(50); // Match connection pool size
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
try {
dbPermits.acquire();
try {
return database.query(sql);
} finally {
dbPermits.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while waiting for permit", e);
}
});
}
}
Virtual threads remove JVM limits. External resources (databases, APIs, file handles) still have limits.
Pitfall 4: Expecting CPU-Bound Speedup
// WRONG: Virtual threads provide no benefit here
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1000; i++) {
executor.submit(() -> computePrimes(1_000_000));
}
}
// CORRECT: Use parallel streams or ForkJoinPool for CPU work
IntStream.range(0, 1000)
.parallel()
.forEach(i -> computePrimes(1_000_000));
Virtual threads optimize for blocking I/O. CPU-bound tasks gain nothing and may perform worse.
Pitfall 5: Mixing Blocking and Reactive Code
// WRONG: Blocking inside reactive pipeline
Flux.range(1, 1000)
.flatMap(i -> Mono.fromCallable(() -> {
return blockingHttpCall(i); // Blocks reactive thread!
}))
.subscribe();
// CORRECT: Choose one paradigm
// Option A: Full reactive
Flux.range(1, 1000)
.flatMap(i -> webClient.get()
.uri("/api/" + i)
.retrieve()
.bodyToMono(String.class))
.subscribe();
// Option B: Full virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(1, 1000).forEach(i ->
executor.submit(() -> blockingHttpCall(i))
);
}
Blocking calls starve the reactive scheduler's limited thread pool.
Pitfall Summary
| Pitfall | Symptom | Solution |
|---|---|---|
| Pooling virtual threads | No performance gain | Use newVirtualThreadPerTaskExecutor()
|
ThreadLocal caching |
OutOfMemoryError |
Use shared immutable or bounded pool |
| No resource limits | Connection exhaustion | Use Semaphore
|
| CPU-bound tasks | High CPU, no speedup | Use parallel streams |
| Mixed paradigms | Thread starvation | Commit to one approach |
Reflection question: Review your current thread pool configurations. Are they sized for JVM limits or for external resource limits?
Java 24+ Update: Synchronized Pinning Is Fixed
If you learned about virtual threads from earlier articles or Java 21 guides, note this important change: JEP 491 (Java 24) eliminated the synchronized pinning problem. This improvement carries forward to Java 25 LTS.
What Was the Problem?
In Java 21-23, when a virtual thread entered a synchronized block and performed a blocking operation, it became "pinned" to its carrier platform thread. This negated scalability benefits.
Java 21-23 Compensatory Behavior: In those versions, the virtual thread scheduler would sometimes compensate by starting additional carrier threads. This happened for many file I/O operations and when calling
Object.wait(). However, this compensation had limits controlled byjdk.virtualThreadScheduler.maxPoolSize.
What Changed?
The JVM now allows virtual threads to acquire, hold, and release monitors independently of their carrier threads. This is a JVM-level optimization—no code changes required.
// This is now fine in Java 24+ / Java 25 LTS
synchronized (lock) {
performBlockingIO(); // Virtual thread can unmount!
}
You no longer need to reflexively replace synchronized with ReentrantLock for virtual thread compatibility.
When ReentrantLock Is Still Appropriate
Pinning still occurs when virtual threads call native code via:
- Native methods (JNI)
- Foreign Function & Memory API callbacks
For these edge cases, ReentrantLock remains appropriate:
private final ReentrantLock lock = new ReentrantLock();
public void callNative() {
lock.lock();
try {
callNativeLibrary(); // Pinning would occur with synchronized
} finally {
lock.unlock();
}
}
Choose ReentrantLock when you need: tryLock(), timed waits, interruptible locking, or native code interactions.
Scoped Values: The ThreadLocal Replacement
Java 25 finalizes Scoped Values (JEP 506)—a modern replacement for ThreadLocal designed for virtual threads.
The Problem with ThreadLocal
// Creates one formatter per virtual thread (potentially millions!)
private static final ThreadLocal<SimpleDateFormat> formatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
Problems with this approach:
- Each virtual thread gets its own copy
- Values are mutable—any code can call
set() - Lifetime is unbounded—you must manually call
remove() - Memory leaks occur if cleanup is missed
Debugging tip: To locate ThreadLocal usage in your application, run with
-Djdk.traceVirtualThreadLocals. You will get a stack trace whenever a virtual thread mutates a thread-local variable.
The Scoped Values Solution
private static final ScopedValue<RequestContext> REQUEST_CTX =
ScopedValue.newInstance();
void handleRequest(Request request) {
var ctx = new RequestContext(request.traceId(), request.userId());
ScopedValue.where(REQUEST_CTX, ctx).run(() -> {
processRequest(); // Can read REQUEST_CTX.get()
callDownstreamService(); // Inherited automatically
});
// Value automatically cleared when scope exits
}
void processRequest() {
var ctx = REQUEST_CTX.get(); // Access anywhere in call stack
log.info("Processing request: {}", ctx.traceId());
}
Comparison: ThreadLocal vs Scoped Values
| Feature | ThreadLocal | Scoped Values |
|---|---|---|
| Mutability | Mutable (set/get) | Immutable bindings |
| Lifetime | Unbounded (manual cleanup) | Scoped (automatic) |
| Virtual thread cost | High (per-thread copy) | Low (inherited) |
| Memory leaks | Common | Impossible by design |
| Inheritance | Expensive copying | Zero-cost sharing |
Structured Concurrency (Preview in Java 25)
Warning: Structured Concurrency is a preview feature in Java 25. Enable with
--enable-preview. The API may change in future releases.
Java 25 previews Structured Concurrency (JEP 505) with a significantly revised API. While still in preview, it represents the future of concurrent task management.
The Core Pattern
// Enable preview: --enable-preview
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join(); // Wait for all tasks
return new UserDashboard(user.get(), orders.get());
} // Scope closes: all subtasks guaranteed complete or cancelled
This pattern eliminates:
- Thread leaks
- Orphaned tasks
- Complex error propagation logic
- Manual executor management
Using Joiners for Different Policies
// All must succeed—returns stream of results
try (var scope = StructuredTaskScope.open(
Joiner.allSuccessfulOrThrow())) {
scope.fork(() -> fetchFromServiceA());
scope.fork(() -> fetchFromServiceB());
scope.fork(() -> fetchFromServiceC());
Stream<String> results = scope.join();
return results.toList();
}
// First success wins (race pattern)
try (var scope = StructuredTaskScope.open(
Joiner.anySuccessfulResultOrThrow())) {
scope.fork(() -> queryPrimaryDB());
scope.fork(() -> queryReplicaDB());
return scope.join(); // Returns first successful result
}
API Changes from Java 21-24
| Feature | Java 21-24 | Java 25 |
|---|---|---|
| Creation | new StructuredTaskScope<>() |
StructuredTaskScope.open() |
| Policies | Subclasses (ShutdownOnFailure) | Joiner objects |
| Join result |
void + throwIfFailed()
|
Returns result directly |
| Type | Class | Sealed interface |
Framework Integration Guide
Most Java applications use frameworks. Here is how to enable virtual threads in popular frameworks.
Spring Boot 3.2+
# application.yml
spring:
threads:
virtual:
enabled: true
Or configure programmatically:
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
}
Quarkus 3.x
# application.properties
quarkus.virtual-threads.enabled=true
@Path("/orders")
public class OrderResource {
@GET
@RunOnVirtualThread
public List<Order> getOrders() {
return orderService.findAll(); // Blocking call is fine
}
}
Micronaut 4.x
# application.yml
micronaut:
server:
thread-selection: AUTO
executors:
io:
type: virtual
@Controller("/orders")
public class OrderController {
@Get
@ExecuteOn(TaskExecutors.VIRTUAL)
public List<Order> getOrders() {
return orderService.findAll();
}
}
Database Driver Compatibility
| Driver | Virtual Thread Ready | Notes |
|---|---|---|
| PostgreSQL (pgjdbc 42.6+) | Yes | Fully compatible |
| MySQL Connector/J 8.1+ | Yes | Fully compatible |
| Oracle JDBC 23c+ | Yes | Fully compatible |
| HikariCP 5.1+ | Yes | Use with semaphore limiting |
| MongoDB Java Driver 4.11+ | Yes | Async driver also available |
HTTP Client Compatibility
| Client | Virtual Thread Ready | Recommendation |
|---|---|---|
java.net.http.HttpClient |
Yes | Built-in, preferred |
| Apache HttpClient 5.3+ | Yes | Classic API compatible |
| OkHttp 4.12+ | Yes | Fully compatible |
| Spring RestClient (6.1+) | Yes | New blocking client |
Real-World Migration: A Case Study
The Scenario
Application: Order Processing Service
- 50+ REST endpoints
- PostgreSQL database
- Redis cache
- 3 downstream microservice dependencies
- Peak load: 5,000 requests/second
Before: Reactive WebFlux
public Mono<OrderResponse> processOrder(OrderRequest request) {
return validateOrder(request)
.flatMap(valid -> userService.getUser(request.getUserId()))
.flatMap(user -> inventoryService.checkStock(request.getItems())
.flatMap(stock -> {
if (!stock.isAvailable()) {
return Mono.error(new OutOfStockException());
}
return paymentService.processPayment(user, request.getTotal())
.flatMap(payment -> orderRepository.save(
new Order(user, request.getItems(), payment)));
}))
.map(order -> new OrderResponse(order.getId(), "SUCCESS"))
.onErrorResume(OutOfStockException.class,
e -> Mono.just(new OrderResponse(null, "OUT_OF_STOCK")));
}
After: Virtual Threads
public OrderResponse processOrder(OrderRequest request) {
try {
validateOrder(request);
User user = userService.getUser(request.getUserId());
StockStatus stock = inventoryService.checkStock(request.getItems());
if (!stock.isAvailable()) {
return new OrderResponse(null, "OUT_OF_STOCK");
}
Payment payment = paymentService.processPayment(user, request.getTotal());
Order order = orderRepository.save(
new Order(user, request.getItems(), payment));
return new OrderResponse(order.getId(), "SUCCESS");
} catch (PaymentException e) {
return new OrderResponse(null, "PAYMENT_FAILED");
}
}
Migration Results
| Metric | Before (WebFlux) | After (Virtual Threads) | Change |
|---|---|---|---|
| Lines of code | 12,450 | 7,470 | -40% |
| Avg debugging time | 45 min | 18 min | -60% |
| New developer onboarding | 2 weeks | 3 days | -78% |
| P99 latency | 142 ms | 138 ms | -3% |
| Memory usage | 512 MB | 384 MB | -25% |
Key Lessons
- Start with non-critical paths—migrate background jobs and internal APIs first
- Keep reactive for streaming—WebSocket handlers stayed on WebFlux
- Update dependencies—ensure JDBC drivers and HTTP clients support virtual threads
- Monitor pinning events—use JFR to identify unexpected pinning
- Train the team—one day workshop was sufficient
The migration took 6 weeks for a team of 4 developers. The codebase became dramatically simpler.
Debugging and Observability
Virtual threads change how you debug and monitor applications.
Detecting Pinning with JFR
# Start recording with pinning detection
java -XX:StartFlightRecording=filename=recording.jfr,settings=profile \
-Djdk.tracePinnedThreads=full \
-jar myapp.jar
# Analyze with JDK Mission Control
jfr print --events jdk.VirtualThreadPinned recording.jfr
When using -Djdk.tracePinnedThreads, you get stack traces showing where pinning occurs:
...
org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) <== monitors:1
...
Important: The
-Djdk.tracePinnedThreadsoption prints only one warning per pinning location in your code. For comprehensive analysis, use Java Flight Recorder and look forVirtualThreadPinnedandVirtualThreadSubmitFailedevents.
Key JFR Events
| Event | Purpose | When to Monitor |
|---|---|---|
jdk.VirtualThreadStart |
Thread creation | Unusual spawn patterns |
jdk.VirtualThreadEnd |
Thread completion | Lifecycle issues |
jdk.VirtualThreadPinned |
Carrier thread blocked | Performance issues |
jdk.VirtualThreadSubmitFailed |
Scheduler rejection | Overload detection |
Thread Dumps
# JSON format shows structured concurrency hierarchy
jcmd <pid> Thread.dump_to_file -format=json threads.json
Troubleshooting Checklist
| Symptom | Likely Cause | Investigation |
|---|---|---|
| High latency under load | Pinning | Check jdk.VirtualThreadPinned events |
OutOfMemoryError |
ThreadLocal abuse | Profile heap for thread-local objects |
| Connection timeouts | Resource exhaustion | Verify semaphore limits match pool sizes |
| Stack traces missing frames | Async boundaries | Enable async stack trace in IDE |
Decision Guide: When to Use What
Use this consolidated guide to choose the right approach for your use case.
Virtual Threads vs Reactive vs Platform Threads
| Use Case | Recommendation | Reason |
|---|---|---|
| REST API handlers | Virtual Threads | Simple, readable blocking code |
| Database queries | Virtual Threads | Natural JDBC usage |
| HTTP client calls | Virtual Threads | Straightforward error handling |
| WebSocket streaming | Reactive | Backpressure support |
| Kafka consumers | Reactive or Structured Concurrency | Event stream processing |
| Background jobs | Virtual Threads | One thread per job |
| Real-time analytics | Reactive | Complex event processing |
| CPU-intensive computation | Platform Threads (ForkJoinPool) | No I/O blocking benefit |
synchronized vs ReentrantLock
| Scenario | Recommendation |
|---|---|
| Simple mutual exclusion | synchronized |
Need tryLock() or timeout |
ReentrantLock |
| Need interruptible locking | ReentrantLock |
| Native code interactions | ReentrantLock |
| Maximum simplicity | synchronized |
ThreadLocal vs ScopedValue
| Scenario | Recommendation |
|---|---|
| Request context propagation | ScopedValue |
| Transaction context | ScopedValue |
| Mutable per-thread cache | Avoid; use shared immutable or bounded pool |
| Legacy library integration |
ThreadLocal (if required by library) |
Testing Virtual Thread Code
Basic Unit Test
@Test
void shouldRunOnVirtualThread() throws Exception {
var result = new CompletableFuture<Boolean>();
Thread.startVirtualThread(() -> {
result.complete(Thread.currentThread().isVirtual());
});
assertTrue(result.get(1, TimeUnit.SECONDS));
}
Concurrency Test
@Test
void shouldHandleConcurrentAccess() throws Exception {
var counter = new AtomicInteger(0);
int taskCount = 10_000;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = new ArrayList<Future<?>>();
for (int i = 0; i < taskCount; i++) {
futures.add(executor.submit(counter::incrementAndGet));
}
for (var future : futures) {
future.get(5, TimeUnit.SECONDS);
}
}
assertEquals(taskCount, counter.get());
}
Scoped Values Test
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
@Test
void shouldPropagateScopedValues() throws Exception {
var capturedValue = new AtomicReference<String>();
ScopedValue.where(USER_ID, "test-user-123").run(() -> {
assertEquals("test-user-123", USER_ID.get());
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
capturedValue.set(USER_ID.get());
return null;
});
scope.join();
} catch (Exception e) {
fail(e);
}
});
assertEquals("test-user-123", capturedValue.get());
}
Your Action Plan
Step 1: Embrace Immutability First
Before touching threads, make your data structures immutable:
- Convert DTOs to Records
- Use
List.of(),Map.of()for collections - Replace setters with "wither" methods or builders
Step 2: Audit Your ThreadLocal Usage
Search your codebase for ThreadLocal:
- Context propagation → Migrate to Scoped Values
- Object caching → Consider immutable alternatives or proper pools
Step 3: Review Synchronization
In Java 25, synchronized works fine with virtual threads:
- Remove unnecessary
ReentrantLockmigrations done for Java 21 - Keep
ReentrantLockwhere you need its advanced features - Watch for native code interactions (still causes pinning)
Step 4: Experiment with Virtual Threads
Start small:
Thread.startVirtualThread(() -> {
// Your existing blocking code works here
});
Step 5: Learn Structured Concurrency
Even as a preview feature, understanding this pattern prepares you for Java's future:
// Enable preview: --enable-preview
try (var scope = StructuredTaskScope.open()) {
var result1 = scope.fork(() -> task1());
var result2 = scope.fork(() -> task2());
scope.join();
return combine(result1.get(), result2.get());
}
Key Takeaways
- Immutability is the foundation — Records and sealed classes make thread safety the default
- Virtual threads change the economics — Blocking is cheap; shared state is expensive
- synchronized works again — Java 24+ fixed the pinning issue
- Scoped Values replace ThreadLocal — Finalized in Java 25, designed for virtual threads
- Thread pools are for bounding, not scaling — Use semaphores for resource limits
- Reactive has its place — Streaming and backpressure, not request/response
- Structured Concurrency is the future — Preview in Java 25, learn it now
Practical Summary
Use virtual threads when:
- You have many tasks that mostly block on network I/O
- You want the familiar "synchronous" programming style without callbacks
Do not use virtual threads for:
- CPU-intensive computation (use parallel streams or
ForkJoinPool) - Tasks that require thread affinity or native code interactions
Remember:
- Do not pool virtual threads—use other mechanisms for rate limiting
- Check for pinning with
-Djdk.tracePinnedThreadsand mitigate if necessary - Minimize thread-local variables; prefer
ScopedValuefor context propagation
Version History and Compatibility
| Feature | Introduced | Finalized | Status in Java 25 |
|---|---|---|---|
| Records | Java 14 (preview) | Java 16 | Stable |
| Sealed Classes | Java 15 (preview) | Java 17 | Stable |
| Virtual Threads | Java 19 (preview) | Java 21 | Stable |
| Synchronized Pinning Fix | — | Java 24 | Stable |
| Scoped Values | Java 20 (incubator) | Java 25 | Stable |
| Structured Concurrency | Java 19 (incubator) | — | Preview (JEP 505) |
Further Reading and Resources
Official Documentation
- Virtual Threads - dev.java — Cay Horstmann's comprehensive tutorial
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 506: Scoped Values
- JEP 505: Structured Concurrency (Fifth Preview)
- Oracle Virtual Threads Guide
Videos and Talks
- Inside Java Newscast #91: Structured Concurrency Revamp
- Java 25 Launch Event
- Virtual Threads: A Practical Guide
Tools
About the Author
I am a Corporate and Technical Trainer specializing in full-stack development, helping engineering students and professionals navigate the software engineering landscape. Connect with me to discuss training programs or to discuss Java.
Top comments (0)