Executor Service & Thread Pools

Creating a new thread for every task is expensive. Each thread consumes ~1MB of stack memory and requires OS context switching. Instead of managing threads manually, Java provides the Executor Framework to reuse threads efficiently.

1. Why Use Thread Pools?

A Thread Pool is a collection of pre-started threads that are ready to execute tasks.

  • Reuse: Threads are returned to the pool after finishing a task, saving creation costs.
  • Throttling: Limits the number of active threads to prevent crashing the CPU.
  • Management: Provides lifecycle methods (shutdown, awaitTermination).

2. Types of Thread Pools

The Executors factory class provides several pre-configured pools:

Pool Type Description Use Case
FixedThreadPool(n) Reuse a fixed number of threads. If all are busy, tasks wait in a queue. Predictable load, stable servers.
CachedThreadPool Creates new threads as needed, but reuses idle ones. Threads die after 60s of inactivity. Short-lived async tasks.
SingleThreadExecutor Uses a single worker thread to execute tasks sequentially. Ensuring order (like an event loop).
ScheduledThreadPool Executes tasks after a delay or periodically. Background maintenance, cron jobs.
// Create a pool with 4 threads
ExecutorService executor = Executors.newFixedThreadPool(4);

for (int i = 0; i < 10; i++) {
  int taskId = i;
  executor.submit(() -> {
    System.out.println("Executing Task " + taskId + " on " + Thread.currentThread().getName());
  });
}

// Always shutdown your pool!
executor.shutdown();

Interactive: Thread Pool Visualizer

Watch how tasks queue up when the worker threads are busy.

Task Queue
Worker Threads (Pool Size: 3)
Thread-1 Idle
Thread-2 Idle
Thread-3 Idle

3. Handling Results with Callable and Future

Standard Runnable tasks cannot return a result or throw checked exceptions. Use Callable<T> instead.

Callable<Integer> task = () -> {
  Thread.sleep(1000);
  return 42;
};

ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Integer> future = executor.submit(task);

// Do other work here...

try {
  // Blocks until the result is ready
  Integer result = future.get();
  System.out.println("Result: " + result);
} catch (InterruptedException | ExecutionException e) {
  e.printStackTrace();
}

4. Go Implementation: Worker Pools

Go does not have a built-in “Thread Pool” class because goroutines are cheap. However, to throttle concurrency (e.g., limit DB connections), we use the Worker Pool Pattern with channels.

package main

import (
  "fmt"
  "sync"
  "time"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
  defer wg.Done()
  for j := range jobs {
    fmt.Printf("Worker %d started job %d\n", id, j)
    time.Sleep(time.Second) // Simulate work
    fmt.Printf("Worker %d finished job %d\n", id, j)
    results <- j * 2
  }
}

func main() {
  const numJobs = 5
  jobs := make(chan int, numJobs)
  results := make(chan int, numJobs)
  var wg sync.WaitGroup

  // Start 3 workers (Fixed Pool equivalent)
  for w := 1; w <= 3; w++ {
    wg.Add(1)
    go worker(w, jobs, results, &wg)
  }

  // Send jobs
  for j := 1; j <= numJobs; j++ {
    jobs <- j
  }
  close(jobs)

  // Wait for workers in background
  go func() {
    wg.Wait()
    close(results)
  }()

  // Collect results
  for r := range results {
    fmt.Println("Result:", r)
  }
}

5. Best Practices

  1. Always Shutdown: Executor services keep the JVM running. Use executor.shutdown() or try-with-resources (Java 19+).
  2. Avoid Unbounded Queues: newFixedThreadPool uses an unbounded queue. If tasks come in faster than they are processed, you will get an OutOfMemoryError.
  3. Use virtualThreadPerTaskExecutor: In Java 21+, if your tasks are blocking (I/O), use virtual threads instead of pooling platform threads.

[!TIP] Virtual Threads vs Pools:

  • Platform Threads (Old): Must be pooled because they are expensive (1MB RAM).
  • Virtual Threads (New): Should NOT be pooled. Create a new virtual thread for every task.