How to Implement a Custom Thread Pool in Java for Learning
The Problem
I spent weeks reading about Java concurrency. I understood the theory behind thread pools, the producer-consumer pattern, and how ExecutorService works. But when I tried to explain how a thread pool actually functions internally, I couldn’t do it.
The gap between understanding concepts and implementing them became clear when I tried to answer a simple question: “How does a thread pool reuse threads instead of creating new ones for every task?”
I knew that thread pools improve performance by reusing threads. I knew about BlockingQueue for task management. But I couldn’t connect the pieces. That’s when someone on r/learnprogramming gave me the best advice:
“Do your own threadpool implementation, you’ll learn a lot.”
So I decided to build one from scratch.
What I Needed to Build
A thread pool has these core components:
+------------------+ +-------------------+| Client Code | | Task Queue || submit(task) ---+---->+ BlockingQueue |+------------------+ +-------------------+ ^ | +----------------------+----------------------+ | | | +----+----+ +----+----+ +----+----+ | Worker1 | | Worker2 | | Worker3 | | Thread | | Thread | | Thread | +---------+ +---------+ +---------+ | | | v v v Executes task Executes task Executes taskThe key pieces:
- Task Queue - A blocking queue that holds pending tasks
- Worker Threads - Threads that pull tasks from the queue and execute them
- Thread Manager - Controls thread lifecycle and pool shutdown
- Task Submission API - Methods to submit
RunnableorCallabletasks
My First Implementation
I started with a minimal version to understand the basics:
import java.util.concurrent.BlockingQueue;import java.util.concurrent.LinkedBlockingQueue;
public class SimpleThreadPool { private final BlockingQueue<Runnable> taskQueue; private final Thread[] workers; private volatile boolean isShutdown = false;
public SimpleThreadPool(int poolSize) { this.taskQueue = new LinkedBlockingQueue<>(); this.workers = new Thread[poolSize];
for (int i = 0; i < poolSize; i++) { workers[i] = new Worker("Pool-Worker-" + i); workers[i].start(); } }
public void submit(Runnable task) { if (!isShutdown) { taskQueue.offer(task); } }
public void shutdown() { isShutdown = true; for (Thread worker : workers) { worker.interrupt(); } }
private class Worker extends Thread { public Worker(String name) { super(name); }
@Override public void run() { while (!isShutdown || !taskQueue.isEmpty()) { try { Runnable task = taskQueue.take(); task.run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }}The key insight: taskQueue.take() blocks until a task is available. This is how worker threads wait for work without consuming CPU. When a task is submitted via offer(), a waiting worker wakes up and takes it.
I tested it with a simple demo:
public class ThreadPoolDemo { public static void main(String[] args) throws InterruptedException { SimpleThreadPool pool = new SimpleThreadPool(4);
for (int i = 0; i < 10; i++) { final int taskId = i; pool.submit(() -> { System.out.println("Task " + taskId + " executed by " + Thread.currentThread().getName()); try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); }
Thread.sleep(1000); pool.shutdown(); }}The output showed tasks being distributed across workers:
Task 0 executed by Pool-Worker-0Task 1 executed by Pool-Worker-1Task 2 executed by Pool-Worker-2Task 3 executed by Pool-Worker-3Task 4 executed by Pool-Worker-0Task 5 executed by Pool-Worker-1...The Problem with My First Version
My simple implementation worked, but it had issues:
- No return values - I could only submit
Runnabletasks, notCallabletasks that return results - Busy waiting on shutdown - Workers would stop immediately when interrupted, even if tasks were still running
- No task rejection handling - What happens when the pool is shut down but someone tries to submit a task?
The most important missing piece was support for Callable<T> and Future<T>. In Java, Runnable.run() returns void, but Callable.call() returns a value. I needed a way to capture that result.
Adding Future Support
To support Callable tasks with return values, I created a wrapper class that implements Runnable but stores the result:
import java.util.concurrent.Callable;
public class FutureTask<T> implements Runnable { private final Callable<T> callable; private T result; private Exception exception; private volatile boolean isDone = false;
public FutureTask(Callable<T> callable) { this.callable = callable; }
@Override public void run() { try { result = callable.call(); } catch (Exception e) { exception = e; } isDone = true; }
public T get() throws Exception { while (!isDone) { Thread.yield(); } if (exception != null) { throw exception; } return result; }
public boolean isDone() { return isDone; }}The get() method blocks until the task completes, then returns the result or throws the exception if one occurred. This is a simplified version of what java.util.concurrent.FutureTask does.
Now I could add submit(Callable) to my pool:
import java.util.concurrent.Callable;
public class ThreadPoolWithFuture { private final BlockingQueue<Runnable> taskQueue; private final Thread[] workers; private volatile boolean isShutdown = false;
public ThreadPoolWithFuture(int poolSize) { this.taskQueue = new LinkedBlockingQueue<>(); this.workers = new Thread[poolSize];
for (int i = 0; i < poolSize; i++) { workers[i] = new Thread(() -> { while (!isShutdown || !taskQueue.isEmpty()) { try { Runnable task = taskQueue.take(); task.run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); workers[i].start(); } }
public void submit(Runnable task) { if (!isShutdown) { taskQueue.offer(task); } }
public <T> FutureTask<T> submit(Callable<T> task) { FutureTask<T> futureTask = new FutureTask<>(task); submit(futureTask); return futureTask; }
public void shutdown() { isShutdown = true; for (Thread worker : workers) { worker.interrupt(); } }}Here’s how to use it:
import java.util.concurrent.Callable;
public class FutureDemo { public static void main(String[] args) throws Exception { ThreadPoolWithFuture pool = new ThreadPoolWithFuture(4);
FutureTask<Integer> future = pool.submit(() -> { int sum = 0; for (int i = 1; i <= 100; i++) { sum += i; } return sum; });
System.out.println("Result: " + future.get()); pool.shutdown(); }}Output:
Result: 5050What I Learned
After implementing this thread pool, I finally understood how ExecutorService works internally:
Producer-Consumer Pattern
The task queue is the shared buffer between producers (code submitting tasks) and consumers (worker threads). BlockingQueue handles all the synchronization automatically:
offer()adds tasks without blockingtake()blocks when the queue is empty and wakes up when a task arrives
Thread Reuse
Workers don’t die after executing a task. They loop back to taskQueue.take() and wait for the next task. This is why thread pools are efficient - no thread creation overhead for each task.
Graceful Shutdown
The volatile boolean isShutdown flag ensures all workers see the shutdown signal. The interrupt() call wakes up any blocked workers waiting on take().
Design Patterns
Building this taught me several patterns:
- Factory Pattern: Could add a
ThreadFactoryto customize thread creation - Strategy Pattern: Rejection policies (what to do when queue is full)
- Template Method: The worker loop follows a template that subclasses could customize
Comparing with Java’s ThreadPoolExecutor
After my implementation, I read the ThreadPoolExecutor source code. Java’s implementation adds more features:
- Core vs maximum pool size (grows/shrinks pool based on load)- Keep-alive time (idle threads timeout and terminate)- Work queue types (ArrayBlockingQueue, LinkedBlockingQueue, SynchronousQueue)- Rejection policies (AbortPolicy, CallerRunsPolicy, DiscardPolicy)- Thread factory for custom thread configuration- Before/after execute hooks for monitoringBut my simple implementation captured the core idea. That’s what made it valuable for learning.
Summary
In this post, I showed how to implement a custom thread pool in Java to understand concurrency internals. The key point is that a thread pool combines the producer-consumer pattern with thread lifecycle management - tasks are produced by client code and consumed by reusable worker threads.
The implementation took me about 3 days of focused work. I started with a minimal Runnable-only pool, then added Callable support with Future for return values. Reading the actual ThreadPoolExecutor source code afterward solidified my understanding.
If you want to deepen your Java concurrency knowledge, I recommend:
- Build your own thread pool first
- Test it with different task types and pool sizes
- Read
ThreadPoolExecutorsource in the JDK - Compare your implementation with
Executors.newFixedThreadPool()
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 java.util.concurrent Package Documentation
- 👨💻 ThreadPoolExecutor Source Code
- 👨💻 Reddit Thread on Java Projects
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments