Definition
- Default pool –
ForkJoinPool.commonPool()uses daemon threads; JVM exit kills them without running finally blocks. - Silent loss – in-flight tasks vanish with no exception, no log, and no retry mechanism.
- Non-daemon fix – custom
ExecutorServicewith non-daemon threads keeps the JVM alive until tasks complete. - Shutdown hook –
Runtime.addShutdownHook()triggers on SIGTERM/SIGINT; callpool.shutdown()+awaitTermination(). - Spring Boot –
setWaitForTasksToCompleteOnShutdown(true)withsetAwaitTerminationSeconds(30). - Durable alternatives – Kafka, transactional outbox, and DB-backed job schedulers survive JVM crashes.
runAsync() on the default pool is like handing a trade settlement to a freelance teller (daemon thread) at National Bank of India. When the bank closes (JVM exit), the freelancer is kicked out mid-settlement. The trade is lost. Nobody knows it was not finished. Fix: hire permanent staff (non-daemon pool) or write the task to a ledger first (Kafka/outbox) so another branch can finish it.
Understanding the Concepts
Why is work lost on shutdown?
The default commonPool uses daemon threads. When all non-daemon threads finish, the JVM exits and daemon threads are killed instantly. There is no warning, no exception, no log entry, and no retry.
public static void main(String[] args) {
CompletableFuture.runAsync(() -> {
settleTrade("TRD-001"); // takes 5 seconds
});
// main() returns. No non-daemon threads left.
// JVM exits. settleTrade killed mid-execution. LOST!
}
What exactly happens when a daemon thread is killed?
The JVM does not call finally blocks on daemon threads during shutdown. Any resources held by the thread (database connections, file handles, partial writes) are abandoned without cleanup. If the thread was mid-way through writing a settlement to the database, the write is lost or left in an inconsistent state.
How do you fix it with a non-daemon pool?
Create a custom ThreadPoolExecutor with non-daemon threads. Non-daemon threads keep the JVM alive until they finish. Add a shutdown hook to drain in-flight tasks gracefully.
package com.bank.executor;
import java.util.concurrent.*;
public class SafeSettlementPool {
private static final ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, 20, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
r -> {
Thread t = new Thread(r, "settlement-worker");
t.setDaemon(false); // non-daemon: keeps JVM alive
return t;
}
);
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
pool.shutdown(); // stop accepting new tasks
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt stragglers
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}, "shutdown-hook"));
}
public static void submitSettlement(String tradeId) {
CompletableFuture.runAsync(() -> settleTrade(tradeId), pool);
}
}
What about Spring Boot?
Spring Boot provides built-in support for graceful executor shutdown.
@Bean("settlementExecutor")
public ThreadPoolTaskExecutor settlementExecutor() {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setCorePoolSize(10);
e.setMaxPoolSize(20);
e.setQueueCapacity(1000);
e.setThreadNamePrefix("settlement-");
e.setWaitForTasksToCompleteOnShutdown(true);
e.setAwaitTerminationSeconds(30);
return e;
}
With setWaitForTasksToCompleteOnShutdown(true), Spring waits up to the specified seconds for in-flight tasks to complete before destroying the bean.
When do you need durable alternatives?
In-memory futures vanish on JVM crash, OutOfMemoryError, or SIGKILL (kill -9). For critical work that must never be lost, in-memory futures are insufficient.
- Kafka – message survives producer crash; consumer retries on restart.
- Transactional outbox – write event to DB in same transaction as business logic; poller retries.
- DB-backed job scheduler – Quartz with JDBC store; job survives JVM restart.
package com.bank.outbox;
import org.springframework.transaction.annotation.Transactional;
public class SettlementService {
@Transactional
public void settleAndPublish(Trade trade) {
tradeRepo.save(trade.withStatus("SETTLED"));
outboxRepo.save(new OutboxEvent("SETTLE", trade.getId(), trade.toJson()));
// Atomic: both saved or neither
// A background poller reads outbox, publishes to Kafka, deletes entry
// If JVM crashes after commit, the poller picks it up on restart
}
}
How does SIGTERM differ from SIGKILL?
| Signal | Shutdown hooks? | Finally blocks? | Use case |
|--------|----------------|-----------------|----------|
| SIGTERM | Yes | Yes (on non-daemon) | Kubernetes pod stop |
| SIGINT (Ctrl-C) | Yes | Yes (on non-daemon) | Developer stop |
| SIGKILL (kill -9) | No | No | Emergency kill |
| OOM crash | No | No | Memory exhaustion |
This is why the transactional outbox is essential for banking: SIGKILL and OOM bypass all shutdown hooks.
What are common mistakes?
// WRONG: critical work on default daemon pool
CompletableFuture.runAsync(() -> processPayment(paymentId));
// daemon thread, may be killed mid-payment on shutdown!
// CORRECT: critical work on non-daemon pool with shutdown hook
CompletableFuture.runAsync(() -> processPayment(paymentId), settlementPool);
What are common interview follow-up questions?
- Does SIGKILL trigger shutdown hooks? No. Only SIGTERM and SIGINT trigger hooks.
- Can you make commonPool non-daemon? Not directly. The internal thread factory marks them daemon.
- What is the transactional outbox pattern? Write the event to a DB table in the same transaction as the business logic. A poller reads and processes events, guaranteeing at-least-once delivery even across JVM restarts.
Visual Overview
Default Pool (daemon threads)
┌───────────────────────────────┐
│ JVM exits → daemon killed │
│ Task: settleTrade("TRD-001") │
│ Status: SILENTLY LOST │
└───────────────────────────────┘
Custom Pool (non-daemon + shutdown hook)
┌───────────────────────────────┐
│ SIGTERM → shutdown hook fires │
│ pool.shutdown() │
│ awaitTermination(30s) │
│ Task completes gracefully │
└───────────────────────────────┘
Durable (Kafka/Outbox)
┌───────────────────────────────┐
│ JVM crashes → event in DB/Kafka│
│ Poller/consumer retries │
│ Task eventually completes │
└───────────────────────────────┘
Key Takeaway
CompletableFuture tasks on the default ForkJoinPool use daemon threads. When the JVM exits, daemon threads are killed instantly. In-flight tasks vanish with no exception, no log, and no retry. This is acceptable for optional work like cache warming, but dangerous for critical work like settlements and payments.
The fix for graceful shutdown: use a non-daemon custom pool with a shutdown hook that calls awaitTermination(). Spring Boot provides setWaitForTasksToCompleteOnShutdown(true). For true durability, in-memory futures are not enough. JVM crash, OOM, and SIGKILL bypass all shutdown hooks. Use the transactional outbox pattern: write the event to a DB table in the same transaction as the business logic, and a poller processes it even if the original JVM is gone.
At National Bank of India, the outbox pattern guarantees zero lost settlements: the trade and the outbox event are saved atomically, and the poller retries on restart.
Remember one sentence: daemon-pool tasks die silently on shutdown; use non-daemon pools with hooks or durable outbox for critical work.
---