Executors and Thread Pools
The problem with new Thread()
Creating a thread is expensive — it allocates a stack and asks the operating system for a scheduling resource. Doing it per task wastes most of the time on setup:
for (int i = 0; i < 1000; i++) {
new Thread(() -> handle(i)).start(); // 1000 threads — bad
}
Beyond the cost, nothing limits how many exist at once. A thousand threads competing for a handful of processor cores mostly switch between each other rather than doing work, and memory use climbs sharply.
An executor separates what to run from how it is run. You submit tasks; a small pool of reusable threads works through them.
import java.util.concurrent.*;
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 1000; i++) {
final int id = i;
pool.submit(() -> handle(id));
}
pool.shutdown();
Four threads handle all thousand tasks, reusing themselves as each finishes.
Choosing a pool
| Factory method | Behaviour | Use when |
|---|---|---|
newFixedThreadPool(n) | Exactly n threads; extra tasks queue | The usual default |
newSingleThreadExecutor() | One thread, tasks run in order | Tasks must not overlap |
newCachedThreadPool() | Grows without bound, reuses idle threads | Many short-lived tasks; risky under load |
newScheduledThreadPool(n) | Runs tasks after a delay or repeatedly | Periodic work |
newVirtualThreadPerTaskExecutor() | A lightweight virtual thread per task | Many tasks that mostly wait (Java 21+) |
For work that keeps the processor busy, a fixed pool sized near the core count is a sensible starting point:
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(cores);
For work that mostly waits — network calls, file reads — more threads than cores is fine, since most are idle.
Be careful with cached pools
newCachedThreadPool() creates a new thread whenever no idle one is available, with no upper limit. Submit tasks faster than they complete and it will happily create thousands, which is the problem you were trying to avoid.
Prefer a fixed pool unless you know the task rate is bounded.
Getting results back
Runnable returns nothing. Callable<T> returns a value, and submit hands you a Future<T> to collect it:
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<Integer> future = pool.submit(() -> {
Thread.sleep(500);
return 42;
});
doSomethingElse(); // runs while the task computes
int result = future.get(); // blocks until the value is ready
get() waits for completion. Two exceptions to handle:
try {
int result = future.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // the exception the task actually threw
}
ExecutionException wraps whatever the task threw. Calling getCause() is how you see the real problem — a detail worth knowing, because the wrapper alone tells you almost nothing.
Use the timeout form to avoid waiting forever:
int result = future.get(2, TimeUnit.SECONDS); // throws TimeoutException
Exceptions in submitted tasks are swallowed
A task submitted with submit() that throws does not print a stack trace. The exception is stored in the Future, and if you never call get(), you never learn about it.
This makes silently-failing background work a common and confusing bug. Either call get(), or wrap the task body in a try/catch that logs.
execute() behaves differently — an exception there does surface on the default handler.
Running many tasks
invokeAll submits a batch and waits for all of them:
List<Callable<Integer>> tasks = List.of(
() -> compute(1),
() -> compute(2),
() -> compute(3)
);
List<Future<Integer>> results = pool.invokeAll(tasks); // blocks until all done
for (Future<Integer> f : results) {
System.out.println(f.get());
}
invokeAny returns as soon as one succeeds and cancels the rest — useful when several approaches could answer the same question.
Scheduled work
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// once, after a delay
scheduler.schedule(() -> System.out.println("later"), 5, TimeUnit.SECONDS);
// repeatedly, every 100 ms
scheduler.scheduleAtFixedRate(this::pollSensor, 0, 100, TimeUnit.MILLISECONDS);
scheduleAtFixedRate targets a fixed start-to-start interval; scheduleWithFixedDelay waits a fixed gap after each run finishes. If a task can overrun its period, the second is usually what you want.
Note that if a repeating task throws, the repetition stops silently. Wrap the body in a try/catch if it must keep running.
Shutting down
An executor’s threads are non-daemon by default, so the program will not exit while a pool is alive.
pool.shutdown(); // no new tasks; finish what is queued
try {
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt what is still running
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
| Method | Effect |
|---|---|
shutdown() | Rejects new tasks, completes queued ones |
shutdownNow() | Also interrupts running tasks, returns those never started |
awaitTermination(t, unit) | Waits up to a timeout for completion |
isShutdown() / isTerminated() | Whether shutdown began / finished |
shutdownNow only interrupts — a task ignoring interruption keeps going. Long-running loops should check Thread.currentThread().isInterrupted() and exit when set.
CompletableFuture
For chaining work without blocking on get():
CompletableFuture
.supplyAsync(() -> fetchReading())
.thenApply(reading -> reading * 2)
.thenAccept(result -> System.out.println(result))
.exceptionally(error -> { error.printStackTrace(); return null; });
Each stage runs after the previous one completes, without a thread sitting idle waiting. Worth knowing exists; a fixed pool with Future covers most needs.
Common mistakes
- Never calling
shutdown(), so the program never exits. - Ignoring the
Future, hiding exceptions entirely. get()immediately aftersubmit(), which is just a slower sequential call.- Not unwrapping
ExecutionException, losing the real cause. - An unbounded cached pool under sustained load.
- A repeating scheduled task that throws, stopping silently.
- Assuming
shutdownNow()stops everything. It interrupts; tasks must cooperate.
Practice
- Submit 20 tasks that each sleep briefly to a fixed pool of 4, printing which thread ran each. Note that only four names appear.
- Submit a
Callablereturning a number and collect it withget(). - Submit a task that throws, then confirm nothing is printed until you call
get(). - Use
invokeAllto run three computations and sum the results. - Schedule a task to run every 200 ms, let it run five times, then shut down cleanly.
Hints
Thread.currentThread().getName().Future<Integer>and handle both exceptions.- This is the swallowed-exception trap — worth seeing once.
invokeAllreturns futures in the same order as the input.scheduleAtFixedRate, thenshutdown()andawaitTermination.