Introduction
Thread pools are a fundamental component of modern Java applications, providing efficient management of concurrent task execution. While Java's Executors class offers convenient factory methods for creating thread pools, there are many scenarios where custom thread pool configuration is necessary for optimal performance and resource management.
Why Custom Thread Pools?
The standard Executors.newFixedThreadPool() and Executors.newCachedThreadPool() methods work well for simple cases, but they lack fine-grained control. Custom thread pools allow you to:
- Set specific queue sizes to prevent memory issues
- Define custom rejection policies for overload scenarios
- Configure thread factory for custom thread creation
- Set appropriate keep-alive times
- Monitor and tune pool performance
Creating a Custom Thread Pool
Here's how to create a custom thread pool using ThreadPoolExecutor:
import java.util.concurrent.*;
public class CustomThreadPool {
private final ThreadPoolExecutor executor;
public CustomThreadPool(int corePoolSize, int maxPoolSize,
long keepAliveTime, int queueCapacity) {
// Custom thread factory
ThreadFactory threadFactory = new CustomThreadFactory("custom-pool");
// Rejection policy
RejectedExecutionHandler rejectionHandler = new CustomRejectionPolicy();
// Work queue
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(queueCapacity);
this.executor = new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
workQueue,
threadFactory,
rejectionHandler
);
}
// Custom thread factory implementation
private static class CustomThreadFactory implements ThreadFactory {
private final String namePrefix;
private final AtomicInteger threadNumber = new AtomicInteger(1);
public CustomThreadFactory(String poolName) {
namePrefix = poolName + "-thread-";
}
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, namePrefix + threadNumber.getAndIncrement());
thread.setDaemon(false);
thread.setPriority(Thread.NORM_PRIORITY);
return thread;
}
}
// Custom rejection policy
private static class CustomRejectionPolicy implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// Log the rejection or implement custom logic
System.err.println("Task rejected: " + r.toString());
throw new RejectedExecutionException("Task " + r.toString() +
" rejected from " + executor.toString());
}
}
public void execute(Runnable task) {
executor.execute(task);
}
public Future<?> submit(Runnable task) {
return executor.submit(task);
}
public void shutdown() {
executor.shutdown();
}
// Monitor methods
public int getActiveCount() {
return executor.getActiveCount();
}
public long getCompletedTaskCount() {
return executor.getCompletedTaskCount();
}
public int getQueueSize() {
return executor.getQueue().size();
}
}
Key Configuration Parameters
Core and Maximum Pool Sizes
// Core threads are always kept alive int corePoolSize = 5; // Maximum number of threads that can be created int maxPoolSize = 20;
Keep-Alive Time
// Time that excess idle threads will wait for new tasks long keepAliveTime = 60L;
Work Queue
// Bounded queue to prevent unlimited memory growth BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(100); // Or use a synchronous queue for direct hand-off BlockingQueue<Runnable> syncQueue = new SynchronousQueue<>();
Advanced Configuration Example
Here's a more sophisticated implementation with monitoring capabilities:
public class MonitoredThreadPool extends ThreadPoolExecutor {
private final Counter completedTasks = new Counter();
private final Histogram taskTimeHistogram = new Histogram();
public MonitoredThreadPool(int corePoolSize, int maxPoolSize,
long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maxPoolSize, keepAliveTime, unit, workQueue);
}
@Override
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
// Record start time or add monitoring logic
}
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
completedTasks.increment();
// Record execution metrics
}
@Override
protected void terminated() {
super.terminated();
// Cleanup or final logging
}
public double getAverageActiveTime() {
return getActiveCount() > 0 ?
(double) getCompletedTaskCount() / getActiveCount() : 0;
}
}
Best Practices
- Queue Sizing: Use bounded queues in production to prevent out-of-memory errors
- Rejection Policies: Implement meaningful rejection handling
- Monitoring: Track pool metrics for capacity planning
- Clean Shutdown: Always properly shutdown thread pools
- Thread Naming: Use descriptive thread names for debugging
Common Rejection Policies
// Caller runs policy - executes task in caller's thread RejectedExecutionHandler callerRuns = new ThreadPoolExecutor.CallerRunsPolicy(); // Discard policy - silently discards the task RejectedExecutionHandler discardPolicy = new ThreadPoolExecutor.DiscardPolicy(); // Discard oldest policy - discards the oldest queued task RejectedExecutionHandler discardOldest = new ThreadPoolExecutor.DiscardOldestPolicy();
Conclusion
Custom thread pool configuration provides the flexibility needed for production-grade applications. By understanding and properly configuring core pool size, maximum pool size, work queues, and rejection policies, developers can create robust, efficient concurrent systems that handle varying workloads effectively while maintaining system stability.
The key is to monitor performance and adjust configurations based on actual usage patterns and requirements of your specific application.