ThreadPoolExecutor internals
the ctl field: run state and worker count in one int.
- chapter
- chapter 17.08
- reading time
- 7 min read
a ThreadPoolExecutor is a BlockingQueue plus a set of Worker threads that loop "take a task, run it". its whole lifecycle lives in one AtomicInteger called ctl: the top 3 bits are the run state (RUNNING, SHUTDOWN, STOP, TIDYING, TERMINATED), the low 29 bits count the workers. execute() is three steps: start a core thread, else queue it, else start an extra thread, else reject. that order is why a pool with an unbounded queue never grows past its core size.
the picture
the executors chapter is about using pools. this one is about the 2,000 lines under execute().
think of a restaurant kitchen. there's a fixed crew of cooks on staff (core threads), a ticket rail with limited hooks (the queue), and a phone number for temp cooks (threads up to max). a new order goes to a free staff cook if the crew isn't full yet. otherwise it's pinned on the rail. only when the rail is full do you call in a temp. and when even the temps are maxed out, you turn customers away at the door.
the subtle part is the order: the rail fills before any temp is called. keep that in mind for the gotcha below.
ctl: two numbers in one int
the pool constantly needs to ask questions like "am I still accepting work and is there room for another worker?" and act on the answer atomically. two separate fields would need a lock. so Doug Lea packed both into one AtomicInteger, and a single CAS changes both at once:
-1 << 29, the only negative state, so "is it running?" is just c < 0. click a state below to jump to it.we read ctl out of live pools with reflection on jdk 25, to make sure the figure isn't lying:
$ java --add-opens java.base/java.util.concurrent=ALL-UNNAMED Ctl
new pool: -536870912 0xe0000000 111 00000000000000000000000000000
3 workers: -536870909 0xe0000003 111 00000000000000000000000000011
after shutdown(): 3 0x00000003 000 00000000000000000000000000011
terminated: 1610612736 0x60000000 011 00000000000000000000000000000
shutdownNow(): 536870913 0x20000001 001 00000000000000000000000000001three details worth noticing:
- the states are ordered numerically (RUNNING < SHUTDOWN < STOP < TIDYING < TERMINATED) and the state only ever moves forward, so "is it at least SHUTDOWN?" is a single
>=. - the worker count is capped at 2²⁹ - 1, about 536 million. the source comment says that if anyone ever needs more, it becomes an
AtomicLong. nobody has. addWorkerdoesn't take a lock to bump the count. it loops: readctl, check the state still allows a new worker and the count is under the limit, CASctlto count + 1. if anything changed in between, the CAS fails and it re-checks.
execute(): the three steps
the method's own comment numbers them, and the code is about 15 lines. press the buttons and watch which step each task takes:
- 1workers < corePoolSize → new thread
- 2queue.offer(task) → queued
- 3workers < maximumPoolSize → new thread
- ×reject
an empty pool: core 2, max 4, a queue that holds 2. no threads exist yet, they're created on demand.
the same seven submissions against a real pool (each task blocks on a latch so nothing finishes):
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
var gate = new CountDownLatch(1);
var pool = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2));
for (int i = 1; i <= 7; i++) {
try {
pool.execute(() -> { try { gate.await(); } catch (InterruptedException e) {} });
System.out.println("task " + i + ": threads=" + pool.getPoolSize() + " queued=" + pool.getQueue().size());
} catch (RejectedExecutionException e) {
System.out.println("task " + i + ": rejected");
}
}
gate.countDown();
pool.shutdown();
}
}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.
var pool = new ThreadPoolExecutor(2, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
for (int i = 0; i < 1000; i++) pool.execute(blockingTask);
System.out.println("threads: " + pool.getPoolSize() + ", queued: " + pool.getQueue().size());that's also how the Executors factory methods are built, which explains their failure modes:
| factory | core / max | queue | how it fails under overload |
|---|---|---|---|
newFixedThreadPool(n) | n / n | unbounded LinkedBlockingQueue | the queue grows until OutOfMemoryError |
newCachedThreadPool() | 0 / Integer.MAX_VALUE | SynchronousQueue | one new thread per waiting task, until you run out of threads |
newSingleThreadExecutor() | 1 / 1 | unbounded | same as fixed |
newVirtualThreadPerTaskExecutor() | not a ThreadPoolExecutor at all | none | a virtual thread per task. no pool to tune, see virtual threads, inside |
the Worker
each thread in the pool is owned by a Worker, a small inner class that is two things at once:
- a Runnable
- the thread's body.
run()callsrunWorker(this): runfirstTaskif there is one, then loop ongetTask()until it returnsnull, then exit. - a lock
- it
extends AbstractQueuedSynchronizer(see locks and AQS) as a tiny non-reentrant mutex. the worker holds it while running a task.
why does a thread need its own lock? for interrupts. shutdown() wants to wake up idle workers (so they notice the shutdown and exit) without interrupting workers in the middle of your task. so it walks the workers and calls tryLock() on each: a busy worker holds its own lock, the tryLock fails, and it's left alone. the lock is deliberately not reentrant, as the source explains, so a task that calls back into pool control methods like setCorePoolSize can't reacquire it from inside.
getTask() is where threads die. if there are more workers than corePoolSize (or allowCoreThreadTimeOut is on), the worker waits with poll(keepAliveTime) instead of take(). a null from that poll means "timed out", getTask decrements the count in ctl, returns null, and the thread ends. there's no "core thread" flag on a worker: whichever idle worker times out first goes.
ctlpacks the run state (top 3 bits) and the worker count (low 29) into oneAtomicInteger, so one CAS updates both.- RUNNING is the only negative state. states only move forward: RUNNING → SHUTDOWN/STOP → TIDYING → TERMINATED.
execute: below core, start a thread. else queue. else grow toward max. else reject.- an unbounded queue means max is never reached. bound your queues.
- a Worker is a thread plus a non-reentrant AQS lock that marks it busy, so shutdown only interrupts idle ones.
- surplus workers die by timing out in
getTask(). any worker can be the one that goes.