Week 8 Day 4 — Executors and CompletableFuture
Goal
Today I want a bounded pool instead of new Thread(), and a clear idea of which thread runs the next thenApply.
Main questions:
- What is wrong with
new Thread()per task? - How does
ThreadPoolExecutorfill core, queue, then max? - What is a rejection policy?
- How does
CompletableFuturepick a thread? - How does this map to Spring’s
TaskExecutor?
1. Do not new Thread() per request
Creating a thread is expensive (stack memory, OS scheduling). An unbounded number of threads under load is a leak: context switching, OOM, death of the machine.
ExecutorService reuses workers, bounds concurrency, and gives shutdown.
ExecutorService pool = new ThreadPoolExecutor(
4, 8,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
r -> {
Thread t = new Thread(r);
t.setName("orders-" + t.getId());
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
Memory sentence:
Bound the pool and the queue; unbounded thread creation is a leak.
2. The ThreadPoolExecutor order
When a task is submitted:
- If running threads
< corePoolSize, start a new thread. - Else if the queue has room, enqueue.
- Else if running threads
< maxPoolSize, start a new thread. - Else reject.
Trap: Executors.newFixedThreadPool(n) uses an unbounded LinkedBlockingQueue. The pool never grows past core (core == max), and the queue can grow until OOM. newCachedThreadPool() has max = Integer.MAX_VALUE and a synchronous queue — a thread per task under load. Neither is a default for a web app.
I set core, max, queue capacity, thread names, rejection policy, and shutdown.
3. Rejection and shutdown
Policies:
| Policy | Behavior |
|---|---|
AbortPolicy | throw RejectedExecutionException (default) |
CallerRunsPolicy | run on the submitting thread (backpressure) |
DiscardPolicy | drop silently — almost never |
DiscardOldestPolicy | drop the oldest queued — rare |
Shutdown: shutdown() then awaitTermination, then shutdownNow() if needed. Spring Boot’s executor lifecycle does this if I use a Spring-managed ThreadPoolTaskExecutor.
Uncaught exceptions on a pool thread: they can vanish unless I set an UncaughtExceptionHandler or handle Future / CompletableFuture failures. @Async has the same hole (Day 5).
4. CompletableFuture and which thread
CompletableFuture.supplyAsync(this::load, pool)
.thenApply(this::toDto) // often the same thread that completed load
.thenApplyAsync(this::enrich, pool) // hops to pool
.orTimeout(2, TimeUnit.SECONDS);
thenApply: if the previous stage is already complete, the caller may run it; otherwise the completing thread.thenApplyAsync: an executor (default: commonForkJoinPool— wrong for blocking JDBC).
Always pass my executor for blocking work. join() wraps checked exceptions as unchecked; get() throws ExecutionException. Prefer timeouts.
CompletableFuture is the type @Async methods often return. The pool still needs to be bounded.
5. Spring connection
@Bean
TaskExecutor ordersExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setThreadNamePrefix("orders-");
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(100);
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
ex.initialize();
return ex;
}
@Async("ordersExecutor")or a dedicatedAsyncConfigurer.- Boot’s default
@Asyncexecutor has historically beenSimpleAsyncTaskExecutor(new thread per task). I configure a pool. Boot 3.2+ can use virtual threads for that executor — Week 9. applicationTaskExecutoris what MVC uses forWebAsyncTask/Callablereturn types.
Do not use ForkJoinPool.commonPool() for JDBC (Week 7 parallel streams — same pool, same wound).
6. Common traps
Trap 1: Executors.newCachedThreadPool() in a web app.
Trap 2: Unbounded queue, wondering why maxPoolSize never kicks in.
Trap 3: thenApplyAsync without an executor, blocking the common pool.
Trap 4: Fire-and-forget runAsync with no exception handler.
Trap 5: Forgetting to shut down a pool I created with new ThreadPoolExecutor outside Spring.
Practice Questions and Answers
Question 1
How do I run work in a thread pool? What is wrong with new Thread()?
Answer:
new Thread() per task is unbounded and expensive. I use an ExecutorService / Spring ThreadPoolTaskExecutor with bounded core/max, a bounded queue, named threads, a rejection policy, and await-termination on shutdown.
Question 2
Why can maxPoolSize never be reached?
Answer:
If the queue is unbounded, new tasks enqueue in step 2 and step 3 never runs. The classic newFixedThreadPool setup. I use a bounded queue if I want the pool to grow to max under load, then reject.
Question 3
thenApply vs thenApplyAsync?
Answer:
thenApply runs on the completing thread (or the caller if already done). thenApplyAsync submits to an executor. For blocking work I pass my pool to thenApplyAsync. The common ForkJoinPool is the wrong default for JDBC.
Question 4
What should happen when the queue is full?
Answer:
A rejection policy. CallerRunsPolicy pushes work back onto the HTTP thread as backpressure. AbortPolicy fails the submit. Silent discard hides overload.
Question 5
Why name pool threads?
Answer:
Thread dumps and logs (orders-3) tell me which pool is stuck. Default pool-1-thread-1 is how incidents take longer.
Memory sentences
Bound the pool and the queue; unbounded thread creation is a leak.
Unbounded queue means max pool size never kicks in.
Pass your executor to
thenApplyAsync. The common pool is not for JDBC.
Next: Week 8 Day 5 — @Async, @Transactional, and ThreadLocal