Skip to main content

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:

  1. What is wrong with new Thread() per task?
  2. How does ThreadPoolExecutor fill core, queue, then max?
  3. What is a rejection policy?
  4. How does CompletableFuture pick a thread?
  5. 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:

  1. If running threads < corePoolSize, start a new thread.
  2. Else if the queue has room, enqueue.
  3. Else if running threads < maxPoolSize, start a new thread.
  4. 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:

PolicyBehavior
AbortPolicythrow RejectedExecutionException (default)
CallerRunsPolicyrun on the submitting thread (backpressure)
DiscardPolicydrop silently — almost never
DiscardOldestPolicydrop 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: common ForkJoinPool — 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 dedicated AsyncConfigurer.
  • Boot’s default @Async executor has historically been SimpleAsyncTaskExecutor (new thread per task). I configure a pool. Boot 3.2+ can use virtual threads for that executor — Week 9.
  • applicationTaskExecutor is what MVC uses for WebAsyncTask / Callable return 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