Core Java

CompletableFuture vs Virtual Threads: When to Use Each

Java 21 introduced virtual threads as a production-ready feature, fundamentally changing how we approach asynchronous programming. For years, CompletableFuture has been the go-to tool for non-blocking operations, but virtual threads offer a compelling alternative with a dramatically different programming model. Understanding when to use each approach is crucial for building efficient, maintainable Java applications.

CompletableFuture vs Virtual Threads in java 21

Two Different Philosophies

CompletableFuture uses a callback-based style while virtual threads allow a synchronous coding style for asynchronous logic. This represents a fundamental philosophical difference:

CompletableFuture: Chain operations through callbacks (thenApply, thenCompose, thenAccept), explicitly managing asynchronous flow.

Virtual Threads: Write straightforward, imperative code that looks synchronous but executes asynchronously under the hood.

Consider a simple example of fetching user data:

// CompletableFuture approach
CompletableFuture<User> userFuture = CompletableFuture
    .supplyAsync(() -> fetchUser(userId))
    .thenApply(user -> enrichUserData(user))
    .thenApply(user -> validateUser(user));

// Virtual threads approach
User user = fetchUser(userId);
user = enrichUserData(user);
user = validateUser(user);

With CompletableFuture, you chain operations using methods like thenApply, thenCompose and thenAccept. Virtual threads let you write simple imperative code that reads more naturally.

Understanding Virtual Threads

Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high-throughput concurrent applications. Unlike platform threads that map directly to operating system threads, virtual threads are managed entirely by the JVM.

The key efficiency comes from what happens during blocking operations. When a virtual thread performs a blocking I/O operation, it can unmount from its carrier (platform thread). The carrier gets free, so the JVM can mount a different virtual thread on that carrier. This allows you to create millions of virtual threads with minimal overhead.

// Creating virtual threads is straightforward
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

executor.submit(() -> {
    String result = blockingHttpCall(); // Automatically unmounts
    processResult(result);
});

The Thread Pinning Problem

Virtual threads have a significant limitation: thread pinning. A current limitation is that performing a blocking operation while inside a synchronized block or method causes the JDK’s virtual thread scheduler to block a precious OS thread, whereas it wouldn’t if the blocking operation were done outside of a synchronized block or method.

This affects real-world applications significantly. JDBC operations typically cause thread pinning. This is partly because many JDBC drivers use synchronised blocks internally, but also because native blocking calls themselves can pin the thread.

However, progress is being made. The PostgreSQL JDBC driver version 42.6.0 replaced virtually all synchronized methods with reentrant locks, addressing pinning issues. The MySQL connector is following suit.

Important: Java 24 (released March 2025) introduced JEP 491, which solves synchronized-related pinning scenarios. If you’re on Java 24+, many pinning concerns are eliminated.

When CompletableFuture Still Makes Sense

Despite virtual threads’ advantages, CompletableFuture remains valuable in several scenarios:

1. Complex Composition Patterns

CompletableFuture excels at declaratively composing multiple asynchronous operations:

CompletableFuture<Report> report = CompletableFuture
    .allOf(fetchSales(), fetchInventory(), fetchAnalytics())
    .thenApply(v -> combineResults(
        fetchSales().join(),
        fetchInventory().join(),
        fetchAnalytics().join()
    ));

2. Existing Reactive Codebases

Existing codebases and simpler asynchronous tasks might be well-served by CompletableFuture’s established API. Migration to virtual threads requires testing and consideration of framework compatibility.

3. Explicit Executor Control

When you need fine-grained control over thread pool configuration and resource allocation, CompletableFuture with custom executors provides more flexibility.

4. APIs Returning CompletableFuture

Many Java libraries and frameworks expose CompletableFuture-based APIs. While you can bridge these to virtual threads, it may be simpler to work with them directly.

When Virtual Threads Shine

1. I/O-Bound Operations

Virtual threads are suitable for running tasks that spend most of the time blocked, often waiting for I/O operations to complete. This makes them ideal for:

  • HTTP clients making external API calls
  • Database queries (with modern, pinning-aware drivers)
  • File I/O operations
  • Message queue consumers

2. Simplified Code Structure

Virtual threads eliminate callback hell. Compare these approaches for a multi-step process:

// CompletableFuture: callback chains
CompletableFuture<Result> future = fetchUser(userId)
    .thenCompose(user -> fetchOrders(user))
    .thenCompose(orders -> calculateTotal(orders))
    .thenApply(total -> generateReport(total));

// Virtual threads: straightforward sequence
User user = fetchUser(userId);
List<Order> orders = fetchOrders(user);
BigDecimal total = calculateTotal(orders);
Report report = generateReport(total);

Virtual threads have lower resource overhead than threads/thread pools used by completable futures, making them more efficient for high-concurrency scenarios.

3. Easier Error Handling

Exception handling is more natural with virtual threads:

// Virtual threads: standard try-catch
try {
    Result result = riskyOperation();
    process(result);
} catch (IOException e) {
    handleError(e);
}

// CompletableFuture: callback-based error handling
CompletableFuture<Result> future = CompletableFuture
    .supplyAsync(() -> riskyOperation())
    .exceptionally(ex -> {
        handleError(ex);
        return defaultValue();
    });

Combining Both Approaches

You can use CompletableFuture with virtual threads to get the best of both worlds. CompletableFuture is able to use virtual threads and handle asynchronous processing in a very efficient manner:

Executor virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> blockingOperation(), virtualExecutor);

Data fetchers explicitly returning CompletableFuture do not get wrapped in a virtual thread, allowing you to keep the option of managing your own thread pools if needed.

Structured Concurrency: The Third Option

Java 21 also introduced Structured Concurrency (preview feature) as a higher-level abstraction for managing concurrent tasks. The principal class is StructuredTaskScope in the java.util.concurrent package, which allows developers to structure a task as a family of concurrent subtasks and coordinate them as a unit.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Supplier<String> user = scope.fork(() -> findUser());
    Supplier<Integer> order = scope.fork(() -> fetchOrder());
    
    scope.join();           // Wait for all subtasks
    scope.throwIfFailed();  // Propagate errors
    
    return new Response(user.get(), order.get());
}

StructuredTaskScope provides two policies: ShutdownOnFailure captures the first exception and shuts down the task scope, while ShutdownOnSuccess captures the first result and shuts down the task scope to interrupt unfinished threads.

This approach offers automatic resource cleanup, clear task hierarchies, and built-in error propagation—benefits that neither CompletableFuture nor raw virtual threads provide alone.

Practical Decision Framework

Here’s a decision tree for choosing the right approach:

ApproachChoose When
Virtual Threads• Writing new I/O-bound code from scratch
• Working with blocking APIs (JDBC, file I/O, HTTP clients)
• Team prefers imperative, sequential code style
• Using Java 21+ with pinning-aware libraries
• Building high-throughput services with many concurrent requests
CompletableFuture• Working with existing reactive codebases
• Need explicit executor control and thread pool tuning
• Composing multiple independent async operations
• Integrating with libraries that expose CompletableFuture APIs
• Team is comfortable with reactive programming patterns
Structured Concurrency• Managing groups of related concurrent tasks
• Need robust error propagation and cancellation
• Want automatic resource cleanup
• Working with virtual threads and need better coordination

Performance Considerations

Benchmarks confirm that R2DBC presents a huge improvement compared to traditional threads, but in terms of performance, virtual threads dethrone R2DBC. This means you can often achieve reactive-level performance with simpler, blocking-style code.

However, be aware of limitations:

  • Virtual threads aren’t intended for long-running CPU-intensive operations
  • Monitor for pinning using -Djdk.tracePinnedThreads=full
  • Framework limitations: Switching from normal threads to virtual threads can have unforeseen consequences for legacy applications, requiring thorough testing

The Future is Hybrid

The future of asynchronous programming in Java likely involves a coexistence of both approaches. Virtual threads don’t obsolete CompletableFuture; they complement it. The choice depends on your specific requirements, team preferences, and existing architecture.

For new projects starting with Java 21+, virtual threads offer a compelling default for I/O-bound operations. They simplify code, reduce bugs, and eliminate the need for complex reactive chains. But CompletableFuture remains a powerful tool when you need explicit composition or are working within existing reactive architectures.

Conclusion

Virtual threads represent a paradigm shift in Java concurrency, bringing simplicity back to asynchronous programming without sacrificing performance. They allow developers to write code that looks synchronous but scales like asynchronous code. However, CompletableFuture isn’t going away—it remains valuable for complex composition patterns and existing reactive codebases.

The key is understanding the strengths of each approach and choosing the right tool for your specific use case. With Java 21’s toolset including virtual threads, CompletableFuture, and structured concurrency, you have more options than ever for building efficient, maintainable concurrent applications.

Additional Resources

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button