Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.


← Java Concurrency & Multithreading

Threads & Runnable

  • Introduction to Threads
  • ExecutorService & Thread Pools
  • Fork/Join Framework

Synchronization & Memory Model

  • synchronized and Locks
  • volatile and the Memory Model
  • CompletableFuture
  • Deadlock, Starvation, Livelock

Concurrent Collections

  • ConcurrentHashMap & CopyOnWriteArrayList
  • HashMap Concurrency Variants
  • Concurrent Utilities & Coordination
Chaturmind
← Java Concurrency & Multithreading

Threads & Runnable

  • Introduction to Threads
  • ExecutorService & Thread Pools
  • Fork/Join Framework

Synchronization & Memory Model

  • synchronized and Locks
  • volatile and the Memory Model
  • CompletableFuture
  • Deadlock, Starvation, Livelock

Concurrent Collections

  • ConcurrentHashMap & CopyOnWriteArrayList
  • HashMap Concurrency Variants
  • Concurrent Utilities & Coordination
HomeLearnJavaJava Concurrency & MultithreadingThreads & Runnable
✓ FreeIntermediate· 12 min read

ExecutorService & Thread Pools

Stop creating raw threads — use ExecutorService and thread pools correctly.

Published September 21, 2026


ExecutorService and Thread Pools

Creating raw threads is expensive. The ExecutorService framework provides a managed pool of threads that can be reused across tasks, preventing the overhead of creating and destroying threads for each task.

Creating Thread Pools

import java.util.concurrent.*;

// Fixed pool: exactly N threads
ExecutorService fixed = Executors.newFixedThreadPool(4);

// Single thread: tasks execute sequentially
ExecutorService single = Executors.newSingleThreadExecutor();

// Cached pool: grows as needed, recycles idle threads after 60s
ExecutorService cached = Executors.newCachedThreadPool();

// Scheduled pool: run tasks with delay or periodically
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);

// Java 21+: virtual thread executor
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();

Submitting Tasks

ExecutorService pool = Executors.newFixedThreadPool(4);

// submit Runnable (no return value)
pool.execute(() -> System.out.println("fire and forget"));

// submit Callable (returns Future)
Future<Integer> future = pool.submit(() -> {
    Thread.sleep(100);
    return 42;
});

// get() blocks until result is ready
Integer result = future.get();           // blocks indefinitely
Integer result2 = future.get(5, TimeUnit.SECONDS); // timeout

// Cancel a task
future.cancel(true); // true = interrupt if running

Proper Shutdown

// Always shut down pools — otherwise the JVM won't exit!
pool.shutdown(); // stop accepting new tasks; wait for running tasks

try {
    if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
        pool.shutdownNow(); // force stop remaining tasks
    }
} catch (InterruptedException e) {
    pool.shutdownNow();
    Thread.currentThread().interrupt();
}

Submitting Multiple Tasks

List<Callable<String>> tasks = List.of(
    () -> fetchUser("u1"),
    () -> fetchUser("u2"),
    () -> fetchUser("u3")
);

// Execute all and wait
List<Future<String>> futures = pool.invokeAll(tasks);
for (Future<String> f : futures) {
    System.out.println(f.get()); // get each result
}

// Get the first successful result
String first = pool.invokeAny(tasks); // returns first completed

ScheduledExecutorService

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

// Run once after delay
scheduler.schedule(() -> System.out.println("delayed"), 5, TimeUnit.SECONDS);

// Run repeatedly at fixed rate
scheduler.scheduleAtFixedRate(
    () -> sendHeartbeat(),
    0,     // initial delay
    30,    // period
    TimeUnit.SECONDS
);

// Run repeatedly with fixed delay BETWEEN executions
scheduler.scheduleWithFixedDelay(
    () -> pollQueue(),
    0, 10, TimeUnit.SECONDS
);

Custom Thread Pool with ThreadPoolExecutor

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    4,                           // corePoolSize
    16,                          // maximumPoolSize
    60L, TimeUnit.SECONDS,       // keepAliveTime
    new LinkedBlockingQueue<>(1000), // task queue
    new ThreadFactory() {           // custom thread names
        int i = 0;
        public Thread newThread(Runnable r) {
            return new Thread(r, "worker-" + i++);
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);

Rejection policies when queue is full:

  • AbortPolicy (default) — throws RejectedExecutionException
  • CallerRunsPolicy — caller thread runs the task (natural backpressure)
  • DiscardPolicy — silently discards
  • DiscardOldestPolicy — discards oldest waiting task

Right-sizing thread pools for the workload type

There's no universal "correct" pool size — it depends entirely on whether tasks are CPU-bound or I/O-bound:

  • CPU-bound work (tight computation, no blocking): the optimal pool size is close to Runtime.getRuntime().availableProcessors(). More threads than cores just adds context-switching overhead without adding throughput — every core is already saturated with useful work.
  • I/O-bound work (waiting on network calls, DB queries, disk): threads spend most of their time blocked, not computing, so a much larger pool than the core count is correct — a common starting formula is threads = cores * (1 + waitTime/computeTime), since more threads can be usefully in-flight while others wait.

Avoiding false sharing and excessive context switching from over-threading: creating far more threads than the workload needs doesn't just waste memory (each thread reserves stack space) — it also increases OS-level context-switch overhead (the CPU spends cycles swapping thread state instead of doing work) and can cause false sharing, where unrelated variables used by different threads happen to sit on the same CPU cache line, so writes by one thread invalidate the other thread's cached copy even though they're not logically sharing data. Right-sizing the pool to the actual workload (not "as many as possible") is what avoids both costs.

Interview Tips

  1. Never use Executors.newCachedThreadPool() for long-running tasks — it can spawn thousands of threads under load.
  2. newFixedThreadPool with unbounded queue can cause OOM if tasks are submitted faster than they complete — monitor queue depth.
  3. The recommended approach in Spring Boot: inject @Bean TaskExecutor and let Spring manage lifecycle.

Previous

Introduction to Threads

Next

Fork/Join Framework

AI Tutor

Lesson: ExecutorService & Thread Pools

Quick actions

AI responses can be inaccurate. Verify critical information.