Stop creating raw threads — use ExecutorService and thread pools correctly.
Published September 21, 2026
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.
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();
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
// 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();
}
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 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
);
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 RejectedExecutionExceptionCallerRunsPolicy — caller thread runs the task (natural backpressure)DiscardPolicy — silently discardsDiscardOldestPolicy — discards oldest waiting taskThere's no universal "correct" pool size — it depends entirely on whether tasks are CPU-bound or I/O-bound:
Runtime.getRuntime().availableProcessors(). More threads than cores just adds context-switching overhead without adding throughput — every core is already saturated with useful work.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.
Executors.newCachedThreadPool() for long-running tasks — it can spawn thousands of threads under load.newFixedThreadPool with unbounded queue can cause OOM if tasks are submitted faster than they complete — monitor queue depth.@Bean TaskExecutor and let Spring manage lifecycle.