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()orThread.sleep() - Call
ForkJoinPool.commonPool().awaitQuiescence(Long.MAX_VALUE, TimeUnit.SECONDS) - Provide a custom
ExecutorServicewith 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:
- Task not yet completed (
resultis stillnull) - Task completed with no return value (
runAsync) - Task completed and returned
nullexplicitly
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
parkandunparkto trace the synchronization