ExecutorService
Submit tasks to thread pools, collect results with Future, bound queues and shut pools down properly.
try (ExecutorService pool = Executors.newFixedThreadPool(n)) {
Future<T> f = pool.submit(callable);
T result = f.get(timeout, unit);
}An ExecutorService separates what to run from how it runs. You submit tasks; the executor decides which thread runs them, reuses threads between tasks and queues work when all threads are busy. Almost every server, scheduler and HTTP client in the Java ecosystem is built on one.
Submitting work
execute(Runnable) fires and forgets. submit(Callable) returns a Future, a handle to a result that may not exist yet. future.get() blocks until the task is done and rethrows a failure wrapped in ExecutionException. invokeAll runs a batch and returns the futures in the same order as the tasks, which keeps output stable.
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
Future<Quote> quote = pool.submit(() -> fetchQuote("acme"));
Quote q = quote.get(2, TimeUnit.SECONDS);
} // close() shuts the pool down, then waits for submitted tasks to finishSince Java 19 ExecutorService is AutoCloseable, so try-with-resources shuts it down and waits. Java 19 also added Future.state(), resultNow() and exceptionNow() for inspecting finished futures without checked exceptions.
Choosing a pool
newFixedThreadPool(n): n threads and an unbounded queue. Good for CPU-bound work sized to the core count.newCachedThreadPool(): creates threads without limit. A traffic spike can create thousands of platform threads and exhaust memory.newVirtualThreadPerTaskExecutor(): a new virtual thread per task, ideal for blocking I/O.new ThreadPoolExecutor(...): full control over core size, maximum size, queue capacity and what happens when everything is full.
Mistakes that hurt in production
- Never shutting the pool down. Its non-daemon threads keep the JVM alive and the process hangs on exit.
- Calling
get()without a timeout on a remote call. One stuck dependency then blocks a request thread forever. - Unbounded queues in front of slow work. The queue grows until memory runs out, and requests wait minutes before failing. A bounded queue with a rejection policy lets you answer 503 quickly instead.
- Ignoring the
Futureof a task that throws. The exception sits inside the future and nobody sees it.
A timed-out get() does not stop the task. Call cancel(true) to interrupt it, otherwise it keeps using a thread and the downstream connection.
Example
When to use it
- Call three supplier pricing APIs at the same time and return the cheapest quote within one request.
- Run a batch of health checks with invokeAll and report each dependency's status on a /health endpoint.
- Protect a slow PDF export with a bounded ThreadPoolExecutor that answers 503 when the queue is full.
More examples
invokeAll for a batch of health checks
invokeAll waits for every task and keeps the input order. state(), resultNow() and exceptionNow() read finished futures without the checked ExecutionException.
void main() throws InterruptedException {
List<Callable<String>> checks = List.of(
() -> "database: up",
() -> "cache: up",
() -> { throw new IllegalStateException("search: timed out"); });
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
for (Future<String> f : pool.invokeAll(checks)) {
IO.println(switch (f.state()) {
case SUCCESS -> f.resultNow();
case FAILED -> "failed, " + f.exceptionNow().getMessage();
default -> f.state().toString();
});
}
}
}
// Output:
// database: up
// cache: up
// failed, search: timed outTiming out and cancelling a slow task
Without cancel(true) the task would hold the pool's only thread for a full minute. Interrupting it frees the thread almost immediately.
void main() throws Exception {
try (ExecutorService pool = Executors.newSingleThreadExecutor()) {
Future<String> report = pool.submit(() -> {
Thread.sleep(Duration.ofMinutes(1)); // a downstream call that hangs
return "report ready";
});
try {
IO.println(report.get(200, TimeUnit.MILLISECONDS));
} catch (TimeoutException e) {
IO.println("gave up after 200 ms");
IO.println("cancelled: " + report.cancel(true)); // interrupts the sleeping task
}
IO.println("state: " + report.state());
}
}
// Output:
// gave up after 200 ms
// cancelled: true
// state: CANCELLEDA bounded pool that rejects overload
The first job goes straight to the new worker thread, the second waits in the queue and the third is rejected. Failing fast like this is better than letting an unbounded queue grow during an outage.
void main() throws InterruptedException {
CountDownLatch release = new CountDownLatch(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1), // at most one job waiting
new ThreadPoolExecutor.AbortPolicy()); // reject anything beyond that
for (int job = 1; job <= 3; job++) {
try {
pool.execute(() -> {
try {
release.await(); // hold the worker busy
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
IO.println("job " + job + " accepted, queued: " + pool.getQueue().size());
} catch (RejectedExecutionException e) {
IO.println("job " + job + " rejected, answer 503");
}
}
release.countDown();
pool.shutdown();
IO.println("finished: " + pool.awaitTermination(5, TimeUnit.SECONDS));
}
// Output:
// job 1 accepted, queued: 0
// job 2 accepted, queued: 1
// job 3 rejected, answer 503
// finished: true
Discussion