00:00

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

FixedPoolDemo.java
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);
    }
}
Output
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 methodBehaviourUse for
newFixedThreadPool(n)Exactly n threads, unbounded queueSteady workloads with a known level of parallelism
newCachedThreadPool()Creates threads as needed, reuses idle ones, removes them after 60 sMany short tasks (beware of unbounded growth)
newSingleThreadExecutor()One thread; tasks run sequentially in orderOrdered background work
newScheduledThreadPool(n)Runs tasks after a delay or periodicallyTimers, polling, health checks
newWorkStealingPool()ForkJoinPool using all coresCPU-bound, divisible tasks
newVirtualThreadPerTaskExecutor()New virtual thread per task (Java 21)Large numbers of blocking I/O tasks

4. Scheduled Thread Pools

ScheduledPoolDemo.java
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");
    }
}
Output
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:

CustomThreadPool.java
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());
    }
}
Output
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:

  1. While fewer than core threads exist, start a new thread (tasks 1–2).
  2. Otherwise, put the task in the queue if there is room (tasks 3–4).
  3. If the queue is full, start extra threads up to maximum (tasks 5–6).
  4. If that is also full, reject the task (task 7).

Rejection policies

  • AbortPolicy (default): throws RejectedExecutionException.
  • 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 newFixedThreadPool can 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, ExecutorService is AutoCloseable: try-with-resources calls close(), 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:

VirtualPerTask.java
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));
    }
}
Output
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.
  • Executors provides fixed, cached, single, scheduled, work-stealing and virtual-thread executors.
  • ThreadPoolExecutor gives 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