Thread Pool in Java – ExecutorService and ThreadPoolExecutor
A thread pool is a group of pre-created, reusable worker threads that execute submitted tasks. Instead of creating a new thread for every task, you hand tasks to the pool; idle workers pick them up from a queue. In Java, thread pools are used through the ExecutorService interface in java.util.concurrent.
This tutorial explains why pools are used, the ready-made pools in Executors, how to configure a ThreadPoolExecutor yourself, rejection policies, correct shutdown, and how virtual threads change the picture in Java 21.
1. Why Use a Thread Pool?
- Lower overhead: creating and destroying platform threads is expensive; pools reuse them.
- Bounded resources: a fixed number of threads stops a traffic spike from creating thousands of threads and exhausting memory.
- Task management: futures, scheduling, cancellation and graceful shutdown come built in.
- Separation of concerns: code describes what to run; the executor decides how.
2. A Fixed Thread Pool
import java.util.*;
import java.util.concurrent.*;
public class FixedPoolDemo {
public static void main(String[] args) throws Exception {
Set<String> workerNames = ConcurrentHashMap.newKeySet();
List<Future<String>> results = new ArrayList<>();
ExecutorService pool = Executors.newFixedThreadPool(3); // 3 reusable threads
for (int i = 1; i <= 10; i++) {
int orderId = i;
results.add(pool.submit(() -> {
workerNames.add(Thread.currentThread().getName());
Thread.sleep(50); // simulate work
return "order-" + orderId + " processed";
}));
}
for (Future<String> f : results) System.out.println(f.get()); // in submission order
pool.shutdown(); // no new tasks; finish queued ones
boolean done = pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("10 tasks ran on " + workerNames.size() + " threads; terminated: " + done);
}
}
order-1 processed order-2 processed order-3 processed order-4 processed order-5 processed order-6 processed order-7 processed order-8 processed order-9 processed order-10 processed 10 tasks ran on 3 threads; terminated: true
Ten tasks were processed by just three threads. Reading the futures in submission order gives results in a predictable order, even though tasks may finish in any order.
3. Ready-Made Pools in Executors
| Factory method | Behaviour | Use for |
|---|---|---|
newFixedThreadPool(n) | Exactly n threads, unbounded queue | Steady workloads with a known level of parallelism |
newCachedThreadPool() | Creates threads as needed, reuses idle ones, removes them after 60 s | Many short tasks (beware of unbounded growth) |
newSingleThreadExecutor() | One thread; tasks run sequentially in order | Ordered background work |
newScheduledThreadPool(n) | Runs tasks after a delay or periodically | Timers, polling, health checks |
newWorkStealingPool() | ForkJoinPool using all cores | CPU-bound, divisible tasks |
newVirtualThreadPerTaskExecutor() | New virtual thread per task (Java 21) | Large numbers of blocking I/O tasks |
4. Scheduled Thread Pools
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ScheduledPoolDemo {
public static void main(String[] args) throws Exception {
try (ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1)) {
ScheduledFuture<String> once = scheduler.schedule(() -> "reminder sent", 200, TimeUnit.MILLISECONDS);
System.out.println("Delayed task: " + once.get());
AtomicInteger ticks = new AtomicInteger();
CountDownLatch three = new CountDownLatch(3);
ScheduledFuture<?> repeating = scheduler.scheduleAtFixedRate(() -> {
System.out.println("health check #" + ticks.incrementAndGet());
three.countDown();
}, 0, 100, TimeUnit.MILLISECONDS);
three.await();
repeating.cancel(false);
}
System.out.println("scheduler closed");
}
}
Delayed task: reminder sent health check #1 health check #2 health check #3 scheduler closed
scheduleAtFixedRate starts runs at a fixed rate; scheduleWithFixedDelay waits a fixed delay after each run ends. If a periodic task throws an exception, later runs are cancelled silently, so catch exceptions inside periodic tasks.
5. Configuring ThreadPoolExecutor
The Executors methods are wrappers around ThreadPoolExecutor. Creating it directly lets you set every parameter, which is recommended for production services:
import java.util.concurrent.*;
public class CustomThreadPool {
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // core pool size
4, // maximum pool size
30, TimeUnit.SECONDS, // idle time before extra threads die
new ArrayBlockingQueue<>(2), // bounded work queue
Thread.ofPlatform().name("api-", 1).factory(),
new ThreadPoolExecutor.AbortPolicy() // reject when full
);
CountDownLatch release = new CountDownLatch(1);
for (int i = 1; i <= 7; i++) {
int id = i;
try {
pool.execute(() -> { try { release.await(); } catch (InterruptedException e) { } });
System.out.printf("task %d accepted: threads=%d, queued=%d%n", id, pool.getPoolSize(), pool.getQueue().size());
} catch (RejectedExecutionException e) {
System.out.println("task " + id + " REJECTED (4 threads busy, queue of 2 full)");
}
}
release.countDown();
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("completed tasks: " + pool.getCompletedTaskCount());
}
}
task 1 accepted: threads=1, queued=0 task 2 accepted: threads=2, queued=0 task 3 accepted: threads=2, queued=1 task 4 accepted: threads=2, queued=2 task 5 accepted: threads=3, queued=2 task 6 accepted: threads=4, queued=2 task 7 REJECTED (4 threads busy, queue of 2 full) completed tasks: 6
The output shows exactly how a ThreadPoolExecutor handles new tasks:
- While fewer than core threads exist, start a new thread (tasks 1–2).
- Otherwise, put the task in the queue if there is room (tasks 3–4).
- If the queue is full, start extra threads up to maximum (tasks 5–6).
- If that is also full, reject the task (task 7).
Rejection policies
AbortPolicy(default): throwsRejectedExecutionException.CallerRunsPolicy: the submitting thread runs the task itself, which naturally slows down producers.DiscardPolicy: silently drops the task.DiscardOldestPolicy: drops the oldest queued task and retries.
6. Choosing the Pool Size
- CPU-bound tasks: about the number of cores (
Runtime.getRuntime().availableProcessors()). - I/O-bound tasks: more threads, roughly cores × (1 + wait time / compute time), or use virtual threads.
- Prefer bounded queues; the unbounded queues of
newFixedThreadPoolcan grow until memory runs out under heavy load. - Measure under realistic load and tune.
7. Shutting Down Correctly
shutdown(): stop accepting tasks and let queued tasks finish.awaitTermination(timeout): wait for completion.shutdownNow(): interrupt running tasks and return the queued ones that never started.- Since Java 19,
ExecutorServiceisAutoCloseable: try-with-resources callsclose(), which shuts down and waits.
Forgetting to shut down a pool of user threads keeps the JVM running after main ends.
8. Virtual Threads: Do You Still Need a Pool?
Virtual threads are cheap, so for I/O-bound work you no longer need to pool them; create one per task:
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class VirtualPerTask {
public static void main(String[] args) throws Exception {
long start = System.nanoTime();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 5_000)
.mapToObj(i -> ex.submit(() -> { Thread.sleep(200); return i; }))
.toList();
long sum = 0;
for (var f : futures) sum += f.get();
System.out.println("sum = " + sum);
}
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("5,000 blocking tasks of 200 ms finished in under 2 s: " + (ms < 2000));
}
}
sum = 12497500 5,000 blocking tasks of 200 ms finished in under 2 s: true
With a fixed pool of, say, 100 platform threads, the same work would take about 10 seconds. Keep platform thread pools for CPU-bound work, and use semaphores to limit concurrent access to scarce resources such as database connections.
9. Summary
- A thread pool reuses a set of worker threads to execute many tasks.
Executorsprovides fixed, cached, single, scheduled, work-stealing and virtual-thread executors.ThreadPoolExecutorgives control over core and max size, queue, thread factory and rejection policy.- Always shut pools down; prefer bounded queues in production.
- For blocking I/O on Java 21+, a virtual thread per task often replaces pooling.
Related Tutorials
Frequently Asked Questions (FAQ)
Q1. What is a thread pool in Java?
A collection of reusable worker threads managed by an ExecutorService, which executes submitted tasks from a queue.
Q2. What is ExecutorService?
An interface for submitting tasks (Runnable or Callable) and managing their execution, results and shutdown.
Q3. What is the difference between execute() and submit()?
execute() runs a Runnable and returns nothing. submit() accepts Runnable or Callable and returns a Future for the result or exception.
Q4. What is the difference between shutdown() and shutdownNow()?
shutdown() lets queued and running tasks finish. shutdownNow() interrupts running tasks and returns tasks that never started.
Q5. What happens if I don't shut down an executor?
Its non-daemon threads stay alive and can prevent the JVM from exiting.
Q6. What is the difference between a fixed and a cached thread pool?
A fixed pool has a constant number of threads and queues extra tasks. A cached pool creates new threads as needed and removes idle ones after 60 seconds.
Q7. What are corePoolSize and maximumPoolSize?
Core size is the number of threads kept alive normally. Maximum size is the upper limit, reached only when the queue is full.
Q8. What is a rejection policy?
The handler that decides what happens when a task cannot be accepted: abort with an exception, run in the caller, discard it, or discard the oldest queued task.
Q9. How many threads should a pool have?
About the number of cores for CPU-bound work, more for I/O-bound work. Measure under real load, or use virtual threads for blocking I/O.
Q10. Why is newFixedThreadPool risky in production?
It uses an unbounded queue, so under sustained overload tasks pile up until memory runs out. A ThreadPoolExecutor with a bounded queue avoids this.
Q11. What is ScheduledExecutorService?
An executor that runs tasks after a delay or periodically, replacing the older Timer class.
Q12. What is the difference between scheduleAtFixedRate and scheduleWithFixedDelay?
Fixed rate starts runs at regular intervals. Fixed delay waits the given time after each run finishes before starting the next.
Q13. What is ForkJoinPool?
A work-stealing pool designed for recursive, divisible tasks. Parallel streams and CompletableFuture async methods use its common pool by default.
Q14. What happens to exceptions in pooled tasks?
With submit(), they are captured in the Future and rethrown by get() as ExecutionException. With execute(), they go to the thread's uncaught exception handler.
Q15. Should virtual threads be pooled?
No. Create a new virtual thread per task with newVirtualThreadPerTaskExecutor(). Use semaphores to limit access to scarce resources.
Q16. What is invokeAll()?
A method that submits a collection of Callable tasks and waits until all complete, returning their futures. invokeAny() returns the result of the first that succeeds.
Q17. What is thread pool starvation?
A deadlock-like state where all pool threads wait for tasks that are queued behind them in the same pool and can never run.
Q18. How do I name threads in a pool?
Pass a ThreadFactory, for example Thread.ofPlatform().name("api-", 1).factory(), to the executor.
Q19. Can I use try-with-resources with ExecutorService?
Yes, since Java 19. close() calls shutdown() and waits for all tasks to finish.
Q20. Is ExecutorService thread-safe?
Yes. Multiple threads can submit tasks to the same executor concurrently.