Creating a new thread for every task is expensive. Thread pools reuse a fixed set of worker threads, improving performance and resource management.

ExecutorService

The primary interface for submitting tasks to a thread pool:

  ExecutorService executor = Executors.newFixedThreadPool(4);

executor.submit(() -> System.out.println("Task running on " + Thread.currentThread().getName()));

Future<Integer> future = executor.submit(() -> 42);
System.out.println(future.get()); // blocks until result is ready

executor.shutdown();
executor.awaitTermination(60, TimeUnit.SECONDS);
  

Common Thread Pool Types

  // Fixed size — best for CPU-bound workloads with known concurrency
ExecutorService fixed = Executors.newFixedThreadPool(4);

// Cached — creates threads as needed, reuses idle threads
ExecutorService cached = Executors.newCachedThreadPool();

// Single thread — sequential execution, tasks queued
ExecutorService single = Executors.newSingleThreadExecutor();

// Scheduled — delayed and periodic tasks
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);
scheduled.scheduleAtFixedRate(() -> System.out.println("Tick"), 0, 1, TimeUnit.SECONDS);
  

Custom ThreadPoolExecutor

For production systems, prefer explicit configuration over Executors factory methods:

  ThreadPoolExecutor pool = new ThreadPoolExecutor(
    4,                              // core pool size
    8,                              // maximum pool size
    60L, TimeUnit.SECONDS,          // keep-alive time
    new LinkedBlockingQueue<>(100), // work queue
    new ThreadFactory() {
        private final AtomicInteger count = new AtomicInteger();
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, "worker-" + count.incrementAndGet());
            t.setDaemon(false);
            return t;
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);
  

Rejection Policies

Policy Behavior
AbortPolicy Throws RejectedExecutionException (default)
CallerRunsPolicy Runs task in the calling thread
DiscardPolicy Silently drops the task
DiscardOldestPolicy Drops oldest queued task

Sizing Thread Pools

Workload Type Suggested Size
CPU-bound Runtime.getRuntime().availableProcessors()
I/O-bound cores * (1 + wait/compute ratio) — often 2× cores or more
Mixed Measure and tune
  int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuPool = Executors.newFixedThreadPool(cores);
ExecutorService ioPool = Executors.newFixedThreadPool(cores * 2);
  

ForkJoinPool

Designed for divide-and-conquer tasks (used by parallel streams):

  ForkJoinPool pool = ForkJoinPool.commonPool();

int sum = pool.invoke(new RecursiveTask<Integer>() {
    @Override
    protected Integer compute() {
        return IntStream.range(1, 1000).sum();
    }
});
  

For custom recursive tasks, extend RecursiveTask<V> (returns value) or RecursiveAction (no return value).

CompletableFuture Integration

  ExecutorService executor = Executors.newFixedThreadPool(4);

CompletableFuture.supplyAsync(() -> fetchData(), executor)
    .thenApplyAsync(data -> process(data), executor)
    .thenAcceptAsync(result -> save(result), executor);
  

Always shut down the executor when done:

  executor.shutdown();
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
    executor.shutdownNow();
}
  

Best Practices

  • Never use Executors.newCachedThreadPool() in production without bounds — it can create unlimited threads
  • Always call shutdown() and handle graceful termination
  • Use meaningful thread names for debugging
  • Prefer ThreadPoolExecutor with explicit bounds over unbounded factory methods
  • Pass a dedicated executor to CompletableFuture.supplyAsync() instead of using the common pool