A thread pool reuses a fixed set of worker threads to run a stream of tasks, decoupling what you submit from how it runs. Creating a thread per request costs ~1 MB of stack each and thousands of context switches; a pool caps that cost, bounds concurrency, and gives you queueing, lifecycle control, and back-pressure. In Java this is the java.util.concurrent Executor framework, and at its heart sits one configurable class: ThreadPoolExecutor.
The Executor framework
Executor is a single method — execute(Runnable). ExecutorService adds lifecycle (shutdown, awaitTermination) and result-bearing submission (submit returning a Future, invokeAll, invokeAny). ScheduledExecutorService adds delayed and periodic execution. You program to these interfaces and let the concrete pool decide threading policy — the same separation-of-concerns that lets you swap a fixed pool for virtual threads in one line.
How a ThreadPoolExecutor runs a task
The behavior surprises people. Tasks do not spread across maximumPoolSize threads eagerly. The algorithm is: (1) if fewer than corePoolSize threads exist, start a new core thread; (2) otherwise try to enqueue the task; (3) only if the queue is full create threads up to maximumPoolSize; (4) if that also fails, invoke the rejection handler.
submit(task) | v core threads full? --no--> start new core thread | yes v queue.offer(task) ok? --yes--> task waits in queue | no (queue full) v pool < maximumPoolSize? --yes--> start new (non-core) thread | no v RejectedExecutionHandler
The corollary: with an unbounded queue (LinkedBlockingQueue with no capacity), step 2 never fails, so maximumPoolSize is dead config and threads never grow past core. That is exactly the trap in Executors.newFixedThreadPool.
Configuration and lifecycle
The seven-arg constructor exposes corePoolSize, maximumPoolSize, keepAliveTime, the BlockingQueue<Runnable> (direct-handoff, bounded, or unbounded), a ThreadFactory (name your threads — your future self reading a thread dump will thank you), and a RejectedExecutionHandler.
Shut a pool down deliberately: shutdown() stops accepting work but drains the queue; shutdownNow() interrupts running tasks and abandons the queue. Either way, no pool is closed until you awaitTermination.