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.
JOptimize Team
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.
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 }
// 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();
// 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) );
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 }
@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)); }
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.
| Scenario | Best Tool |
|---|---|
| Parallel independent I/O calls, combine results | CompletableFuture.allOf() |
| Sequential async pipeline with error handling | CompletableFuture.thenCompose() |
| High concurrency HTTP handlers with blocking code | Virtual threads |
| Task group where one failure should cancel all | StructuredTaskScope.ShutdownOnFailure |
| Race: take first successful response | StructuredTaskScope.ShutdownOnSuccess |
| Background async jobs with @Async | Virtual thread executor |
| Reactive streaming with backpressure | WebFlux/Reactor |
// 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(); }
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)ConcurrentHashMap, or AtomicLong for shared statesynchronized pins virtual threads to carrier threads, defeating the concurrency benefit; use ReentrantLock for virtual thread-compatible locking.exceptionally() or call .join()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.
Concurrent code often hides N+1 patterns — multiple threads each triggering separate queries that could be batched. JOptimize detects these.
Concurrent. Fast. Correct.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.