Contents

Executors.newFixedThreadPool() uses an unbounded LinkedBlockingQueue, which can absorb an unlimited number of tasks and cause OutOfMemoryError under sustained load long before any rejection policy fires. Executors.newCachedThreadPool() creates a new thread for every task that arrives when no idle thread is available, which can spawn thousands of threads under burst load. ThreadPoolExecutor exposes explicit control over every knob: corePoolSize, maximumPoolSize, queue capacity, and rejection policy, so you can size the pool appropriately and choose what happens when capacity is exceeded.

// Executors factory methods and their hidden pitfalls: // newFixedThreadPool(n) — n threads, UNBOUNDED queue ExecutorService fixed = Executors.newFixedThreadPool(10); // Risk: tasks pile up in queue indefinitely → OutOfMemoryError under load // newCachedThreadPool() — UNLIMITED threads, SynchronousQueue ExecutorService cached = Executors.newCachedThreadPool(); // Risk: creates thousands of threads under burst load → OOM or context-switch storm // newSingleThreadExecutor() — 1 thread, UNBOUNDED queue ExecutorService single = Executors.newSingleThreadExecutor(); // Risk: same unbounded queue issue as fixed pool // ThreadPoolExecutor gives you full control over EVERY parameter: ThreadPoolExecutor pool = new ThreadPoolExecutor( 4, // corePoolSize 8, // maximumPoolSize 60L, TimeUnit.SECONDS, // keepAliveTime for idle threads above core new ArrayBlockingQueue<>(200), // BOUNDED queue — tasks rejected when full new ThreadFactory() { // custom thread factory private int n = 0; public Thread newThread(Runnable r) { Thread t = new Thread(r, "worker-" + n++); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() // backpressure policy ); // Rule of thumb for sizing: // CPU-bound tasks: corePoolSize = maxPoolSize = number of CPU cores // I/O-bound tasks: corePoolSize = 2 * cores, maxPoolSize = 4 * cores (tune empirically) int cores = Runtime.getRuntime().availableProcessors(); System.out.println("Available CPUs: " + cores); Executors.newFixedThreadPool and newSingleThreadExecutor use an unbounded LinkedBlockingQueue. Under sustained overload they will queue millions of tasks, consuming gigabytes of memory before your rejection policy ever fires. Always prefer a bounded queue in production.

The constructor's seven parameters each control a distinct aspect of pool behavior. corePoolSize is the number of threads kept alive even when idle. maximumPoolSize is the upper limit during bursts; extra threads above core are created when the queue is full and retired after they have been idle for keepAliveTime. The workQueue buffers tasks when all core threads are busy; a bounded queue is strongly preferred in production. threadFactory controls thread naming, daemon status, and exception handlers. The handler determines what happens when both the queue and the max thread count are exhausted.

// Full constructor signature: // ThreadPoolExecutor(int corePoolSize, // int maximumPoolSize, // long keepAliveTime, // TimeUnit unit, // BlockingQueue<Runnable> workQueue, // ThreadFactory threadFactory, // RejectedExecutionHandler handler) // corePoolSize — threads kept alive even when idle // - new tasks go to idle core threads first // - if all core threads busy AND queue not full → task goes to queue // - if queue full AND thread count < max → spawn extra thread // - if queue full AND thread count == max → rejection policy fires // maximumPoolSize — max threads (including core threads) // - extra threads (above core) are reclaimed after keepAliveTime idle // keepAliveTime + unit — how long idle non-core threads survive // - call allowCoreThreadTimeOut(true) to also reclaim idle CORE threads // workQueue — buffered tasks waiting for a thread // - determines how tasks queue when all core threads are busy // threadFactory — creates new threads (name, priority, daemon flag, UncaughtExceptionHandler) ThreadFactory namedFactory = r -> { Thread t = new Thread(r); t.setName("order-processor-" + System.nanoTime()); t.setDaemon(true); // JVM exits even if these threads are running t.setUncaughtExceptionHandler((thread, ex) -> System.err.println(thread.getName() + " threw: " + ex)); return t; }; // Typical CPU-bound pool: int n = Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor cpuPool = new ThreadPoolExecutor( n, n, // core == max — no dynamic growth 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), // bounded buffer namedFactory, new ThreadPoolExecutor.AbortPolicy() ); // Typical I/O-bound pool: ThreadPoolExecutor ioPool = new ThreadPoolExecutor( n * 2, n * 4, // core threads, burst up to 4x cores 30L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), namedFactory, new ThreadPoolExecutor.CallerRunsPolicy() ); // Dynamic resizing at runtime (rare but possible): cpuPool.setCorePoolSize(n + 2); cpuPool.setMaximumPoolSize(n + 4); cpuPool.setKeepAliveTime(120, TimeUnit.SECONDS);

LinkedBlockingQueue without a capacity argument is unbounded and dangerous in production — prefer the bounded form. ArrayBlockingQueue uses a fixed-size array and is recommended when you know the upper bound on queued tasks and want lower memory overhead. SynchronousQueue has zero internal capacity: every submitted task must be handed directly to a waiting thread, so maximumPoolSize matters and extra threads are created aggressively — this reproduces newCachedThreadPool behavior. PriorityBlockingQueue processes tasks in priority order, useful for scheduling where some tasks must run before others.

import java.util.concurrent.*; // 1. LinkedBlockingQueue — optionally bounded linked list BlockingQueue<Runnable> unbounded = new LinkedBlockingQueue<>(); // Integer.MAX_VALUE capacity BlockingQueue<Runnable> bounded = new LinkedBlockingQueue<>(1000); // bounded — preferred! // Behaviour in TPE: core threads fill first, then queue, then extra threads (up to max) // With unbounded: maximumPoolSize is NEVER reached (queue absorbs everything) // 2. ArrayBlockingQueue — fixed-size array, one lock, fair/nonfair BlockingQueue<Runnable> arr = new ArrayBlockingQueue<>(200); // nonfair (default) BlockingQueue<Runnable> fair = new ArrayBlockingQueue<>(200, true); // fair — FIFO ordering // Slightly higher memory efficiency than LinkedBlockingQueue (no node objects) // Use when queue size is known upfront and you want bounded allocation // 3. SynchronousQueue — no storage; each submit needs a waiting thread BlockingQueue<Runnable> sync = new SynchronousQueue<>(); // With SynchronousQueue + unbounded maxPoolSize → Executors.newCachedThreadPool() behaviour // Each submitted task MUST be handed directly to a thread — no buffering at all // Good when latency matters more than throughput and tasks are short-lived // 4. PriorityBlockingQueue — heap-ordered by task priority PriorityBlockingQueue<Runnable> pq = new PriorityBlockingQueue<>(); // Tasks must implement Comparable or you must supply a Comparator class PriorityTask implements Runnable, Comparable<PriorityTask> { final int priority; final Runnable work; PriorityTask(int p, Runnable r) { this.priority = p; this.work = r; } public void run() { work.run(); } public int compareTo(PriorityTask o) { return Integer.compare(o.priority, priority); } // high first } // 5. DelayQueue — tasks become available only after their delay expires // Used in scheduled executors — not typical for custom TPE // Summary: // | Queue type | Bounded? | Use case | // |-------------------------|----------|-----------------------------------| // | LinkedBlockingQueue(n) | Yes | General purpose — recommended | // | ArrayBlockingQueue(n) | Yes | Known size, lower memory overhead | // | SynchronousQueue | No (0) | Direct hand-off, cached-like pool | // | PriorityBlockingQueue | No | Priority scheduling | // | LinkedBlockingQueue() | No | Avoid in production! | With LinkedBlockingQueue (unbounded), the pool will NEVER grow beyond corePoolSize because tasks always fit in the queue — the extra threads above core are never needed. To actually use maximumPoolSize, you need a bounded queue or SynchronousQueue.

Rejection occurs when the pool is at maximumPoolSize and the queue is also full. AbortPolicy (the default) throws RejectedExecutionException, letting the caller handle the overload explicitly. CallerRunsPolicy runs the task on the submitting thread, naturally slowing the producer and providing backpressure without dropping work. DiscardPolicy silently drops the new task — use only for truly best-effort work. DiscardOldestPolicy removes the oldest waiting task from the queue and retries the submission, useful when recent data is more valuable than old.

// Rejection fires when: pool is at maximumPoolSize AND queue is full // Four built-in RejectedExecutionHandler implementations: // 1. AbortPolicy (DEFAULT) — throws RejectedExecutionException ThreadPoolExecutor p1 = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), new ThreadPoolExecutor.AbortPolicy()); try { p1.submit(() -> doWork()); } catch (RejectedExecutionException e) { // Handle: log, return error, drop task, etc. System.err.println("Task rejected: " + e.getMessage()); } // 2. DiscardPolicy — silently drops the new task (no exception!) ThreadPoolExecutor p2 = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), new ThreadPoolExecutor.DiscardPolicy()); // Danger: you lose tasks silently — use only for best-effort/fire-and-forget work // 3. DiscardOldestPolicy — drops the OLDEST waiting task and retries submit ThreadPoolExecutor p3 = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), new ThreadPoolExecutor.DiscardOldestPolicy()); // Makes sense when recent data is more valuable than old (sensor readings, events) // 4. CallerRunsPolicy — submitting thread runs the task itself (see section below) ThreadPoolExecutor p4 = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), new ThreadPoolExecutor.CallerRunsPolicy()); // 5. Custom rejection policy — implement RejectedExecutionHandler class LogAndQueuePolicy implements RejectedExecutionHandler { private final BlockingQueue<Runnable> overflow; LogAndQueuePolicy(BlockingQueue<Runnable> overflow) { this.overflow = overflow; } @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (executor.isShutdown()) return; // pool shut down — discard System.err.println("[WARN] Pool saturated — task queued to overflow buffer"); if (!overflow.offer(r)) { System.err.println("[ERROR] Overflow buffer also full — task dropped!"); } } } BlockingQueue<Runnable> overflowBuf = new LinkedBlockingQueue<>(5000); ThreadPoolExecutor p5 = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new LogAndQueuePolicy(overflowBuf));

ThreadPoolExecutor exposes real-time metrics that are invaluable for diagnosing saturation and tuning pool size. getPoolSize() and getActiveCount() show current thread utilization, getCompletedTaskCount() tracks throughput, and getQueue().size() reveals how many tasks are waiting. Combining these in a periodic monitoring thread or a health-check endpoint lets you detect saturation early and adjust corePoolSize or queue capacity in response.

// ThreadPoolExecutor exposes real-time metrics — no JMX needed ThreadPoolExecutor pool = new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200)); // Thread counts: int active = pool.getActiveCount(); // threads currently running tasks int poolSize = pool.getPoolSize(); // current number of threads (may be < core) int coreSize = pool.getCorePoolSize(); // configured core threads int maxSize = pool.getMaximumPoolSize(); // configured max threads int largest = pool.getLargestPoolSize(); // peak thread count since pool creation // Task counts: long submitted = pool.getTaskCount(); // tasks submitted (approx) long completed = pool.getCompletedTaskCount(); // tasks finished int queued = pool.getQueue().size(); // tasks waiting in queue int remaining = pool.getQueue().remainingCapacity(); // space left in queue // Compute pending (submitted but not yet completed): long pending = pool.getTaskCount() - pool.getCompletedTaskCount(); System.out.printf("Threads: %d active / %d pool / %d max%n", active, poolSize, maxSize); System.out.printf("Queue: %d waiting / %d remaining%n", queued, remaining); System.out.printf("Tasks: %d completed / %d pending%n", completed, pending); // Periodic monitoring thread: ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(); monitor.scheduleAtFixedRate(() -> { System.out.printf("[POOL] active=%d pool=%d queue=%d completed=%d%n", pool.getActiveCount(), pool.getPoolSize(), pool.getQueue().size(), pool.getCompletedTaskCount()); }, 0, 5, TimeUnit.SECONDS); // Hook into task lifecycle by subclassing: class InstrumentedPool extends ThreadPoolExecutor { InstrumentedPool(int core, int max, BlockingQueue<Runnable> q) { super(core, max, 60, TimeUnit.SECONDS, q); } @Override protected void beforeExecute(Thread t, Runnable r) { super.beforeExecute(t, r); System.out.println(t.getName() + " starting task"); } @Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); if (t != null) System.err.println("Task threw: " + t); } @Override protected void terminated() { super.terminated(); System.out.println("Pool fully shut down"); } }

shutdown() initiates an orderly shutdown: no new tasks are accepted but already-queued and currently-running tasks are allowed to complete. awaitTermination() blocks until all tasks finish or the timeout expires — always pair these two calls to wait for clean completion. shutdownNow() attempts to cancel queued tasks and interrupt running threads, returning the list of tasks that were never started; use it as a last resort or when fast exit is required. Always invoke the shutdown sequence in a try-finally block to ensure the pool is shut down even when exceptions occur.

// Two shutdown modes: // shutdown() — orderly shutdown // - Rejects NEW tasks immediately // - Allows already-queued and currently-running tasks to complete pool.shutdown(); // shutdownNow() — immediate shutdown // - Attempts to cancel all queued tasks (removes them from queue) // - Sends interrupt to running threads (they must handle it!) // - Returns list of tasks that were queued but never started List<Runnable> unstarted = pool.shutdownNow(); System.out.println("Discarded " + unstarted.size() + " queued tasks"); // Waiting for completion after shutdown(): pool.shutdown(); try { if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { // Timeout — force stop pool.shutdownNow(); if (!pool.awaitTermination(10, TimeUnit.SECONDS)) { System.err.println("Pool did not terminate"); } } } catch (InterruptedException e) { pool.shutdownNow(); Thread.currentThread().interrupt(); } // Registration as JVM shutdown hook for server applications: class ManagedPool { private final ThreadPoolExecutor pool = new ThreadPoolExecutor( 4, 8, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500)); ManagedPool() { Runtime.getRuntime().addShutdownHook(new Thread(() -> { System.out.println("Shutdown hook: draining pool..."); pool.shutdown(); try { pool.awaitTermination(15, TimeUnit.SECONDS); } catch (InterruptedException e) { pool.shutdownNow(); } System.out.println("Pool drained."); }, "pool-shutdown-hook")); } Future<?> submit(Runnable r) { return pool.submit(r); } } // State query: pool.isShutdown(); // true after shutdown() or shutdownNow() pool.isTerminating(); // true between shutdown() and full termination pool.isTerminated(); // true once all tasks have finished after shutdown Always combine shutdown() with awaitTermination() in production. Calling only shutdown() without waiting means the JVM may exit before running tasks finish — data loss in database writes, file writes, or message sends.

When the pool and queue are both full, CallerRunsPolicy makes the thread that submitted the task execute it directly instead of queuing or rejecting it. This occupies the submitting thread for the duration of the task, which naturally slows its submission rate and prevents further queue overflow — a self-regulating backpressure mechanism. It is ideal for batch processors and internal task queues where no work should be dropped. Avoid it when the submitting thread is a non-blocking I/O event loop, as blocking it would stall all I/O on that loop.

// CallerRunsPolicy — when the pool is saturated, the SUBMITTING thread // runs the task itself instead of queuing it or throwing. // Effect: submitting thread is blocked doing work → it submits fewer new tasks // → natural backpressure: the system slows down instead of crashing // Example: web server processing requests ThreadPoolExecutor handler = new ThreadPoolExecutor( 8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy() // <--- backpressure ); // Simulated request handler: void handleRequest(HttpRequest request) { handler.submit(() -> { processRequest(request); // normally runs in pool thread }); // If pool is saturated: // CallerRunsPolicy → THIS thread (the HTTP acceptor) runs processRequest() // HTTP acceptor is busy → accepts no new connections → backpressure to client // Client sees slower responses → backs off naturally } // Compare with AbortPolicy: // Pool saturated → RejectedExecutionException // Server must catch it and return HTTP 503 // Client must implement retry logic // Code is more complex; client must understand overload signal // CallerRunsPolicy pitfall — Netty / async I/O event loop: // If your submitting thread is a non-blocking event loop, CallerRunsPolicy // will block it while running the task → all I/O on that loop stalls. // In this case: prefer AbortPolicy + return 503 from the async handler. // Custom CallerRunsPolicy with logging: class LoggingCallerRunsPolicy implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (!executor.isShutdown()) { System.err.println("[BACKPRESSURE] Pool saturated; caller running task: " + Thread.currentThread().getName()); long start = System.nanoTime(); r.run(); long ms = (System.nanoTime() - start) / 1_000_000; System.err.println("[BACKPRESSURE] Caller-run task finished in " + ms + " ms"); } } } CallerRunsPolicy is self-tuning: as the pool saturates, callers slow their submission rate automatically. This makes it a good default for batch processors and internal task queues where tasks must eventually complete. For external-facing APIs, explicit 503 responses (using AbortPolicy) give cleaner client semantics.