⚡ Advanced Concurrency · Advanced

Sizing thread pools in Java

CPU-bound vs I/O-bound work, rejection policies, bounded queues.

🧩 The mysteryYour 8-core server's pool has 8 threads, yet the CPUs sit at 10% while requests pile up. Add threads? Add cores? It all depends on one ratio: waiting vs working.

CPU-bound work

For pure computation, use about one thread per core (Runtime.getRuntime().availableProcessors()). Only one thread per core can run at a time, so 10× more threads doesn't make it 10× faster. They just compete and add context-switch overhead.

I/O-bound work

Threads that mostly wait (on databases, networks, disks) leave cores idle, so you need more of them: roughly cores × (1 + wait time / compute time). Or skip the math for blocking I/O and use virtual threads.

int cores = Runtime.getRuntime()
    .availableProcessors();
// 90 ms waiting per 10 ms of CPU:
int threads = cores * (1 + 90 / 10);
🔮 Predict it

Your turn

A 4-core server runs tasks that wait 50 ms on a remote API for every 10 ms of CPU work. About how many threads keep the CPUs busy?

  1. About 4
  2. About 24
  3. About 400
Show the answer

4 × (1 + 50/10) = 24. Each thread uses the CPU only one-sixth of the time, so you need about six threads per core to keep every core busy.

⚠️ The trap

Unbounded queues hide overload

newFixedThreadPool(10) uses an unbounded LinkedBlockingQueue. Bursts pile up in memory until OutOfMemoryError. A cached pool just creates unbounded threads instead. Fix: a ThreadPoolExecutor with a bounded queue and a rejection policy.

new ThreadPoolExecutor(
    10, 10, 0, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1_000),
    new ThreadPoolExecutor.CallerRunsPolicy());

Rejection policies

When the pool and its queue are both full: AbortPolicy (default) throws RejectedExecutionException. CallerRunsPolicy makes the submitting thread run the task itself, slowing producers down: back-pressure. DiscardPolicy silently drops the new task. DiscardOldestPolicy drops the oldest queued task, then retries.

🤔 Think first

The pool that deadlocks itself

A fixed pool of 4 threads runs tasks that each submit a subtask to the same pool, then block on its future.get(). Under load everything hangs at 0% CPU. Why?

Think about it, then reveal the answer

All 4 threads are blocked waiting for subtasks that sit in the queue behind them, and those subtasks need a free thread to run. That's thread-starvation deadlock. Use separate pools for parent and child tasks, or don't block inside pooled tasks.

💼 In the real world

Bulkheads

Production systems often give each downstream dependency its own bounded pool, like watertight compartments in a ship. If the payments API hangs, only its pool fills up; search and checkout keep their threads. Pair that with timeouts and CallerRuns or rejection, and overload becomes visible instead of fatal.

Key takeaways

  1. CPU-bound: about the number of cores
  2. I/O-bound: cores × (1 + wait/compute) — or virtual threads
  3. Unbounded queues hide overload until OutOfMemoryError
  4. Rejection policies: Abort (default), CallerRuns, Discard, DiscardOldest
🤯 Did you know?

HikariCP's pool-sizing guide suggests starting near (cores × 2) + effective spindle count database connections, often far fewer than people expect.

Practice questions

An 8-core server runs tasks that wait 90 ms on a database for every 10 ms of CPU work. Roughly how many threads keep the CPUs busy?

  1. About 80
  2. About 8
  3. About 9
  4. About 800
Check your answer

About 80. 8 × (1 + 90/10) = 80. Each thread uses the CPU only 10% of the time, so you need about ten times as many threads as cores.

A fixed pool of 4 threads runs tasks that each submit a subtask to the SAME pool and then block on its future.get(). Under load, everything hangs with 0% CPU. Why?

  1. All 4 threads wait for subtasks stuck in the queue behind them — a pool-induced deadlock
  2. Futures can't be used from inside pool threads
  3. The full queue silently drops the subtasks
  4. get() busy-spins and starves the subtasks
Check your answer

All 4 threads wait for subtasks stuck in the queue behind them — a pool-induced deadlock. This is thread-starvation deadlock: the subtasks need a free thread, but every thread is blocked waiting for them. Use separate pools, or don't block inside pooled tasks.

Next world: go under the hood. Heap, stack, garbage collectors and the JIT compiler: how the JVM actually runs and remembers your code.