Back to Blog
java-21concurrencyvirtual-threadscompletablefuturespring-bootstructured-concurrency

Java Concurrency in 2026: CompletableFuture, Virtual Threads, and Structured Concurrency

Java concurrency has evolved dramatically. From raw threads to CompletableFuture to virtual threads and structured concurrency — here's which tool to use for each problem.

J

JOptimize Team

May 30, 2026· 10 min read

Java concurrency has three major models available in 2026, and the right choice depends on what you're trying to accomplish. CompletableFuture handles async pipelines and parallel independent operations. Virtual threads handle high-concurrency I/O-bound work with simple blocking code. Structured concurrency (Java 21 preview, stable in 22) manages groups of tasks with clean lifecycle semantics.

Using the wrong tool for the job creates code that's either unnecessarily complex or doesn't perform as expected. Here's the decision guide.


Model 1: CompletableFuture — Parallel Independent Operations

CompletableFuture is the right tool when you need to run multiple independent operations in parallel and combine their results:

// Sequential: 250ms total public DashboardDto buildDashboard(Long userId) { UserProfile profile = profileService.get(userId); // 80ms List<Order> orders = orderService.getRecent(userId); // 100ms List<Alert> alerts = alertService.get(userId); // 70ms return new DashboardDto(profile, orders, alerts); // 250ms total } // Parallel with CompletableFuture: ~100ms total (limited by the slowest call) public DashboardDto buildDashboard(Long userId) { CompletableFuture<UserProfile> profileFuture = supplyAsync(() -> profileService.get(userId)); CompletableFuture<List<Order>> ordersFuture = supplyAsync(() -> orderService.getRecent(userId)); CompletableFuture<List<Alert>> alertsFuture = supplyAsync(() -> alertService.get(userId)); return new DashboardDto( profileFuture.join(), // Block until all complete ordersFuture.join(), alertsFuture.join() ); // join() propagates the first exception if any future fails }

Chaining Operations

// Pipeline: each step depends on the previous CompletableFuture<OrderConfirmation> confirmation = orderRepo.findByIdAsync(orderId) .thenApply(order -> orderValidator.validate(order)) // Transform (sync) .thenCompose(order -> paymentService.charge(order)) // Chain async step .thenCompose(payment -> inventoryService.reserve(payment.getOrderId())) .thenApply(reservation -> new OrderConfirmation(reservation)); // Get the result (blocks, or use thenAccept/thenApply for non-blocking handling) OrderConfirmation result = confirmation .exceptionally(ex -> { log.error("Order confirmation failed: {}", ex.getMessage()); return OrderConfirmation.failed(ex.getMessage()); }) .join();

Combining Multiple Futures

// Wait for ALL to complete CompletableFuture.allOf(future1, future2, future3) .thenRun(() -> log.info("All completed")) .join(); // Take the FIRST to complete (fastest service wins) CompletableFuture.anyOf(primaryService.call(req), fallbackService.call(req)) .thenApply(result -> (ServiceResponse) result) .join(); // Combine two futures when both complete CompletableFuture.supplyAsync(() -> getOrderData(id)) .thenCombine( CompletableFuture.supplyAsync(() -> getCustomerData(id)), (order, customer) -> new OrderWithCustomer(order, customer) );

Model 2: Virtual Threads — High-Concurrency I/O with Simple Code

Virtual threads handle the case where you have many concurrent requests that block on I/O:

// Java 21: enable virtual threads for the Spring Boot HTTP server @Bean public TomcatProtocolHandlerCustomizer<?> virtualThreads() { return handler -> handler.setExecutor( Executors.newVirtualThreadPerTaskExecutor() ); } // With virtual threads, each request gets its own thread — no pool exhaustion // This blocking code is safe under high concurrency: @GetMapping("/orders/{id}") public OrderDto getOrder(@PathVariable Long id) { Order order = orderRepo.findById(id).orElseThrow(); // Blocks, yields carrier thread Customer customer = customerRepo.findById(order.getCustomerId()).orElseThrow(); // Same List<OrderItem> items = itemRepo.findByOrderId(id); // Same return new OrderDto(order, customer, items); // Under 10,000 concurrent requests, this works fine with virtual threads // With platform threads, you'd need async programming to achieve the same concurrency }

Virtual Thread Executor for Async Tasks

@Bean("vtExecutor") public Executor virtualThreadExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); // Creates a new virtual thread for each submitted task // Cheap enough to not need pooling } @Async("vtExecutor") public CompletableFuture<ReportDto> generateReport(Long reportId) { // Each report generation gets its own virtual thread // Blocking calls inside don't waste platform threads Report report = reportRepo.findById(reportId).orElseThrow(); List<DataPoint> data = analyticsService.fetchData(report); // Slow — blocking OK return CompletableFuture.completedFuture(buildReport(report, data)); }

Model 3: Structured Concurrency — Task Groups with Lifecycle

Structured concurrency (finalized in Java 24, preview in 21/22) manages groups of tasks with clear parent-child relationships:

// Java 22+ structured concurrency public DashboardDto buildDashboard(Long userId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { // Fork all three tasks — they run concurrently StructuredTaskScope.Subtask<UserProfile> profileTask = scope.fork(() -> profileService.get(userId)); StructuredTaskScope.Subtask<List<Order>> ordersTask = scope.fork(() -> orderService.getRecent(userId)); StructuredTaskScope.Subtask<List<Alert>> alertsTask = scope.fork(() -> alertService.get(userId)); scope.join(); // Wait for all tasks scope.throwIfFailed(); // Propagate first failure (if any) return new DashboardDto( profileTask.get(), ordersTask.get(), alertsTask.get() ); } // Scope closes: all tasks that haven't completed are cancelled } // ShutdownOnSuccess: take the first successful result, cancel others public String callFirstAvailable(List<String> endpoints, String query) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) { for (String endpoint : endpoints) { scope.fork(() -> callEndpoint(endpoint, query)); } scope.join(); return scope.result(); // First to succeed wins; others are cancelled } }

Key advantage over CompletableFuture: if one task fails, all sibling tasks are cancelled automatically. No leaked threads, no half-completed work.


Choosing the Right Model

ScenarioBest Tool
Parallel independent I/O calls, combine resultsCompletableFuture.allOf()
Sequential async pipeline with error handlingCompletableFuture.thenCompose()
High concurrency HTTP handlers with blocking codeVirtual threads
Task group where one failure should cancel allStructuredTaskScope.ShutdownOnFailure
Race: take first successful responseStructuredTaskScope.ShutdownOnSuccess
Background async jobs with @AsyncVirtual thread executor
Reactive streaming with backpressureWebFlux/Reactor

Thread Pool Sizing

// For CPU-bound work: pool size = number of CPU cores Executor cpuPool = Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors() ); // For I/O-bound work with platform threads: // pool size = cores × (1 + wait_time / service_time) // Example: 8 cores, 90% wait time → pool size = 8 × 10 = 80 // For I/O-bound work with virtual threads: // No pool sizing needed — create one per task Executor ioExecutor = Executors.newVirtualThreadPerTaskExecutor(); // Spring Boot @Async pool for I/O-bound async tasks @Bean public Executor asyncExecutor() { // Java 21: prefer virtual threads for I/O-bound async return Executors.newVirtualThreadPerTaskExecutor(); }

Common Mistakes to Avoid

  • CompletableFuture.get() on the main thread without timeout — if the async operation never completes, the calling thread is blocked forever; always add .orTimeout(5, TimeUnit.SECONDS)
  • Shared mutable state between concurrent tasks — race conditions are subtle; use immutable objects, ConcurrentHashMap, or AtomicLong for shared state
  • Virtual threads with synchronized blockssynchronized pins virtual threads to carrier threads, defeating the concurrency benefit; use ReentrantLock for virtual thread-compatible locking
  • Not handling exceptions in CompletableFuture — an unhandled exception in a future is silently swallowed unless you chain .exceptionally() or call .join()

Summary

Java concurrency in 2026 has three tools for different jobs: CompletableFuture for parallel pipelines and combining independent operations, virtual threads for high-concurrency I/O-bound work without complex async code, and structured concurrency for task groups where failure propagation and lifecycle management matter. Virtual threads have made blocking I/O code viable at high concurrency, reducing the need for reactive programming in many use cases.


JOptimize: Analyze Data Access in Concurrent Code

Concurrent code often hides N+1 patterns — multiple threads each triggering separate queries that could be batched. JOptimize detects these.

Concurrent. Fast. Correct.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.