RizTech Academy logo
RizTech Academy
ConcurrencyLesson 7 of 835 min

Futures and CompletableFuture

A thread that computes something is only half useful if you cannot get the answer back. Future is how you do that, and CompletableFuture is how you combine several without ending up in a nest of callbacks.

Future: a result that is not ready yet

ExecutorService pool = Executors.newFixedThreadPool(4);

Future<Long> total = pool.submit(() -> billing.monthlyTotalPaise(month));

// … do something else …

long paise = total.get();   // blocks until it is ready

submit returns immediately with a handle. get() waits.

Three things about get() that matter:

It blocks, possibly forever. Always prefer the timeout version:

try {
    long paise = total.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    total.cancel(true);
    throw new IllegalStateException("billing took more than 5s", e);
}

A get() with no timeout in a request handler is how a thread pool fills up with threads waiting on something that will never arrive.

Exceptions arrive wrapped. Anything thrown inside the task comes back as an ExecutionException, with the real one as its cause:

try {
    total.get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
    Throwable actual = e.getCause();   // the exception your task threw
    …
}

Forget getCause() and your logs fill with ExecutionException and no information.

InterruptedException must be handled properly. If you catch it and cannot propagate it, restore the flag:

catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // never swallow this silently
    throw new IllegalStateException("interrupted while billing", e);
}

Swallowing an interrupt means the thread carries on doing work somebody asked it to stop doing, and shutdown hangs.

The limits of Future

Future can only be waited on. You cannot say "when this finishes, do that" — you have to block a thread to find out it finished, which wastes the thread you were trying to save.

Future<Rates> rates = pool.submit(this::loadRates);
Future<List<Delivery>> deliveries = pool.submit(this::loadDeliveries);

// Both run in parallel — good — but the calling thread now sits idle.
Rates r = rates.get();
List<Delivery> d = deliveries.get();
return bill(r, d);

CompletableFuture: composing instead of blocking

CompletableFuture<Rates> rates =
        CompletableFuture.supplyAsync(this::loadRates, pool);

CompletableFuture<List<Delivery>> deliveries =
        CompletableFuture.supplyAsync(this::loadDeliveries, pool);

CompletableFuture<String> report =
        rates.thenCombine(deliveries, this::bill)
             .thenApply(this::format);

String text = report.join();

Same parallelism, but the combining and formatting are described rather than waited for. Nothing blocks until join(), and by then everything is done.

The methods worth knowing

Method Does
supplyAsync(fn, pool) start work, produce a value
runAsync(fn, pool) start work, no value
thenApply(fn) transform the result
thenCompose(fn) chain another future — avoids nesting
thenCombine(other, fn) wait for two, combine
allOf(a, b, c) wait for all
anyOf(a, b, c) wait for the first
exceptionally(fn) recover from a failure
handle((v, e) -> …) deal with both outcomes together

thenApply versus thenCompose is the one people get wrong. thenApply with a function that itself returns a future gives you CompletableFuture<CompletableFuture<T>>. thenCompose flattens it — the same distinction as map versus flatMap on a stream.

Always pass the pool

CompletableFuture.supplyAsync(this::loadRates);        // uses the common pool
CompletableFuture.supplyAsync(this::loadRates, pool);  // uses yours

Without an executor, the work runs on ForkJoinPool.commonPool(), which is shared by the whole JVM and sized to your CPU count minus one. Blocking I/O there starves parallel streams and everything else using it — and it is a genuinely nasty bug to diagnose, because the symptom appears in unrelated code.

Pass your own executor. Every time.

Errors do not throw where you wrote them

CompletableFuture.supplyAsync(this::loadRates, pool)
        .thenApply(this::bill)          // skipped if loadRates threw
        .exceptionally(e -> {
            log.warn("billing failed", e);
            return "Report unavailable";
        });

A failure short-circuits the chain to the first handler. If there is no handler and nobody calls join(), the exception vanishes silently — no stack trace, no log line, nothing.

That is the single most common CompletableFuture bug. Every chain should end in exceptionally, handle, or a join() that is inside a try/catch.

// join throws CompletionException (unchecked); get throws ExecutionException
// (checked). Both wrap the real cause.
try {
    return report.join();
} catch (CompletionException e) {
    throw new ReportFailedException(e.getCause());
}

Virtual threads changed the calculus

The previous lesson introduced virtual threads. They change when you need any of this.

CompletableFuture exists largely so that blocking does not waste a platform thread. A virtual thread that blocks costs almost nothing — the JVM parks it and reuses the carrier thread.

So on Java 21, plain blocking code inside virtual threads is often clearer and just as fast:

try (var scope = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<Rates> rates = scope.submit(this::loadRates);
    Future<List<Delivery>> deliveries = scope.submit(this::loadDeliveries);
    return format(bill(rates.get(), deliveries.get()));   // blocking is fine here
}

That reads like the sequential version and runs like the concurrent one.

Use CompletableFuture when you genuinely need composition — combining, racing, recovering, fanning out. Use virtual threads and straightforward blocking code when you just want several things to happen at once, which is most of the time.

The older advice — "never block" — was advice about platform threads. It is worth knowing why it existed, and worth knowing it no longer applies the same way.

Check your work

What Future.get() costs: it blocks, so always use the timeout form.

Why getCause() matters: task exceptions arrive wrapped in ExecutionException.

Why to restore the interrupt flag: swallowing it means the thread ignores a request to stop, and shutdown hangs.

What Future cannot do: react to completion — you must block to find out.

thenApply versus thenCompose: the second flattens a nested future, like flatMap.

Why always pass an executor: the default common pool is shared JVM-wide, and blocking in it starves unrelated code.

The silent failure: a chain with no exceptionally/handle and no join() loses its exception entirely.

What virtual threads changed: blocking is cheap again, so plain sequential -looking code is often the better answer.

When CompletableFuture still wins: genuine composition — combining, racing, recovering.

Practice

  1. Submit a task returning a value and retrieve it with get(2, SECONDS).
  2. Make the task sleep for five seconds and confirm the timeout fires, then cancel it.
  3. Throw inside a task. Catch ExecutionException and print both it and getCause().
  4. Catch InterruptedException without restoring the flag, then try to shut the pool down cleanly.
  5. Load two things with supplyAsync and combine them with thenCombine. Time it against doing them one after the other.
  6. Write a chain using thenApply with a function returning a future. Look at the type, then fix it with thenCompose.
  7. Throw inside a supplyAsync chain with no exceptionally and no join. Confirm nothing is printed anywhere.
  8. Add exceptionally and confirm you get a usable fallback.
  9. Run twenty blocking tasks on the common pool, then on your own fixed pool. Compare the timings.
  10. Rewrite exercise 5 with newVirtualThreadPerTaskExecutor and plain blocking get(). Decide which you would rather read.

Next: deadlock, and finding it in a thread dump.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship