Skip to content
Home Java CompletableFuture vs Future — Why get() Blocked 200 Threads

CompletableFuture vs Future — Why get() Blocked 200 Threads

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Java 8+ Features → Topic 14 of 16
Future.
🔥 Advanced — solid Java foundation required
In this tutorial, you'll learn
Future.
  • Future.get() blocks — treat it as toxic in request threads.
  • CompletableFuture enables declarative non-blocking pipelines.
  • Always attach exception handlers (exceptionally, handle) to every async chain.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • Core: CompletableFuture extends Future with non-blocking composition
  • Future.get() blocks the calling thread — dangerous in high-throughput systems
  • CompletableFuture enables declarative pipelines: thenApply, thenCompose, allOf
  • Performance: CompletableFuture can reduce thread pool usage by 70% under load
  • Production gotcha: default ForkJoinPool.commonPool() is shared — risks subtle contention
  • Biggest mistake: calling get() inside a pipeline — that reintroduces blocking
🚨 START HERE

Quick Debug Cheat Sheet

Immediate commands and steps for diagnosing async pipeline failures.
🟡

Thread pool exhaustion

Immediate ActionTake thread dump. Count threads in waiting/blocked state.
Commands
jstack -l <pid> | grep -A 10 'java.util.concurrent.FutureTask'
ps -T -p <pid> | wc -l (to count threads)
Fix NowReplace blocking get() with non-blocking thenApply() or thenAccept(). Use a bounded executor.
🟡

Stage never calls callback

Immediate ActionCheck if the future was already complete when callback attached.
Commands
Use CompletableFuture.isDone() in a breakpoint or log.
Attach callback before completing future with complete() or exceptionallyComplete().
Fix NowUse thenApplyAsync(..., executor) to ensure callback runs on a separate thread.
🟡

Combined future (allOf) never completes

Immediate ActionLog isDone() on each component future.
Commands
Array of futures: each.get() in a loop with small timeout to find the stuck one.
Check network calls or database queries that might never complete.
Fix NowAdd timeouts to each component future using completeOnTimeout() or orTimeout() (Java 9+).
Production Incident

The Blocking get() That Exhausted a Thread Pool

A payment service started timing out under moderate load. Thread dumps showed all worker threads blocked on Future.get().
SymptomAverage response time jumped from 50ms to 8s under 50% load. Thread pool metrics showed all 200 threads active, 0 idle.
AssumptionThe team assumed that because they used ExecutorService, the API was non-blocking.
Root causeA developer called future.get(500, TimeUnit.MILLISECONDS) inside an async servlet, which blocked the request thread. With 200 threads and each call taking ~2 seconds, the pool saturated. New requests queued indefinitely, eventually timing out.
FixReplaced Future.get() with CompletableFuture.supplyAsync() + thenApply(). The blocking was eliminated; threads were freed to handle other work. The response time dropped back to 50ms.
Key Lesson
Future.get() always blocks the calling thread — never use it in a web server thread.CompletableFuture lets you attach callbacks without blocking.If you see all threads blocked in Thread::park or LockSupport.parkNanos, look for Future.get().
Production Debug Guide

Symptom → Action steps for Common Future/CompletableFuture Failures

Application threads all blocked in Future.get()Take a thread dump (jstack <pid>). Look for 'Waiting on condition' with java.util.concurrent.FutureTask.get(). Replace with CompletableFuture.
CompletableFuture stage never executesCheck the executor. If using default ForkJoinPool.commonPool(), it may be starved. Use a dedicated ExecutorService.
CompletableFuture completes but callback doesn't fireEnsure the stage is attached before future completes. For already-completed futures, use thenApplyAsync to force a different thread.
AllOf/AnyOf waits indefinitelyCheck each component future: if any is stuck (e.g. on a never-ending network call), the combined future will never complete. Add timeouts per future.

Concurrency bugs are the hardest kind to debug in production. They hide in timing, they only appear under load, and they cost real money. Java's Future interface, introduced in Java 5, was a genuine step forward — but it left developers with a sharp edge they kept cutting themselves on: you couldn't react to a result without blocking a thread. In a high-throughput microservice handling thousands of simultaneous requests, blocking threads is exactly the kind of waste that tanks your throughput and inflates your infrastructure bill.

Java 8 shipped CompletableFuture as the answer. It's not just 'Future but better' — it's a fundamentally different mental model. You stop thinking about 'waiting for a value' and start thinking about 'pipelines of transformations.' The difference is the same as the difference between a synchronous REST call that hangs your thread and a reactive callback chain that frees your thread the moment the work is dispatched. Under the hood, CompletableFuture implements both Future and CompletionStage, giving you backward compatibility while unlocking a rich combinator API.

By the end of this article you'll be able to: explain precisely why Future.get() is dangerous in production, build non-blocking async pipelines with CompletableFuture including fan-out and fan-in patterns, handle exceptions without swallowing them silently, reason about which thread pool is actually executing your callbacks, and answer the tricky interview questions that trip up even experienced Java engineers.

The Problem with Future: Blocking Gets and No Composition

Java's Future was designed for parallelism but not for composition. Once you call Future<V>.get(), the calling thread blocks until the result is available. In a servlet container with a fixed thread pool, even a single get() call that takes 2 seconds blocks one thread for 2 seconds. With 200 threads, throughput collapses to 100 requests/second, no matter how many CPUs you have.

Worse, Future offers no way to chain async operations. To fetch an order, then compute a discount, then send an email, you have to wait for each step and explicitly delegate to new tasks. That leads to nested callbacks, awkward error handling, and thread leaks.

io/thecodeforge/concurrency/FutureBlockingExample.java · JAVA
1234567891011121314151617181920
package io.thecodeforge.concurrency;

import java.util.concurrent.*;

public class FutureBlockingExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        // Bad: blocks the main thread
        Future<String> future = executor.submit(() -> {
            Thread.sleep(2000);
            return "Order-123";
        });

        String result = future.get();  // main thread blocked for 2 seconds
        System.out.println("Result: " + result);

        executor.shutdown();
    }
}
▶ Output
// After 2 seconds: Result: Order-123
⚠ Thread starvation warning
Never call Future.get() on the request thread in a web server. It converts your async code into synchronous blocking, defeating the entire purpose.
📊 Production Insight
In production, a single Future.get() inside a request handler can cause thread pool exhaustion under load.
Even with a short timeout (500ms), you're still blocking — just for less time. That thread can't serve other requests.
Rule: if you see a pattern like executor.submit(...).get(), refactor to CompletableFuture.
🎯 Key Takeaway
Future.get() is blocking — it turns async into sync.
Never use it on a request thread.
Use CompletableFuture for non-blocking composition.
When to use Future vs CompletableFuture
IfYou need a one-off async result and blocking is acceptable (e.g., batch processing)
UseFuture is sufficient — simpler API
IfYou need chaining, error handling, or reactive callbacks
UseUse CompletableFuture
IfYou're inside a web server or reactive framework
UseAlways CompletableFuture — never Future.get()

CompletableFuture: Non-Blocking Async Pipelines

CompletableFuture introduces a declarative model: instead of waiting for values, you define what should happen when they arrive. The method thenApply() transforms a result when it's ready, without blocking. thenCompose() handles chaining when the transformation itself returns a CompletableFuture. Both run on a thread from the common ForkJoinPool by default, but you can provide a custom executor.

This pattern lets you express async workflows as readable pipelines. Each stage runs only when its input is ready, and all stages share the same thread pool efficiently.

io/thecodeforge/concurrency/CompletableFuturePipeline.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637
package io.thecodeforge.concurrency;

import java.util.concurrent.*;

public class CompletableFuturePipeline {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(4);

        CompletableFuture.supplyAsync(() -> fetchOrderFromDB(), executor)
            .thenApply(order -> computeDiscount(order))
            .thenAccept(discounted -> sendEmail(discounted))
            .exceptionally(ex -> {
                System.err.println("Pipeline failed: " + ex.getMessage());
                return null;
            })
            .thenRun(() -> executor.shutdown());

        System.out.println("Main thread is free!");
        // Output: Main thread is free!
        // (order printed later from callback)
    }

    static String fetchOrderFromDB() {
        simulateDelay(1000);
        return "Order-456";
    }
    static String computeDiscount(String order) {
        return order + " with 10% discount";
    }
    static void sendEmail(String message) {
        simulateDelay(500);
        System.out.println("Email sent: " + message);
    }
    static void simulateDelay(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}
▶ Output
Main thread is free!
Email sent: Order-456 with 10% discount
📊 Production Insight
Without a custom executor, stages run on ForkJoinPool.commonPool(). If your app uses parallel streams or other async operations, this pool gets shared, causing resource contention.
Always supply a dedicated executor for mission-critical pipelines.
Use thenApplyAsync() if you need guaranteed execution on a different thread.
🎯 Key Takeaway
thenApply transforms; thenCompose flattens nested futures.
Supply a dedicated executor to avoid common pool starvation.
Async+ suffix forces a thread switch — use it for long or blocking callbacks.

Exception Handling in Async Pipelines

Exception handling in CompletableFuture is explicit. The exceptionally() method catches any exception from the preceding stage and allows you to return a fallback value. handle() is a more flexible variant that receives both result and exception, letting you map successes and failures. Neither swallows the exception silently — you must return something.

One common production mistake: forgetting that an unhandled exception in a stage silently completes the future exceptionally. If no downstream handler exists, that exception disappears, leading to hard-to-diagnose failures.

io/thecodeforge/concurrency/ExceptionHandlingExample.java · JAVA
12345678910111213141516171819
package io.thecodeforge.concurrency;

import java.util.concurrent.*;

public class ExceptionHandlingExample {
    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> {
            if (Math.random() > 0.5) throw new RuntimeException("DB timeout");
            return "Data";
        })
        .thenApply(data -> data + " processed")
        .exceptionally(ex -> {
            System.err.println("Exception: " + ex.getMessage());
            return "Fallback: cached data";
        })
        .thenAccept(System.out::println)
        .join(); // only for demonstration, normally avoid
    }
}
▶ Output
// About 50% of runs: Exception: DB timeout
// Fallback: cached data
📊 Production Insight
If you use thenApply without attaching exceptionally, an exception in the pipeline propagates silently. The CompletableFuture chain stops, and no callback runs.
Always attach a terminal handler (exceptionally, whenComplete, or handle) to every async pipeline in production.
Use handle() when you need to decide fallback vs re-throw conditions.
🎯 Key Takeaway
Unhandled exceptions in CompletableFuture pipelines are lost.
Attach exceptionally() to every chain in production.
handle() gives you both result and exception in one callback.

Combining Multiple Async Tasks: allOf and anyOf

Real systems often need to wait for several independent async results. allOf() returns a CompletableFuture<Void> that completes when all provided futures complete. But it doesn't aggregate their results — you must combine them manually using a collector. anyOf() completes when the first future completes, returning its result.

A common performance trap: passing thousands of futures to allOf() without batching. The allOf implementation registers a completion callback on each future. With 10,000 futures, that's 10,000 callbacks and significant memory pressure. Instead, batch into groups.

io/thecodeforge/concurrency/AllOfExample.java · JAVA
12345678910111213141516171819202122232425
package io.thecodeforge.concurrency;

import java.util.concurrent.*;
import java.util.stream.*;

public class AllOfExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(10);

        // Simulate 3 independent API calls
        CompletableFuture<String> product = CompletableFuture.supplyAsync(() -> "Laptop", executor);
        CompletableFuture<Double> price = CompletableFuture.supplyAsync(() -> 1200.0, executor);
        CompletableFuture<Integer> stock = CompletableFuture.supplyAsync(() -> 50, executor);

        CompletableFuture<Void> combined = CompletableFuture.allOf(product, price, stock);

        // Aggregate results
        CompletableFuture<String> result = combined.thenApply(v ->
            product.join() + " - $" + price.join() + " - Stock: " + stock.join()
        );

        System.out.println(result.get()); // Output: Laptop - $1200.0 - Stock: 50
        executor.shutdown();
    }
}
▶ Output
Laptop - $1200.0 - Stock: 50
📊 Production Insight
For thousands of tasks, allOf() creates O(n) internal completion callbacks. If the tasks are numerous, batch them into groups of 100–500.
Use anyOf() for racing requests (e.g., call two services, use the faster response).
But anyOf() doesn't cancel the slower futures — you must handle that explicitly with completeOnTimeout.
🎯 Key Takeaway
allOf() waits for all, but doesn't collect results — you must join manually.
anyOf() races futures; the loser keeps running unless cancelled.
Batch large allOf() calls to avoid memory pressure from internal callbacks.

Thread Pool Selection: The Silent Pitfall

By default, CompletableFuture's async methods (supplyAsync, thenApplyAsync, etc.) use ForkJoinPool.commonPool(). This pool is shared across all CompletableFutures in the JVM, plus parallel streams, plus other libraries. Its parallelism defaults to Runtime.availableProcessors() - 1. In a containerised environment (e.g., 2 CPU cores), that's just 1 worker thread. One long-running callback can starve every other async operation in the system.

Always supply a dedicated ExecutorService for production async pipelines. Size it based on I/O vs. CPU workload. For I/O-heavy work, larger pools (2x-4x core count) improve throughput. For CPU-bound work, match core count.

io/thecodeforge/concurrency/DedicatedExecutorExample.java · JAVA
123456789101112131415161718192021222324252627282930
package io.thecodeforge.concurrency;

import java.util.concurrent.*;

public class DedicatedExecutorExample {
    public static void main(String[] args) {
        // For I/O-heavy tasks: use a pool sized for concurrency, not cores
        ExecutorService ioPool = Executors.newFixedThreadPool(50);

        CompletableFuture.supplyAsync(() -> fetchFromAPI(), ioPool)
            .thenApplyAsync(data -> transformWithCPU(data), ioPool)
            .thenAcceptAsync(System.out::println, ioPool)
            .exceptionally(ex -> {
                System.err.println("Error: " + ex.getMessage());
                return null;
            })
            .thenRun(() -> ioPool.shutdown());

        // Main thread remains free
    }

    static String fetchFromAPI() {
        // Simulate network call
        return "{\"status\":\"ok\"}";
    }
    static String transformWithCPU(String data) {
        // Simulate CPU compute
        return data.toUpperCase();
    }
}
▶ Output
{"STATUS":"OK"}
📊 Production Insight
The common ForkJoinPool is a concealed dependency. If your app uses multiple async libraries, they all compete for the same small pool.
In production, always create a dedicated executor for each subsystem.
For long-running tasks, supply an executor with a sufficiently large queue — or risk RejectedExecutionException.
🎯 Key Takeaway
Default pool = ForkJoinPool.commonPool() — shared, small, risky.
Always pass an explicit executor for production pipelines.
Size I/O pools large; size CPU pools equal to cores.
🗂 Future vs CompletableFuture at a Glance
FeatureFuture (Java 5)CompletableFuture (Java 8)
BlockingYes — get() blocks the calling threadNo — callbacks fire on executor threads
ChainingManual — submit a new task and waitDeclarative — thenApply, thenCompose
Exception handlingCatch ExecutionException around get()exceptionally(), handle(), whenComplete()
Combining multipleManual — awkward with CompletionServiceallOf(), anyOf()
Completing manuallyNot possiblecomplete(), completeExceptionally()
Timeoutsget(timeout, unit) — still blocksorTimeout(), completeOnTimeout() (Java 9+)

🎯 Key Takeaways

  • Future.get() blocks — treat it as toxic in request threads.
  • CompletableFuture enables declarative non-blocking pipelines.
  • Always attach exception handlers (exceptionally, handle) to every async chain.
  • Provide a dedicated executor for production pipelines — never rely on the common pool.
  • Use allOf for fan-in, but batch large inputs to avoid O(n) callback overhead.
  • Async stages (thenApplyAsync) force a thread switch — use for long or blocking callbacks.

⚠ Common Mistakes to Avoid

    Calling get() inside a web server thread
    Symptom

    Thread pool exhaustion under moderate load; thread dumps show many threads blocked on Future.get()

    Fix

    Replace with CompletableFuture and attach callbacks. Never call get() except in daemon threads or initialisers.

    Not attaching exception handlers in async chains
    Symptom

    Silent failures — pipeline stage doesn't execute, and no error is logged

    Fix

    Always chain exceptionally() or handle() before the terminal operation. Log exceptions there.

    Using default ForkJoinPool in production web apps
    Symptom

    Mysterious slowdowns under load; parallel streams also slow down

    Fix

    Provide a dedicated ExecutorService for each async pipeline or subsystem.

    Forgetting that thenApply runs on the same thread as the previous stage
    Symptom

    Long-running callbacks block the executor thread, stalling other tasks

    Fix

    Use thenApplyAsync() to force a thread switch when callbacks involve blocking operations (I/O, DB).

    Passing thousands of futures to allOf() without batching
    Symptom

    Memory pressure from internal callbacks; GC overhead

    Fix

    Batch futures into groups of 100–500 and combine iteratively.

Interview Questions on This Topic

  • QWhat is the difference between Future and CompletableFuture?JuniorReveal
    Future (Java 5) represents a result that will be available later. Its get() method blocks until the result is ready. CompletableFuture (Java 8) extends Future and adds the ability to attach callbacks (thenApply, thenAccept) that execute non-blockingly when the result becomes available. It also supports composition (thenCompose, allOf) and explicit completion (complete, completeExceptionally).
  • QHow do you combine multiple independent async tasks and process their results together?Mid-levelReveal
    Use CompletableFuture.allOf() which returns a CompletableFuture<Void> that completes when all provided futures complete. To collect results, combine allOf().thenApply(v -> { ... }) and call each future's join() inside the callback. For example: CompletableFuture<String> f1 = ...; CompletableFuture<Integer> f2 = ...; CompletableFuture.allOf(f1, f2).thenApply(v -> f1.join() + f2.join()); For the first successful result, use anyOf().
  • QWhat are the thread pool implications of using CompletableFuture in a web application?SeniorReveal
    By default, async stages run on ForkJoinPool.commonPool(), which has a low parallelism (usually #CPU-1). This pool is shared across the entire JVM — parallel streams, other CompletableFutures, etc. In a web server, this can cause contention and starvation. Always provide a dedicated ExecutorService sized appropriately for the workload: I/O-bound -> large pool (50-100 threads), CPU-bound -> small pool (matching cores). Use thenApplyAsync() to force a thread switch.
  • QHow do you handle timeouts in CompletableFuture?SeniorReveal
    Java 9 introduced orTimeout(long timeout, TimeUnit unit) which completes the future exceptionally with a TimeoutException after the specified delay. Also completeOnTimeout(value, timeout, unit) that completes with a default value on timeout. For Java 8, you can manually create a ScheduledExecutorService to forcefully complete a future after a delay using completeExceptionally().

Frequently Asked Questions

What is CompletableFuture vs Future in simple terms?

CompletableFuture vs Future is a fundamental concept in Java. Think of it as a tool — once you understand its purpose, you'll reach for it constantly.

Can I convert a Future to a CompletableFuture?

Yes, you can use CompletableFuture.supplyAsync() inside the original task submission, or use a helper that polls the Future with a ScheduledExecutorService. However, the cleanest approach is to rewrite the original task to return a CompletableFuture directly.

What happens if I never attach a callback to a CompletableFuture?

The future still executes if it was started (via supplyAsync). The result sits in the future object. If the future is never queried or joined, it may waste memory if it holds large results. Always design your pipelines to terminate with a callback or join (though join blocks).

How do I cancel a CompletableFuture?

call cancel(mayInterruptIfRunning). Cancellation propagates through the chain: if you cancel a stage, downstream stages also complete exceptionally with CancellationException. Note: cancel may not actually stop the running task if it doesn't check the interrupted flag.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousPattern Matching in JavaNext →Text Blocks in Java 15
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged