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.