ThreadPoolExecutor is driven by four sizing parameters — corePoolSize, maximumPoolSize, keepAliveTime, and the workQueue — plus a ThreadFactory and a RejectedExecutionHandler. The non-obvious part is the order in which they interact: the queue fills before the pool grows past core.
The parameters
- corePoolSize — the number of threads kept alive even when idle. New tasks always start a core thread until this many exist.
- maximumPoolSize — the ceiling on total threads. Threads beyond core are created only when the queue is full.
- keepAliveTime — how long an excess (non-core) idle thread lives before being reaped. With
allowCoreThreadTimeOut(true), core threads time out too. - workQueue — a
BlockingQueue<Runnable>holding tasks that arrive while all core threads are busy.
The admission algorithm (the trap)
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // corePoolSize
16, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAlive for excess threads
new ArrayBlockingQueue<>(100), // BOUNDED queue
new ThreadFactoryBuilder().setNameFormat("api-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
For each submitted task:
- If
poolSize < corePoolSize→ spawn a new core thread to run it. - Else try
workQueue.offer(task)→ if it succeeds, the task waits. - Only if the offer fails (queue full) → spawn threads up to
maximumPoolSize. - If the pool is already at max → hand the task to the rejection handler.
The killer consequence: an unbounded queue means step 2 never fails, so the pool never grows beyond corePoolSize and maximumPoolSize is silently ignored. To actually use a burst capacity of threads, you need a bounded queue. A SynchronousQueue (zero capacity, direct handoff) makes every task either run immediately on a new thread or be rejected — that is how a cached pool scales threads.