Concurrent Utilities

Thread Pool Types

15 min Lesson 4 of 13

Thread Pool Types

Creating a raw Thread for every unit of work is expensive — thread creation involves kernel calls, stack allocation, and scheduling overhead. Thread pools solve this by keeping a set of pre-created threads alive and reusing them across many tasks. The java.util.concurrent.Executors factory class gives you four battle-tested pool flavours, each tuned for a different workload profile.

The Executors Factory — a Quick Map

All four factory methods return an ExecutorService (or ScheduledExecutorService), so you always interact with the same interface regardless of the pool type. Under the hood they differ in thread count, queue strategy, and how they handle peaks.

Fixed Thread Pool

Executors.newFixedThreadPool(n) creates exactly n worker threads and keeps them alive forever (until you shut the pool down). Submitted tasks queue in an unbounded LinkedBlockingQueue when all threads are busy.

import java.util.concurrent.*; ExecutorService fixed = Executors.newFixedThreadPool(4); for (int i = 0; i < 20; i++) { final int taskId = i; fixed.submit(() -> { System.out.printf("Task %d on %s%n", taskId, Thread.currentThread().getName()); Thread.sleep(200); // simulate work return taskId * taskId; }); } fixed.shutdown(); // stop accepting new tasks fixed.awaitTermination(30, TimeUnit.SECONDS);

With 20 tasks and a pool of 4, the first batch of 4 starts immediately; the remaining 16 wait in the queue and drain four at a time.

When to use a fixed pool: CPU-bound work where you want exactly N threads competing for the processor (typically Runtime.getRuntime().availableProcessors() or a small multiple). A fixed pool prevents unbounded resource consumption and avoids the context-switching storm that comes from having far more runnable threads than cores.
Unbounded queue risk: The queue in a fixed pool has no capacity limit. If producers submit tasks much faster than the pool can drain them, the queue grows without bound and will eventually exhaust heap memory. In production, prefer constructing a ThreadPoolExecutor directly with a bounded ArrayBlockingQueue and a rejection policy.

Cached Thread Pool

Executors.newCachedThreadPool() creates new threads on demand and reuses idle ones. A thread that sits idle for 60 seconds is terminated and removed from the pool, so the pool shrinks back to zero when quiet.

ExecutorService cached = Executors.newCachedThreadPool(); for (int i = 0; i < 100; i++) { final int id = i; cached.submit(() -> { System.out.println("IO task " + id + " on " + Thread.currentThread().getName()); Thread.sleep(50); // fast I/O work }); } cached.shutdown(); cached.awaitTermination(30, TimeUnit.SECONDS);

Submitting 100 tasks may create up to 100 threads if they all arrive before the first ones finish. That is fine for short-lived I/O tasks — threads are cheap to keep around for 60 s and reused aggressively.

Sweet spot for cached pools: Many short-lived, predominantly I/O-bound tasks where the number of simultaneous tasks is naturally bounded by the upstream rate (e.g., handling inbound network requests on a lightly loaded server). Avoid it for CPU-intensive work or when the task submission rate can spike without limit — you could spawn thousands of threads and crash the JVM.

Single-Thread Executor

Executors.newSingleThreadExecutor() is a fixed pool of exactly one thread. Tasks execute sequentially in submission order, which makes it a handy serialisation primitive without any manual synchronisation.

ExecutorService single = Executors.newSingleThreadExecutor(); single.submit(() -> System.out.println("Step 1 — init")); single.submit(() -> System.out.println("Step 2 — process")); single.submit(() -> System.out.println("Step 3 — cleanup")); single.shutdown(); single.awaitTermination(5, TimeUnit.SECONDS); // Always prints Step 1, Step 2, Step 3 — strict ordering guaranteed.
Wrapping matters: Unlike newFixedThreadPool(1), the single-thread executor wraps its worker in a delegating wrapper. If the internal thread dies due to an uncaught exception, a new one is silently spawned to replace it, keeping the executor alive. A plain newFixedThreadPool(1) would not respawn.

Scheduled Thread Pool

Executors.newScheduledThreadPool(n) returns a ScheduledExecutorService that can run tasks after a delay or on a fixed schedule. It is the modern replacement for java.util.Timer.

import java.util.concurrent.*; ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); // run once after a 1-second delay scheduler.schedule( () -> System.out.println("Delayed task at " + System.currentTimeMillis()), 1, TimeUnit.SECONDS ); // run repeatedly — first execution after 0 s, then every 3 s ScheduledFuture<?> handle = scheduler.scheduleAtFixedRate( () -> System.out.println("Heartbeat: " + System.currentTimeMillis()), 0, 3, TimeUnit.SECONDS ); // cancel after 10 seconds scheduler.schedule(() -> { handle.cancel(false); // let the current run finish scheduler.shutdown(); }, 10, TimeUnit.SECONDS); scheduler.awaitTermination(15, TimeUnit.SECONDS);

There are two repeating variants: scheduleAtFixedRate aims for a constant wall-clock period (ideal for heartbeats, metrics polling), while scheduleWithFixedDelay waits for the previous run to finish before starting the delay counter (ideal when the task duration varies and overlap is dangerous).

Use at least 2 threads in a scheduled pool when you have multiple recurring tasks. With only 1 thread, a long-running task will delay every subsequent scheduled task in the queue.

Choosing the Right Pool

  • CPU-bound, bounded concurrencynewFixedThreadPool(cores)
  • Many short I/O tasks, variable ratenewCachedThreadPool()
  • Sequential task queue, no synchronisation needednewSingleThreadExecutor()
  • Delayed or periodic executionnewScheduledThreadPool(n)

Always Shut Down Properly

An ExecutorService that is never shut down keeps its threads alive, preventing JVM exit and leaking resources. Use the two-phase shutdown idiom:

pool.shutdown(); // stop accepting, let running tasks finish if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { pool.shutdownNow(); // cancel pending tasks if (!pool.awaitTermination(60, TimeUnit.SECONDS)) { System.err.println("Pool did not terminate"); } }
Never ignore shutdown in production code. A daemon-thread trick or a plain System.exit() may mask leaked executors during development, but in a long-running service they accumulate over time and exhaust thread stack memory.

Summary

The Executors factory gives you four ready-made pool types. Fixed pools cap concurrency for CPU-bound work; cached pools elastically absorb I/O bursts; single-thread executors serialise work; and scheduled pools replace Timer for deferred or periodic tasks. Always shut executors down and, for production workloads, prefer building a ThreadPoolExecutor directly when you need a bounded queue or a custom rejection policy.