Mastering CompletableFuture: From Daemon Threads to Advanced Usage

The Daemon Thread Traps in CompletableFuture

When using CompletableFuture, developers often encounter a situation where the program terminates before the asynchronous task completes. Consider this example:

public class MainTest {
    public static void main(String[] args) throws Exception {
        CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + "-Lady: I start making up. Will call you when done.");
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "Makeup done.";
        }).whenComplete((result, exception) -> {
            if (exception == null) {
                System.out.println(Thread.currentThread().getName() + result);
            } else {
                System.out.println(Thread.currentThread().getName() + "Lady stood you up.");
            }
        });
        System.out.println(Thread.currentThread().getName() + "-While waiting, I can do my own things.");
        Thread.currentThread().join();  // Without this, program may exit prematurely
    }
}

Without the Thread.currentThread().join() line, the main thread might finish and terminate the JVM before the asynchronous task completes. Why? Because by default, CompletableFuture uses the ForkJoinPool.commonPool(), whose threads are daemon threads. JVM exits when all user threads finish, evenif daemon threads are still running.

You can verify this by setting a breakpoint inside the lambda and inspecting the thread properties:

// Inside worker thread:
thread.isDaemon()  // true

The ForkJoinPool registers new workers as daemon threads in registerWorker(). This is by design, but it means your main thread must stay alive long enough for asynchronous tasks to complete.

To ensure completion, you can:

  • Use Thread.currentThread().join() or Thread.sleep()
  • Call ForkJoinPool.commonPool().awaitQuiescence(Long.MAX_VALUE, TimeUnit.SECONDS)
  • Provide a custom ExecutorService with user threads (e.g., newFixedThreadPool)
ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> {
    // async task
    return "Done";
}, executor);
// No join needed; executor threads are non-daemon

Comparing Future and CompletableFuture

Future (introduced in Java 5) requires blocking .get() to retrieve results, which defeats the purpose of asynchrony. CompletableFuture (Java 8) solves this with callback-based design.

Feature Future CompletableFuture
Blocking get Yes No (callbacks)
Chaining No Yes
Combining No Yes
Exception handling Manual Built-in

Callback Example

// Future style (blocking)
Future<String> future = executor.submit(() -> {
    Thread.sleep(5000);
    return "Makeup done";
});
System.out.println(Thread.currentThread().getName() + "-Blocked!");
String result = future.get();  // blocks here

// CompletableFuture style (non-blocking)
CompletableFuture
    .supplyAsync(() -> {
        System.out.println(Thread.currentThread().getName() + "-Making up...");
        sleep(5);
        return "Makeup done";
    })
    .whenComplete((result, exception) -> {
        if (exception == null) {
            System.out.println(Thread.currentThread().getName() + "-Callback: " + result);
        }
    });
System.out.println(Thread.currentThread().getName() + "-I can do other things now.");

whenComplete runs in the same thread as the task. To execute the callback in a different thread, use whenCompleteAsync:

CompletableFuture
    .supplyAsync(() -> "Makeup done")
    .whenCompleteAsync((result, ex) -> {
        System.out.println(Thread.currentThread().getName() + "-Async callback");
    });

Serial Execution with thenApply

Suppose two sequential tasks: makeup first, then dres selection. thenApply chains them:

ExecutorService executor = Executors.newFixedThreadPool(10);

CompletableFuture<String> makeup = CompletableFuture.supplyAsync(() -> {
    System.out.println(Thread.currentThread().getName() + "-Makeup...");
    sleep(5);
    return "Makeup done";
}, executor);

CompletableFuture<String> dress = makeup.thenApply(makeupResult -> {
    System.out.println(Thread.currentThread().getName() + "-Dress selection after: " + makeupResult);
    sleep(1);
    return makeupResult + " + Dress done";
});

dress.thenAccept(finalResult -> {
    System.out.println(Thread.currentThread().getName() + "-" + finalResult);
});

To run the second task in a different thread, use thenApplyAsync.

Parallel Execution with anyOf and allOf

anyOf: First CompletableFuture wins

Imagine two ladies getting ready; you go out with the one who finishes first:

CompletableFuture<String> ladyA = CompletableFuture.supplyAsync(() -> {
    int time = ThreadLocalRandom.current().nextInt(5);
    sleep(time);
    System.out.println(Thread.currentThread().getName() + "-Lady A done in " + time);
    return "A";
}, executor);

CompletableFuture<String> ladyB = CompletableFuture.supplyAsync(() -> {
    int time = ThreadLocalRandom.current().nextInt(5);
    sleep(time);
    System.out.println(Thread.currentThread().getName() + "-Lady B done in " + time);
    return "B";
}, executor);

CompletableFuture<Object> winner = CompletableFuture.anyOf(ladyA, ladyB);
winner.thenAccept(result -> {
    System.out.println("First ready: " + result);
});

allOf: Wait for all

CompletableFuture<String> restaurant1 = winner.thenApplyAsync(w -> "Eat at ShaXian", executor);
CompletableFuture<String> restaurant2 = winner.thenApplyAsync(w -> "Eat at HuangMenJi", executor);

CompletableFuture<Void> all = CompletableFuture.allOf(restaurant1, restaurant2);
all.thenAccept(v -> {
    System.out.println("Both restaurants considered");
});

Note: allOf returns CompletableFuture<Void>; you need to combine results manually if needed.

The get Method and Null Handling

CompletableFuture distinguishes three cases when result is null:

  1. Task not yet completed (result is still null)
  2. Task completed with no return value (runAsync)
  3. Task completed and returned null explicitly

Internally, CompletableFuture wraps null in an AltResult object (a singleton sentinel). The completeNull method assigns AltResult.NULL to result. This allows the get() logic to differentiate:

if (r == null) {
    // not completed yet
} else if (r instanceof AltResult) {
    // completed with null value or exception
} else {
    // completed with actual value
}

Behind the Scenes: Spinning and Parking

The waitingGet() method uses a brief spin-wait before parking to improve latency:

private Object waitingGet(boolean interruptible) {
    Signaller q = new Signaller(interruptible);
    Object r;
    while ((r = result) == null) {
        // short spin
        if (spins > 0) {
            --spins;
            Thread.onSpinWait();
        } else {
            // park the thread
            q.block();  // inside Signaller.block()
        }
    }
    return r;
}

The block() method eventually calls LockSupport.park(). The unpark happens when the task completes, via postComplete() -> tryUnpush() -> LockSupport.unpark().

To debug this flow:

  • Run your program with get() call
  • Pause in IDE (camera icon)
  • See main thread parked at Signaller.block(CompletableFuture.java:1707)
  • Set breakpoint both at park and unpark to trace the synchronization

Tags: CompletableFuture daemon threads Asynchronous Programming Future vs CompletableFuture ForkJoinPool

Posted on Sat, 25 Jul 2026 16:59:37 +0000 by as22607