Mastering Custom ThreadPoolExecutor: Build Flexible Java Thread Pools
This article explains why the standard Executors factory methods may be insufficient, details each ThreadPoolExecutor constructor parameter, demonstrates how to configure a custom pool for performance testing, and walks through multiple code examples that illustrate thread creation, queue behavior, and rejection policies.
The standard java.util.concurrent.Executors factory methods are convenient but lack flexibility for complex multithreading scenarios, prompting the need for custom thread pools that allow precise control over thread count, lifecycle, and scalability.
corePoolSize
Defines the number of core threads that are always kept alive. New tasks create threads until this number is reached; thereafter tasks are queued. Core threads are retained even when idle beyond the keep‑alive time.
maximumPoolSize
Specifies the maximum number of threads the pool can grow to. Threads beyond the core count are created only when the work queue is full.
keepAliveTime and unit
Together they set the maximum idle time for non‑core threads. When a thread remains idle longer than this duration, it is terminated to free resources.
workQueue
The queue that holds tasks awaiting execution. It must be a BlockingQueue<Runnable> and provides thread‑safe storage.
Thread‑safe queue implementation.
Producers block when the queue is full.
Only objects of type java.lang.Runnable are accepted.
threadFactory
Creates new Thread instances for the pool. Implementations must provide Thread newThread(Runnable r) to wrap a task in a thread.
handler
Defines the rejection policy invoked when a task cannot be submitted. The JDK supplies four standard policies: AbortPolicy: throws RejectedExecutionException. DiscardPolicy: silently discards the task. DiscardOldestPolicy: discards the oldest queued task and retries. CallerRunsPolicy: runs the task in the calling thread.
Key practical insights:
New threads are created either when a task arrives and the current thread count is below corePoolSize, or when the queue is full and the count is below maximumPoolSize.
Rejection policies are triggered only after both the queue is full and the pool has reached its maximum size.
Choosing an appropriate queue capacity is critical: an overly large queue can cause tasks to pile up and effectively reduce the pool to a fixed‑size executor.
Selecting a suitable rejection strategy balances throughput and resource usage; custom policies can be implemented when the built‑in ones are insufficient.
Example 1 – Core 0, Max 2, Queue capacity 2
package org.funtester.performance.books.chapter01.section3;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* Thread pool creation demo
*/
public class CreateThreadDemo {
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor executor = new ThreadPoolExecutor(0, 2, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2)); // create pool
for (int i = 0; i < 4; i++) {
int index = i; // task identifier
Thread.sleep(200);
executor.execute(() -> {
try {
Thread.sleep(1000); // simulate work
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + " " + System.currentTimeMillis() + " " + index + " execute task");
});
System.out.println(Thread.currentThread().getName() + " " + System.currentTimeMillis() + " " + index + " submit task");
}
executor.shutdown(); // no new tasks, finish queued ones
}
}Running this code produces four submission logs followed by a single execution thread because the pool initially creates only one thread (core size 0, max 2, but the queue is not full).
main 1712996368590 0 submit task
main 1712996368795 1 submit task
main 1712996369000 2 submit task
main 1712996369201 3 submit task
pool-1-thread-1 1712996369591 0 execute task
pool-1-thread-1 1712996370592 1 execute task
pool-1-thread-1 1712996371593 2 execute task
pool-1-thread-1 1712996372594 3 execute taskExample 2 – Queue capacity 2
main 1712996440747 0 submit task
main 1712996440950 1 submit task
main 1712996441155 2 submit task
main 1712996441359 3 submit task
pool-1-thread-1 1712996441748 0 execute task
pool-1-thread-2 1712996442361 3 execute task
pool-1-thread-1 1712996442749 1 execute task
pool-1-thread-2 1712996443362 2 execute taskAnalysis shows that after the queue fills, the pool creates a second thread to handle the fourth task, while earlier tasks wait in the queue.
Example 3 – Core 2, Max 3, Queue 10
main 1712997072632 0 submit task
main 1712997072832 1 submit task
main 1712997073038 2 submit task
main 1712997073242 3 submit task
pool-1-thread-1 1712997073643 0 execute task
pool-1-thread-2 1712997073834 1 execute task
pool-1-thread-1 1712997074644 2 execute task
pool-1-thread-2 1712997074834 3 execute taskHere the pool immediately creates two core threads because the current count is below corePoolSize, even though the queue is not full.
The underlying execution logic can be seen in ThreadPoolExecutor#execute:
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (!isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
} else if (!addWorker(command, false)) {
reject(command);
}When the queue cannot accept a task, addWorker is invoked with the task and false indicating a non‑core thread.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
