skip to content

executors and thread pools

core size, max size, the queue, and rejection. play with a pool.

chapter
chapter 09.08
reading time
9 min read

an executor separates what to run (a task) from how it runs (which thread, when). a ThreadPoolExecutor keeps a set of threads alive and feeds them tasks from a queue. its rule for a new task is the counterintuitive part: core threads first, then the queue, then extra threads, then rejection. extra threads only appear once the queue is full.

the picture

a restaurant kitchen. there are a few cooks on permanent staff (the core threads), a ticket rail where orders wait (the queue), and a phone number for temp cooks (threads up to maximumPoolSize). a new order goes to a free permanent cook if the kitchen is still hiring permanent staff. otherwise it goes on the rail. only when the rail is completely full does the manager call a temp, who takes the newest order straight away while older tickets keep waiting. and when the rail is full and every temp is busy, the door gets a "sorry, we're full" sign (the rejection policy). temps who stand around idle for too long get sent home (keepAliveTime).

why not just new Thread()

platform threads are expensive to create and each one reserves a stack. starting one per request means thread creation on every request and no upper bound when traffic spikes. a pool fixes both: threads are reused, and the number of them is capped.

the API is three interfaces deep:

Pools.java
Executor e = command -> new Thread(command).start();   // just "run this, somehow"
 
ExecutorService pool = Executors.newFixedThreadPool(4); // + lifecycle and results
Future<Integer> f = pool.submit(() -> 6 * 7);           // Callable in, Future out
int answer = f.get();                                    // blocks until it's done
pool.close();                                            // java 19+: shutdown, then wait

Executor just runs Runnables. ExecutorService adds submit (returns a Future), invokeAll/invokeAny, and shutdown. ScheduledExecutorService adds delays and repeats. ThreadPoolExecutor is the class behind almost all of them.

the execute() decision

every task handed to a ThreadPoolExecutor goes through exactly this, in this order:

the order that surprises everyone: the queue comes before the extra threads. so extra threads are created only when the queue is full, and they run the newest task, not the oldest.

here it is for real: core 2, max 4, a queue of 2, and seven tasks that all block, so nothing finishes while we watch:

Main.java
import java.util.concurrent.*;
 
void main() throws InterruptedException {
    CountDownLatch release = new CountDownLatch(1);    // keeps every task busy
    ThreadPoolExecutor pool = new ThreadPoolExecutor(
            2, 4,                                       // core 2, max 4
            30, TimeUnit.SECONDS,                       // extra threads idle out after 30 s
            new ArrayBlockingQueue<>(2));               // the queue holds 2
    for (int task = 1; task <= 7; task++) {
        try {
            pool.execute(() -> {
                try { release.await(); } catch (InterruptedException e) { }
            });
            IO.println("task " + task + ": threads=" + pool.getPoolSize()
                    + " queued=" + pool.getQueue().size());
        } catch (RejectedExecutionException e) {
            IO.println("task " + task + ": rejected");
        }
    }
    release.countDown();
    pool.shutdown();
}
output, from jdk 25
task 1: threads=1 queued=0
task 2: threads=2 queued=0
task 3: threads=2 queued=1
task 4: threads=2 queued=2
task 5: threads=3 queued=2
task 6: threads=4 queued=2
task 7: rejected

this one needs a real JVM (java.util.concurrent synchronizers), so it doesn't run in the browser. copy it into java Main.java to try it.

tasks 1 and 2 get core threads. 3 and 4 wait in the queue. 5 and 6 get brand-new threads and start running before 3 and 4, which arrived earlier. task 7 finds a full queue and a maxed-out pool, and the default AbortPolicy throws RejectedExecutionException.

play with a pool

corePoolSize =
2
maxPoolSize =
4
queue capacity =
2
task takes =
4 s
arrivals =
1/s
when full =
work queue · 0/2next to run is on the left
··
threads · 0 (core 2, max 4)keepAliveTime = 3 s
core slot
core slot
room for extra
room for extra
submitted0
completed0
rejected0
largest pool0
t = 0.0 s

an empty pool: no threads yet. they're created lazily, one per task, until there are corePoolSize of them.

press submit 10 at once on the defaults and watch tasks 5 and 6 jump the queue onto extra (dashed) threads while 3 and 4 wait, and 7 to 10 get rejected. then start arrivals, slow the tasks down, and push the arrival rate past what the pool can do. finally set the queue to ∞: max pool size stops mattering, and the queue just grows.
quick check
core 2, max 10, and an unbounded LinkedBlockingQueue. tasks arrive far faster than 2 threads can handle. how many threads will the pool have?

the Executors factories

Executors has static factories for the common shapes. they're convenient, and most of them hide a decision you should make yourself:

factorycore / maxqueuethe catch
newFixedThreadPool(n)n / nLinkedBlockingQueue, unboundednever rejects; under overload the queue grows without limit
newSingleThreadExecutor()1 / 1unboundedsame, plus everything runs in order on one thread
newCachedThreadPool()0 / Integer.MAX_VALUESynchronousQueue (holds nothing)never queues, so every burst creates threads, without limit; idle ones die after 60 s
newScheduledThreadPool(n)na delay queue, unboundedfor "run later" and "run every 5 s"
newWorkStealingPool()a ForkJoinPoolper-thread dequessee fork/join
newVirtualThreadPerTaskExecutor()no pool at allnoneone new virtual thread per task java 21+

when the pool is full: rejection policies

policywhat happens to the task
AbortPolicy (default)execute throws RejectedExecutionException at the caller
CallerRunsPolicythe calling thread runs the task itself. it's busy, so it stops submitting: natural backpressure
DiscardPolicysilently dropped
DiscardOldestPolicythe oldest queued task is silently dropped, and this one is queued instead

CallerRunsPolicy is the underrated one: instead of failing, it slows the producer down to the pool's speed. try it in the simulator with a high arrival rate: the caller gets stuck running tasks and arrivals stop. the two discard policies lose work without telling anyone, so only use them when that's genuinely fine (sampling, metrics).

futures, and where exceptions go

execute(runnable) and submit(task) look interchangeable. they handle failure very differently:

Main.java
import java.util.concurrent.*;
 
void main() throws InterruptedException {
    try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
        pool.execute(() -> { throw new IllegalStateException("from execute"); });
        Future<?> f = pool.submit(() -> { throw new IllegalStateException("from submit"); });
        try {
            f.get();
        } catch (ExecutionException e) {
            IO.println("get() threw: " + e.getCause());
        }
    }
    IO.println("pool closed");
}
output, from jdk 25
get() threw: java.lang.IllegalStateException: from submit
pool closed

this one needs a real JVM (executors and futures), so it doesn't run in the browser. copy it into java Main.java to try it.

the execute one kills its worker thread with a stack trace on stderr (Exception in thread "pool-1-thread-1" java.lang.IllegalStateException: from execute), and the pool quietly starts a replacement. the submit one is captured inside the Future and only comes out, wrapped in ExecutionException, when someone calls get(). if nobody ever calls get(), that exception is gone. no log, nothing.

shutting down

pool threads are non-daemon, so a pool you forget to shut down keeps the JVM alive after main returns. the program just never exits.

main
thread-1
thread-2
sharedqueue
1
running #1
running #2
[#3, #4]

two threads busy, two tasks queued.

step 1/7
a graceful shutdown(): the door closes to new tasks, but everything already queued still runs, and then the threads exit.
  • shutdown(): stop accepting new tasks, finish everything already submitted (queued ones too), then let the threads die.
  • shutdownNow(): stop accepting, interrupt running tasks, and return the queued ones as a list without running them. tasks that ignore interrupts keep running.
  • awaitTermination(timeout): block until everything has finished, or the timeout passes.
  • close() java 19+: shutdown() then wait until done. it's why ExecutorService works in try-with-resources, as above.

how many threads?

  • CPU-bound work (compression, hashing, number crunching): about one thread per core. more just adds context switching. Runtime.getRuntime().availableProcessors() tells you how many.
  • I/O-bound work (calling databases, HTTP): more threads than cores, roughly cores × (1 + wait time / compute time), since most threads are waiting at any moment.
  • lots of blocking I/O: skip the sizing math and use virtual threads, which make "one thread per task" cheap again. pools still make sense for limiting how many tasks hit a slow downstream at once, but a Semaphore does that more directly.
one level deeper:how the pool tracks its state+

ThreadPoolExecutor packs two things into one AtomicInteger called ctl: the top 3 bits are the run state (RUNNING, SHUTDOWN, STOP, TIDYING, TERMINATED) and the low 29 bits are the worker count. so "is it running, and how many threads are there" can be read and CASed in one step. each worker is a small AQS lock wrapped around a thread, which is how shutdown() can tell idle workers (lock free) from busy ones. the whole walk-through is in ThreadPoolExecutor internals.

keylines
  • a pool reuses threads and caps how many there are. you submit tasks, not threads.
  • execute(): core threads, then the queue, then extra threads, then rejection. in that order.
  • an unbounded queue means maximumPoolSize never matters and overload becomes OOM.
  • pick a rejection policy on purpose. CallerRunsPolicy is backpressure for free.
  • submit hides exceptions in the Future until someone calls get().
  • shut pools down (or try-with-resources them), or the JVM never exits.