Java · Java Concurrency (java.util.concurrent)

Java: Executors, Futures and CompletableFuture

How a thread pool grows and what its queue hides, sizing and backpressure, shutting down, futures and timeouts, and CompletableFuture pipelines - and why blocking in the common pool hurts.

  • 8 min read
  • 15 practice questions
  • 1 graded problem
On this page
  1. Description
  2. Submitting work and reading results
  3. Shutting down
  4. How a pool grows
  5. How many threads?
  6. CompletableFuture
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

Description

Creating a new thread for every piece of work is like hiring a new cashier for every customer: slow to set up, and with a sudden crowd you'd have thousands of cashiers and no room for them. An executor is the better arrangement: a fixed team of threads (a thread pool) and a waiting line of tasks. You hand it work; it decides which thread runs it and when.

Almost all concurrent Java code runs on executors: web servers, message consumers, scheduled jobs, parallel calls to other services. Their defaults are also behind some classic outages, like a service that ran out of memory because work piled up in a queue nobody could see. This skill covers how to use executors, how a pool decides to grow, and how to chain asynchronous work with CompletableFuture.

Here's the path, from easiest to most technical:

  1. Submitting work and getting results back with a Future.
  2. Shutting a pool down properly.
  3. How a pool grows, where work waits, and what happens when it's full.
  4. How many threads a pool should have.
  5. CompletableFuture: describing what happens when a result arrives.

Submitting work and reading results

ExecutorService pool = Executors.newFixedThreadPool(4);         // 4 worker threads

Future<Quote> future = pool.submit(() -> fetchQuote("ACME"));   // runs on a pool thread
Quote quote = future.get(2, TimeUnit.SECONDS);                  // wait for the result, at most 2 s

A Future is a receipt for work that's still running. A few things to know about it:

  • Exceptions are kept inside the Future. If the task throws, nothing is printed; the exception waits there until someone calls get(), which throws an ExecutionException with the original as its cause. A future nobody reads is a failure nobody sees.
  • get(timeout) stops waiting, not the task. It throws a TimeoutException, but the task keeps running. To stop it, call future.cancel(true), which interrupts the thread (and only helps if the task responds to interruption).
  • Periodic tasks stop on the first exception. A task scheduled with scheduleAtFixedRate that throws once is never run again, silently. Catch exceptions inside periodic tasks.

Shutting down

A pool's threads keep the program alive until you shut it down:

pool.shutdown();                                   // accept no new tasks; finish the queued ones
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
    pool.shutdownNow();                            // interrupt running tasks, drop the queued ones
}

shutdown() returns immediately; it doesn't wait. awaitTermination does the waiting. Since Java 19 you can also write try (ExecutorService pool = ...) { ... }, which shuts the pool down and waits at the end of the block.

How a pool grows

Every pool from the Executors factory methods is a ThreadPoolExecutor with five settings: a core size, a maximum size, a queue, a keep-alive time for extra threads, and a rejection policy. When a task arrives, the pool decides like this:

yes

no

yes

no

yes

no

New task

Fewer than core threads?

Start a new thread for it

Room in the queue?

Put it in the queue to wait

Fewer than max threads?

Start an extra thread for it

Reject it

yes

no

yes

no

yes

no

New task

Fewer than core threads?

Start a new thread for it

Room in the queue?

Put it in the queue to wait

Fewer than max threads?

Start an extra thread for it

Reject it

The surprise is the order: the queue fills up before any extra threads start. A pool with core 2, max 10 and a queue of 100 runs ten slow tasks on just two threads, with eight waiting.

That explains the two factory methods to be careful with:

  • newFixedThreadPool(n) has an unbounded queue. It never grows and never rejects; when work arrives faster than it finishes, the queue just grows in memory. When a slow downstream service made tasks take seconds instead of milliseconds, that queue is how a service quietly ran out of memory.
  • newCachedThreadPool() has no queue capacity and no thread limit, so a burst of slow tasks can create thousands of threads.

The safer setup is a pool with a bounded queue and a deliberate rejection policy:

PolicyWhen the pool is full
AbortPolicy (the default)throws RejectedExecutionException
CallerRunsPolicyruns the task on the thread that submitted it, which slows the submitter down (natural backpressure)
DiscardPolicysilently drops the task (rarely what you want)

How many threads?

It depends on what the tasks spend their time doing:

  • CPU-bound work (calculations, parsing, compression) can't go faster with more threads than cores: Runtime.getRuntime().availableProcessors().
  • I/O-bound work (database queries, HTTP calls) spends most of its time waiting, so more threads help. A classic estimate is cores × (1 + wait time ÷ compute time): a task that waits 90 ms for every 10 ms of CPU needs about 10 threads per core.

Measure rather than guess, and give separate dependencies separate pools, so one slow service can't tie up every thread (the bulkhead pattern). For lots of blocking I/O, Java 21's virtual threads remove the sizing problem; the Java 21 module covers them.

CompletableFuture

future.get() makes you wait. A CompletableFuture lets you say what should happen when the result arrives, as a chain of steps:

CompletableFuture<Receipt> receipt =
    CompletableFuture.supplyAsync(() -> loadCart(userId), ioPool)   // start: run on ioPool
        .thenApply(cart -> price(cart))                             // then: turn a value into a value
        .thenCompose(total -> payments.charge(userId, total))       // then: start more async work
        .exceptionally(e -> Receipt.failed(e));                     // if anything above failed

The steps you'll use most:

MethodUse it when the next step...
thenApply(fn)turns the value into another value
thenCompose(fn)returns another CompletableFuture (flattens it, like flatMap)
exceptionally(fn)should replace a failure with a fallback value
handle(fn)needs to see either the value or the exception
allOf(a, b, c)must wait for several futures (then read each one)

Three rules prevent most mistakes:

  1. Use thenCompose for a step that returns a future. With thenApply, you'd get a CompletableFuture<CompletableFuture<Receipt>>.
  2. A failure skips the remaining steps until it reaches an exceptionally or handle. If loadCart throws, price and charge never run.
  3. Pass your own executor for anything that blocks. Without one, supplyAsync runs on the JVM-wide common pool, which has only about one thread per CPU core and is shared with parallel streams and other libraries. Blocking HTTP or database calls there slow down everything else in the program.

To get the final value, join() waits and throws an unchecked CompletionException on failure; get() throws a checked ExecutionException. Both wrap the original exception.

Common mistakes

MistakeWhat happensDo this instead
newFixedThreadPool under overloadthe queue grows until memory runs outa bounded queue and a rejection policy
newCachedThreadPool with slow tasksthousands of threadsa bounded pool
Expecting extra threads before the queue is fullonly core threads runsize the core for normal load
Never calling get() on a Futurefailures disappearread it, or catch inside the task
A periodic task that can throwit stops running, silentlycatch everything inside it
Assuming get(timeout) stops the taskit keeps runningcancel(true), and make the task interruptible
Assuming shutdown() waitsresults aren't ready yetawaitTermination, or try-with-resources
Blocking calls in supplyAsync without an executorthe shared common pool stallspass a dedicated executor
thenApply with a function returning a futurea future of a futurethenCompose

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. A pool has core 2, max 10 and a queue of 100. Ten slow tasks arrive. How many threads run them?

Show answer

Two. Tasks 3 to 10 wait in the queue. Extra threads only start once the queue is full.

2. A task submitted with submit throws an exception. Where does the exception go?

Show answer

Into the Future. Nothing is printed; calling get() throws an ExecutionException whose cause is the original exception.

3. What's the difference between thenApply and thenCompose?

Show answer

thenApply takes a function that returns a plain value. thenCompose takes a function that returns another CompletableFuture and flattens it; with thenApply you'd end up with a future of a future.

4. Where does CompletableFuture.supplyAsync(() -> httpCall()) run, and why does it matter?

Show answer

On the common pool, which is shared by the whole JVM and sized to about one thread per core. A blocking HTTP call there holds one of those few threads and slows down everything else using it. Pass your own executor.

Key takeaways

  • Use executors, not raw threads, and always read your Futures (or catch inside the task).
  • A pool fills core threads, then the queue, then extra threads, then rejects: bound the queue and choose a rejection policy.
  • Size pools by workload: about the core count for CPU work, more for I/O.
  • shutdown() plus awaitTermination (or try-with-resources) to stop cleanly.
  • CompletableFuture: thenCompose to chain futures, exceptionally or handle to recover, and your own executor for blocking steps.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Executors in five lines

ExecutorService pool = new ThreadPoolExecutor(8, 16, 30, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(500), new ThreadPoolExecutor.CallerRunsPolicy());   // bounded, with backpressure
Future<Quote> f = pool.submit(() -> fetchQuote(id));     // exceptions wait inside f
Quote q = f.get(2, TimeUnit.SECONDS);                    // timeout stops waiting, not the task
pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS);   // or try-with-resources (Java 19+)

How a pool places a task

core threads → queue → extra threads up to max → reject. The queue fills before extra threads start.

FactoryCore / maxQueueRisk
newFixedThreadPool(n)n / nunboundedmemory fills under overload
newCachedThreadPool()0 / unlimitednone (SynchronousQueue)thousands of threads
newSingleThreadExecutor()1 / 1unboundedsame as fixed
newVirtualThreadPerTaskExecutor() (21)a virtual thread per tasknonestill needs timeouts

Rejection policies: AbortPolicy (throw, default), CallerRunsPolicy (backpressure), DiscardPolicy (silent drop).

Sizing

  • CPU-bound: about availableProcessors() threads.
  • I/O-bound: cores × (1 + wait ÷ compute); measure; one pool per dependency (bulkheads).
  • Little's law: max throughput = threads ÷ latency. 20 threads at 200 ms = 100/s; at 2 s = 10/s.

CompletableFuture

supplyAsync(() -> load(id), ioPool)          // always pass an executor for blocking work
    .thenApply(this::price)                  // value -> value
    .thenCompose(payments::charge)           // value -> future (flattened)
    .exceptionally(e -> fallback(e));        // e may be a CompletionException: check getCause()

allOf then join() each; thenCombine for two; join() throws CompletionException, get() throws ExecutionException.

The traps

  • Unread Futures hide failures; a periodic task that throws stops forever.
  • get(timeout) and orTimeout don't stop the work.
  • shutdown() doesn't wait.
  • supplyAsync without an executor uses the small, shared common pool.
  • thenApply returning a future gives a future of a future.

Staff-level points, one line each

  • ctl packs run state and worker count; core threads start lazily (prestartAllCoreThreads), never time out unless allowed.
  • execute + exception kills and replaces the worker; submit keeps it in the Future.
  • Monitor queue size, active count, rejections, and wait/run times (beforeExecute/afterExecute).
  • Common pool parallelism = CPUs - 1 (1 thread on a 1-CPU container); work stealing suits CPU tasks, not blocking ones.
  • A stage on an already-completed future runs on the caller's thread.
  • exceptionally gets the bare exception from completeExceptionally, but a CompletionException from supplyAsync or earlier stages.
  • CompletableFuture.cancel(true) doesn't interrupt the running task.

Say it in an interview

  • "I'd use a bounded queue with CallerRunsPolicy, so overload slows producers instead of filling memory."
  • "Twenty threads at two seconds each is ten requests a second, so we need timeouts and a rejection path."
  • "Blocking calls get their own executor, never the common pool."
  • "I'd unwrap CompletionException before deciding how to recover."

Practice

This skill comes with 15 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Java: Executors, Futures and CompletableFuture free

Price a Basket in Parallel

MediumProCoding problemJava

A grocery app in Austin shows a basket's total at checkout. Prices come from a remote service that takes about 150 ms per item, so pricing a 24-item basket one item at a time takes 3.6 seconds. Customers won't wait that long.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Java: Executors, Futures and CompletableFuture free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime