Future and CompletableFuture in Java

A Future is a placeholder for a result that is not ready yet. CompletableFuture adds composition, so results can be chained without blocking.

Future

ExecutorService executor = Executors.newFixedThreadPool(2);

Future<Integer> future = executor.submit(() -> {
    Thread.sleep(500);
    return 42;
});

System.out.println(future.isDone());          // probably false

Integer result = future.get();                // blocks until ready
Integer withTimeout = future.get(2, TimeUnit.SECONDS);

future.cancel(true);                          // interrupt if still running

What Future cannot do

  • It cannot tell you when it completes; you must poll or block.
  • It cannot be chained to another computation.
  • It cannot be combined with a second future.
  • It has no way to express failure other than throwing from get.

CompletableFuture, added in Java 8, addresses all four.

Creating a CompletableFuture

CompletableFuture<String> async = CompletableFuture.supplyAsync(() -> fetch(url));
CompletableFuture<Void> task = CompletableFuture.runAsync(() -> cleanUp());
CompletableFuture<String> ready = CompletableFuture.completedFuture("cached");

CompletableFuture<String> onMyPool =
        CompletableFuture.supplyAsync(() -> fetch(url), executor);

Without an explicit executor these run on the common ForkJoin pool, which is sized for CPU work and shared with parallel streams. Always pass your own executor for anything that blocks.

Chaining

CompletableFuture<Integer> pipeline = CompletableFuture
        .supplyAsync(() -> fetch(url), executor)   // String
        .thenApply(String::strip)                   // transform
        .thenApply(String::length);                 // transform again

System.out.println(pipeline.join());
MethodThe functionProduces
thenApplyTakes the value, returns a valueCompletableFuture<R>
thenAcceptTakes the value, returns nothingCompletableFuture<Void>
thenRunTakes nothingCompletableFuture<Void>
thenComposeReturns another futureA flattened future
thenCombineMerges two independent futuresOne future

thenApply against thenCompose

// Nested, which is rarely what you want
CompletableFuture<CompletableFuture<Note>> nested =
        findId(slug).thenApply(id -> loadNote(id));

// Flattened
CompletableFuture<Note> flat =
        findId(slug).thenCompose(id -> loadNote(id));

This is the same distinction as map and flatMap on a stream: use thenCompose when the function itself returns a future.

Combining independent work

CompletableFuture<Profile> profile = CompletableFuture.supplyAsync(this::loadProfile, executor);
CompletableFuture<List<Note>> notes = CompletableFuture.supplyAsync(this::loadNotes, executor);

CompletableFuture<Dashboard> dashboard =
        profile.thenCombine(notes, Dashboard::new);   // both run at once

Dashboard result = dashboard.join();
List<CompletableFuture<String>> futures = urls.stream()
        .map(url -> CompletableFuture.supplyAsync(() -> fetch(url), executor))
        .toList();

CompletableFuture<Void> all = CompletableFuture
        .allOf(futures.toArray(new CompletableFuture[0]));

List<String> results = all.thenApply(ignored ->
        futures.stream().map(CompletableFuture::join).toList()).join();

allOf completes when every future does; anyOf completes with the first. Note that allOf returns Void, so the results are collected afterwards as above.

Handling failure

CompletableFuture<String> safe = CompletableFuture
        .supplyAsync(() -> fetch(url), executor)
        .exceptionally(error -> {
            logger.warning("Fetch failed: " + error.getMessage());
            return "";                         // a fallback value
        });

CompletableFuture<String> handled = CompletableFuture
        .supplyAsync(() -> fetch(url), executor)
        .handle((value, error) -> error == null ? value : "fallback");

CompletableFuture<String> observed = CompletableFuture
        .supplyAsync(() -> fetch(url), executor)
        .whenComplete((value, error) -> log(value, error));   // observes, changes nothing
MethodRuns onCan replace the result
exceptionallyFailure onlyYes
handleBothYes
whenCompleteBothNo
An exception inside a stage is wrapped in a CompletionException. Call getCause() to reach the original, or the log will only ever show the wrapper.

get compared with join

future.get();    // throws checked InterruptedException and ExecutionException
future.join();   // throws unchecked CompletionException

join is usually more convenient inside a lambda, because the built in functional interfaces cannot declare checked exceptions.

Async variants

.thenApply(this::transform)                 // may run on the completing thread
.thenApplyAsync(this::transform)            // runs on the common pool
.thenApplyAsync(this::transform, executor)  // runs on a pool you control

The plain form may execute on whichever thread completed the previous stage. When a stage does anything slow, use the Async variant with an explicit executor so a caller thread is never hijacked.

A worked example

public CompletableFuture<String> buildSummary(long noteId) {
    return CompletableFuture
            .supplyAsync(() -> repository.load(noteId), executor)
            .thenCompose(note -> CompletableFuture
                    .supplyAsync(() -> author.load(note.authorId()), executor)
                    .thenApply(name -> note.title() + " by " + name))
            .orTimeout(3, TimeUnit.SECONDS)
            .exceptionally(error -> "Summary unavailable");
}

orTimeout and completeOnTimeout, added in Java 9, put a bound on a stage without any extra scheduling code.

Structured concurrency

// Preview in recent releases; check availability for your version
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var profile = scope.fork(this::loadProfile);
    var notes = scope.fork(this::loadNotes);

    scope.join();
    scope.throwIfFailed();

    return new Dashboard(profile.get(), notes.get());
}

Structured concurrency ties the lifetime of subtasks to a scope: if one fails the rest are cancelled, and nothing outlives the block. It is the direction modern Java concurrency is heading, and it pairs naturally with virtual threads.

Common mistakes

  • Calling get() immediately after creating the future, which makes it synchronous.
  • Using the common pool for blocking work and starving parallel streams.
  • Using thenApply where thenCompose is needed, producing a nested future.
  • Forgetting error handling, so a failure disappears silently.
  • Logging a CompletionException without unwrapping the cause.
  • Assuming allOf returns the results; it returns Void.

Best practices

  • Pass an explicit executor for anything that blocks.
  • Compose stages rather than blocking between them.
  • Always terminate a chain with exceptionally or handle.
  • Use thenCompose for dependent work and thenCombine for independent work.
  • Set a timeout with orTimeout.
  • Unwrap CompletionException before logging.

Practice

  1. Fetch two independent values concurrently and combine them into one object.
  2. Explain why thenApply returns a nested future when the function returns a future.
  3. Add a fallback so a failing call returns a default instead of propagating.
  4. Why is join often easier than get inside a lambda?
  5. Convert a chain of blocking calls into a non blocking pipeline and describe what changed.

Conclusion

A Future holds a pending result; a CompletableFuture lets you describe what happens next without blocking. Compose rather than wait, always handle failure, and give blocking work its own executor.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.