DEV Community

Cover image for Java's Concurrency Revolution: How Immutability and Virtual Threads Changed Everything!
dbc2201
dbc2201

Posted on • Edited on

Java's Concurrency Revolution: How Immutability and Virtual Threads Changed Everything!

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());
}
Enter fullscreen mode Exit fullscreen mode

Migration Priority:

  1. Enable virtual threads in your framework
  2. Replace ThreadLocal with ScopedValue for context propagation
  3. Add Semaphore limits for databases and external APIs
  4. Remove unnecessary ReentrantLock workarounds 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)
  • ExecutorService and 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

This code compiles. It runs. It even passes most tests. But it hides three problems:

  1. Cognitive overhead — Every field access requires you to ask: "Is this synchronized?"
  2. Scalability limits — The synchronized keyword serializes access, creating bottlenecks under load.
  3. 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) {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Your code runs on a virtual thread
  2. The JVM schedules virtual threads onto carrier threads (platform threads)
  3. When a virtual thread blocks (I/O, sleep, lock), it unmounts from its carrier
  4. The carrier can then run another virtual thread
  5. 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.
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Option 3: Quick One-Off Thread

For demos or simple cases:

Thread t = Thread.startVirtualThread(() -> doWork());
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 of Thread.sleep() when you want to avoid catching InterruptedException:

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
Enter fullscreen mode Exit fullscreen mode

Note: The jdk.tracePinnedThreads option 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());
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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);
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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))
    );
}
Enter fullscreen mode Exit fullscreen mode

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 by jdk.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!
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

Problems with this approach:

  1. Each virtual thread gets its own copy
  2. Values are mutable—any code can call set()
  3. Lifetime is unbounded—you must manually call remove()
  4. 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());
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

Quarkus 3.x

# application.properties
quarkus.virtual-threads.enabled=true
Enter fullscreen mode Exit fullscreen mode
@Path("/orders")
public class OrderResource {

    @GET
    @RunOnVirtualThread
    public List<Order> getOrders() {
        return orderService.findAll(); // Blocking call is fine
    }
}
Enter fullscreen mode Exit fullscreen mode

Micronaut 4.x

# application.yml
micronaut:
  server:
    thread-selection: AUTO
  executors:
    io:
      type: virtual
Enter fullscreen mode Exit fullscreen mode
@Controller("/orders")
public class OrderController {

    @Get
    @ExecuteOn(TaskExecutors.VIRTUAL)
    public List<Order> getOrders() {
        return orderService.findAll();
    }
}
Enter fullscreen mode Exit fullscreen mode

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")));
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Start with non-critical paths—migrate background jobs and internal APIs first
  2. Keep reactive for streaming—WebSocket handlers stayed on WebFlux
  3. Update dependencies—ensure JDBC drivers and HTTP clients support virtual threads
  4. Monitor pinning events—use JFR to identify unexpected pinning
  5. 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
Enter fullscreen mode Exit fullscreen mode

When using -Djdk.tracePinnedThreads, you get stack traces showing where pinning occurs:

...
org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) <== monitors:1
...
Enter fullscreen mode Exit fullscreen mode

Important: The -Djdk.tracePinnedThreads option prints only one warning per pinning location in your code. For comprehensive analysis, use Java Flight Recorder and look for VirtualThreadPinned and VirtualThreadSubmitFailed events.

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
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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 ReentrantLock migrations done for Java 21
  • Keep ReentrantLock where 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
});
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Immutability is the foundation — Records and sealed classes make thread safety the default
  2. Virtual threads change the economics — Blocking is cheap; shared state is expensive
  3. synchronized works again — Java 24+ fixed the pinning issue
  4. Scoped Values replace ThreadLocal — Finalized in Java 25, designed for virtual threads
  5. Thread pools are for bounding, not scaling — Use semaphores for resource limits
  6. Reactive has its place — Streaming and backpressure, not request/response
  7. 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.tracePinnedThreads and mitigate if necessary
  • Minimize thread-local variables; prefer ScopedValue for 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

Videos and Talks

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)