Use ExecutorService, thread pools, and Future to manage concurrent tasks without creating raw threads.
Published February 11, 2025
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 taskExecutors.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.