Contents
- Creating Thread Pools
- Callable and Future
- invokeAll and invokeAny
- ScheduledExecutorService
- Shutdown and Lifecycle
The Executors class provides factory methods for the most common thread pool configurations, all returning an ExecutorService. newFixedThreadPool(n) creates a pool with exactly n threads — tasks beyond that queue up, giving you bounded concurrency that scales predictably with CPU cores. newCachedThreadPool() creates threads on demand and reuses idle ones, making it efficient for short-lived bursty tasks but risky if tasks are long-running because thread count is unbounded. newSingleThreadExecutor() uses one thread and executes tasks sequentially in submission order, effectively serializing access to a shared resource without explicit locking. For Java 21 and later, newVirtualThreadPerTaskExecutor() assigns a lightweight virtual thread to every submitted task, removing the need to size thread pools for I/O-bound workloads.
import java.util.concurrent.*;
// Executors factory methods — the right pool type for the job
// Fixed thread pool — capped at N threads; excess tasks queue up
ExecutorService fixed = Executors.newFixedThreadPool(4);
// Use: CPU-bound tasks where you want to limit parallelism to core count
// Cached thread pool — creates threads on demand; idle threads die after 60s
ExecutorService cached = Executors.newCachedThreadPool();
// Use: short-lived tasks with bursty load; risky for long tasks (unbounded threads)
// Single-thread executor — one thread, tasks run sequentially
ExecutorService single = Executors.newSingleThreadExecutor();
// Use: serialized access to a shared resource (e.g., log writer)
// Work-stealing pool — ForkJoinPool; parallelism = CPU cores
ExecutorService workStealing = Executors.newWorkStealingPool();
// Use: recursive decomposable tasks (same as ForkJoinPool.commonPool())
// Virtual thread executor — Java 21 (Project Loom)
ExecutorService vthreads = Executors.newVirtualThreadPerTaskExecutor();
// Use: high-concurrency I/O-bound tasks; each task gets its own virtual thread
// Submit a simple Runnable (no return value)
fixed.submit(() -> System.out.println("Task on thread: " + Thread.currentThread().getName()));
fixed.execute(() -> System.out.println("Another task")); // execute() vs submit() — no Future
// Always shut down the executor when done
fixed.shutdown();
Callable<V> is the result-bearing counterpart to Runnable: its call() method returns a value of type V and is permitted to throw checked exceptions, unlike Runnable.run(). Submitting a Callable to an ExecutorService returns a Future<V> immediately — the task runs asynchronously while the caller continues. Calling future.get() blocks until the result is available; if the task threw an exception, get() wraps it in an ExecutionException, so always inspect getCause() to find the actual error. Always use the timeout overload get(long, TimeUnit) in production code to avoid blocking forever if a task hangs; cancel the future if the timeout expires.
ExecutorService pool = Executors.newFixedThreadPool(4);
// Callable — like Runnable but returns a result and can throw checked exceptions
Callable<Integer> task = () -> {
Thread.sleep(1000); // simulate work
return 42;
};
// submit(Callable) returns a Future
Future<Integer> future = pool.submit(task);
System.out.println("Task submitted, doing other work...");
// future.get() blocks until the result is available
try {
Integer result = future.get(); // blocks here
System.out.println("Result: " + result); // 42
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
System.err.println("Task threw: " + e.getCause()); // actual exception
}
// get with timeout — don't wait forever
try {
Integer result = future.get(2, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true); // interrupt the task
System.err.println("Task timed out");
}
// isDone, isCancelled
System.out.println(future.isDone()); // true once result available
System.out.println(future.isCancelled()); // true if cancelled
// Cancel a task (may interrupt if running)
future.cancel(true); // true = interrupt if running
// Submit multiple tasks
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 5; i++) {
final int id = i;
futures.add(pool.submit(() -> "Result-" + id));
}
for (Future<String> f : futures) {
System.out.println(f.get()); // collect results in submission order
}
pool.shutdown();
invokeAll() is a convenience method that submits a collection of Callable tasks at once and blocks until every task has completed (or the optional timeout elapses). It returns a List<Future> in the same order as the input collection; each future is already done when the list is returned, so get() returns immediately. Tasks that exceeded the timeout are cancelled and their futures will report isCancelled() == true. invokeAny() takes the same collection but races them: it blocks only until the first task completes successfully, returns that result directly, and cancels all remaining tasks. Use invokeAny for hedged requests — submitting the same query to multiple replicas or endpoints and taking whichever answers first.
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Callable<String>> tasks = List.of(
() -> { Thread.sleep(300); return "task-1"; },
() -> { Thread.sleep(100); return "task-2"; },
() -> { Thread.sleep(200); return "task-3"; }
);
// invokeAll — submit all tasks; blocks until ALL complete (or timeout)
// Returns a List<Future> in the same order as input tasks
List<Future<String>> results = pool.invokeAll(tasks);
for (Future<String> f : results) {
System.out.println(f.get()); // task-1, task-2, task-3 in order
}
// invokeAll with timeout — tasks not done by deadline are cancelled
List<Future<String>> timedResults = pool.invokeAll(tasks, 500, TimeUnit.MILLISECONDS);
for (Future<String> f : timedResults) {
if (f.isDone() && !f.isCancelled()) {
System.out.println(f.get());
} else {
System.out.println("cancelled/timed out");
}
}
// invokeAny — submit all tasks; returns result of the FIRST successful one
// Other tasks are cancelled. Use when any result will do (e.g., racing servers)
String first = pool.invokeAny(tasks);
System.out.println("Fastest result: " + first); // "task-2" (100ms wait)
// This is the "racing futures" pattern
pool.shutdown();
invokeAny is perfect for implementing fallback strategies — submit the same request to multiple service endpoints and use whichever responds first. The slower requests are automatically cancelled.
ScheduledExecutorService extends ExecutorService with three scheduling methods. schedule() runs a task once after a specified delay and returns a ScheduledFuture you can cancel or inspect. scheduleAtFixedRate() fires at a fixed calendar interval regardless of how long each execution takes — if a task runs longer than its period, the next execution starts immediately after it finishes rather than stacking up. scheduleWithFixedDelay() inserts the fixed delay between the end of one execution and the start of the next, so the inter-execution gap is always at least the delay duration; this is safer when task duration is variable and you want to avoid overlapping runs. Both repeating methods continue indefinitely until the returned ScheduledFuture is cancelled or the scheduler is shut down.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// schedule — run once after a delay
ScheduledFuture<?> delayed = scheduler.schedule(
() -> System.out.println("Delayed task"),
5, TimeUnit.SECONDS
);
// scheduleAtFixedRate — run at fixed intervals from initial delay
// Period = time between start of consecutive executions
// If task takes longer than period, next run starts immediately after
ScheduledFuture<?> heartbeat = scheduler.scheduleAtFixedRate(
() -> System.out.println("Heartbeat: " + System.currentTimeMillis()),
0, // initial delay
1, // period
TimeUnit.SECONDS
);
// scheduleWithFixedDelay — run with fixed delay BETWEEN executions
// Delay = time between end of one execution and start of the next
ScheduledFuture<?> poller = scheduler.scheduleWithFixedDelay(
() -> pollDatabase(), // if this takes 2s, next run starts 3s after it finishes
0, // initial delay
3, // delay between end of one run and start of next
TimeUnit.SECONDS
);
// Cancel individual tasks
heartbeat.cancel(false); // false = let current execution finish
// Cancel at specific time (compute delay from now)
LocalDateTime nextRun = LocalDateTime.now().plusDays(1).withHour(9).withMinute(0);
long secondsUntil = ChronoUnit.SECONDS.between(LocalDateTime.now(), nextRun);
scheduler.schedule(() -> generateReport(), secondsUntil, TimeUnit.SECONDS);
// Shut down cleanly
scheduler.shutdown();
An ExecutorService owns threads that keep the JVM alive until they exit. shutdown() initiates an orderly shutdown: no new tasks are accepted, but tasks already submitted continue to run. shutdownNow() goes further by sending an interrupt to every running thread and returning the list of queued tasks that were never started; tasks must cooperate with interruption by checking Thread.currentThread().isInterrupted() or blocking on interruptible operations for this to take effect promptly. awaitTermination(timeout, unit) blocks the calling thread until all tasks finish or the timeout expires — combine the two in a try/finally block to handle the common escalation pattern: ask nicely with shutdown(), wait a reasonable time, then force with shutdownNow(). Skipping shutdown is the most common cause of thread leaks in application servers and hanging test suites.
// Graceful shutdown pattern — the correct way to stop an executor
ExecutorService pool = Executors.newFixedThreadPool(4);
// ... submit tasks ...
// Step 1: shutdown() — no new tasks accepted; queued tasks still run
pool.shutdown();
try {
// Step 2: wait for in-progress tasks to complete (up to timeout)
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// Step 3: if still running after timeout, force stop
pool.shutdownNow(); // sends interrupt to running tasks
// Step 4: wait a bit more for interrupt-responsive tasks to stop
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
}
}
} catch (InterruptedException ex) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
// shutdownNow() — attempts to stop all running tasks by interrupting threads
// Returns a list of queued tasks that were never started
List<Runnable> notStarted = pool.shutdownNow();
// isShutdown() — true after shutdown() called (but tasks may still be running)
// isTerminated() — true after all tasks have completed post-shutdown
// Thread factory — customize thread properties (name, priority, daemon)
ThreadFactory namedFactory = r -> {
Thread t = new Thread(r, "worker-pool");
t.setDaemon(true); // daemon threads don't prevent JVM exit
t.setPriority(Thread.NORM_PRIORITY);
return t;
};
ExecutorService named = Executors.newFixedThreadPool(4, namedFactory);
// Java 19+: structured concurrency (preview) — better lifecycle management
// try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Future<String> f1 = scope.fork(() -> task1());
// Future<String> f2 = scope.fork(() -> task2());
// scope.join(); scope.throwIfFailed();
// }
Always call shutdown() or shutdownNow() when done with an ExecutorService. Non-daemon thread pool threads keep the JVM alive indefinitely if not shut down — a common source of hanging processes in application servers and test suites.