Introduction
Creating a Thread by hand works for a quick example, but it does not scale. Every new Thread(...).start() call pays the cost of allocating a new OS thread, and nothing stops you from creating thousands of them if the workload grows — exhausting memory and starving the CPU with context switches. Production code needs a way to reuse a bounded set of threads across many tasks, and to manage the lifecycle of that thread pool safely.
ExecutorService is the answer Java provides. It decouples what runs (a task) from how it runs (thread creation, scheduling, reuse), and gives you a clean way to submit work, collect results, and shut everything down.
This article assumes you are comfortable with how to create a thread in Java, the thread lifecycle, and thread synchronization — ExecutorService builds directly on those concepts.
1. Why ExecutorService?
1.1 The Executor Interface
At the root of the framework is Executor, a functional interface with a single abstract method, so you can implement it with a lambda:
void execute(Runnable command);
Its Javadoc defines it as “an object that executes submitted Runnable tasks”, providing “a way of decoupling task submission from the mechanics of how each task will be run”. The caller hands over a Runnable and no longer decides which thread runs it or when. Behind this interface, implementations such as thread pools keep a set of worker threads alive and reuse them across many tasks. That avoids the cost of creating and destroying a thread for every unit of work.
Executor alone is limited, though. execute() returns nothing, so you cannot get a result or an exception back. It also provides no way to stop the threads it manages.
1.2 What ExecutorService Adds
ExecutorService extends Executor to fill those two gaps. Its Javadoc defines it as:
“An
Executorthat provides methods to manage termination and methods that can produce aFuturefor tracking progress of one or more asynchronous tasks.”
In other words, it is an Executor with two added capabilities:
- Result tracking:
submit()returns aFuture. You can use it to retrieve a task’s return value or exception, check whether the task is done, or cancel it. - Lifecycle management:
shutdown(),shutdownNow()andawaitTermination()let you stop the executor in a controlled way, so you do not need to track everyThreadyourself to know when work is done.
In practice you almost always program against ExecutorService rather than Executor, and that is the type the Executors factory methods return.
2. Creating an ExecutorService
You rarely construct an ExecutorService implementation directly. The Executors factory class provides ready-made configurations for the most common cases:
| Factory Method | Thread Behavior | Best For |
|---|---|---|
newFixedThreadPool(int n) | Fixed number of reusable threads; excess tasks wait in an unbounded queue | Known, bounded workloads |
newCachedThreadPool() | Creates threads as needed, reuses idle ones, terminates threads idle for 60 seconds | Many short-lived tasks |
newSingleThreadExecutor() | A single worker thread; tasks run sequentially in submission order | Work that must not run concurrently |
newVirtualThreadPerTaskExecutor() (Java 21+) | Starts a new virtual thread per task, with no upper bound on thread count | High-concurrency, I/O-bound workloads |
The following snippet creates a fixed pool of three threads and submits five tasks. Only three run at a time; the rest wait until a thread frees up:
// A pool of 3 reusable threads; excess tasks wait in an unbounded queue
ExecutorService executor = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 5; i++) {
int taskId = i;
executor.execute(() ->
System.out.println("Task " + taskId + " running on " + Thread.currentThread().getName()));
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
Task 1 running on pool-1-thread-1
Task 2 running on pool-1-thread-2
Task 3 running on pool-1-thread-3
Task 4 running on pool-1-thread-1
Task 5 running on pool-1-thread-2
The exact interleaving varies between runs, since the JVM scheduler decides which of the three threads picks up each queued task next.
3. Submitting Tasks
ExecutorService gives you two ways to hand work to the pool, depending on whether you need a result back.
3.1 execute(Runnable)
execute(), inherited from Executor, is fire-and-forget: it takes a Runnable and returns nothing. There is no way to observe the outcome or catch an exception thrown by the task — an uncaught exception simply terminates that worker thread silently.
ExecutorService executor = Executors.newSingleThreadExecutor();
// execute() returns nothing: no way to observe the result or a thrown exception
executor.execute(() -> System.out.println("Fire-and-forget task completed"));
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
Fire-and-forget task completed
3.2 submit(Callable) and Future
submit() accepts a Callable<T> (or a Runnable) and immediately returns a Future<T> — a handle to a result that is not ready yet. Calling future.get() blocks until the task completes and returns its value, or rethrows the task’s exception wrapped in an ExecutionException.
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<Integer> task = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
Future<Integer> future = executor.submit(task);
Integer result = future.get(); // blocks until the task completes
System.out.println("Sum of 1..100 = " + result);
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
Sum of 1..100 = 5050
4. Waiting for Multiple Tasks
Submitting tasks one by one and calling get() on each Future in turn works, but ExecutorService also provides two bulk operations that express common patterns directly.
4.1 invokeAll
invokeAll() submits a collection of Callable tasks and blocks until all of them complete, returning a List<Future<T>> in the same order as the input tasks. Every returned Future is done — either with a result or an exception — so it is safe to call get() on each one without blocking further.
ExecutorService executor = Executors.newFixedThreadPool(3);
// square(n) = n * n
List<Callable<Integer>> tasks = List.of(
() -> square(2),
() -> square(3),
() -> square(4)
);
List<Future<Integer>> futures = executor.invokeAll(tasks);
for (Future<Integer> future : futures) {
try {
System.out.println("Result: " + future.get());
} catch (ExecutionException e) {
System.out.println("Task failed: " + e.getCause());
}
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
Result: 4
Result: 9
Result: 16
4.2 invokeAny
invokeAny() takes the opposite approach: it submits a collection of tasks and returns the result of the first one to complete successfully, cancelling the rest. This fits scenarios where several tasks compute the same thing through different paths — such as querying redundant mirrors — and only the fastest answer matters.
ExecutorService executor = Executors.newFixedThreadPool(3);
// Each task asks a different mirror server for the same resource.
// fetchFromMirror(name, delayMillis) is a mock: instead of making a real network
// call, it sleeps for delayMillis to simulate that mirror's response time, then
// returns the mirror's name so we can see which one answered first.
List<Callable<String>> mirrors = List.of(
() -> fetchFromMirror("mirror-1", 300),
() -> fetchFromMirror("mirror-2", 50),
() -> fetchFromMirror("mirror-3", 200)
);
// Returns as soon as one task succeeds; the other two are cancelled
String fastest = executor.invokeAny(mirrors);
System.out.println("Fastest response: " + fastest);
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
Fastest response: mirror-2
5. Shutting Down an ExecutorService
An ExecutorService keeps its threads alive until you explicitly shut it down — the JVM will not exit while non-daemon pool threads are still running. There are two shutdown methods, and they behave very differently.
shutdown()initiates an orderly shutdown: no new tasks are accepted, but tasks already submitted — including queued ones — run to completion. It does not block.shutdownNow()attempts to stop all actively executing tasks (typically by interrupting them), halts processing of queued tasks, and returns theList<Runnable>of tasks that never started.
Neither method blocks until termination; for that, pair either one with awaitTermination(timeout, unit), which blocks until all tasks finish, the timeout elapses, or the calling thread is interrupted.
// A single worker thread guarantees the queued tasks below never get to run
ExecutorService executor = Executors.newSingleThreadExecutor();
// Task 1 occupies the single worker thread for 200 ms
executor.execute(() -> {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// Tasks 2–4 wait in the queue because the only thread is busy
for (int i = 1; i <= 3; i++) {
executor.execute(() -> System.out.println("Queued task running"));
}
// Interrupts task 1 and returns tasks 2–4, which never started
List<Runnable> neverStarted = executor.shutdownNow();
System.out.println("Tasks that never started: " + neverStarted.size());
executor.awaitTermination(5, TimeUnit.SECONDS);
Tasks that never started: 3
6. Best Practices
6.1 Always Shut Down Executors
An ExecutorService that is never shut down leaks threads and can keep the JVM alive indefinitely. Since Java 19, ExecutorService extends AutoCloseable: its close() method calls shutdown(), waits for termination, and falls back to shutdownNow() if interrupted while waiting. This makes try-with-resources the safest way to scope an executor’s lifetime to a block of code.
// close() shuts down the executor and blocks until termination, or calls
// shutdownNow() if the waiting thread is interrupted
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
executor.execute(() -> System.out.println("Task running inside try-with-resources"));
}
System.out.println("Executor closed automatically");
Task running inside try-with-resources
Executor closed automatically
6.2 Prefer submit() Over execute() for Anything That Can Fail
Because execute() swallows uncaught exceptions, use submit() whenever a task can fail and you need to know about it. Calling future.get() surfaces the failure as an ExecutionException, which you can catch and handle instead of losing it silently in a worker thread.
6.3 Size the Pool for the Workload
newFixedThreadPool and newCachedThreadPool are general-purpose defaults, not the right choice for every workload. The right number of threads depends on what your tasks spend their time doing.
6.3.1 CPU-Bound Tasks
CPU-bound tasks — hashing, compression, sorting, number crunching — keep a core busy the whole time they run. A core executes only one thread at a time, so on an 8-core machine at most 8 threads make progress at once. Adding more threads does not speed anything up; the OS just spends extra time pausing and resuming them (context switching). Size the pool to the number of cores:
// One thread per core: every core stays busy, no time wasted on context switching
int cores = Runtime.getRuntime().availableProcessors();
System.out.println("Available cores: " + cores);
try (ExecutorService executor = Executors.newFixedThreadPool(cores)) {
List<Future<Long>> results = new ArrayList<>();
long start = System.currentTimeMillis();
// One CPU-heavy task per core
for (int i = 0; i < cores; i++) {
results.add(executor.submit(() -> {
long sum = 0;
for (long n = 0; n < 500_000_000L; n++) {
sum += n % 7;
}
return sum;
}));
}
for (Future<Long> result : results) {
result.get();
}
System.out.println(cores + " CPU-bound tasks finished in "
+ (System.currentTimeMillis() - start) + " ms");
}
Available cores: 8
8 CPU-bound tasks finished in 412 ms
All tasks run in parallel, one per core, so the batch takes roughly as long as a single task.
6.3.2 I/O-Bound Tasks
I/O-bound tasks — HTTP calls, database queries, file reads — spend most of their time waiting for a response. A waiting thread uses no CPU but still occupies a slot in the pool. With only one thread per core, all threads can end up waiting at once while the CPU sits idle and new tasks pile up in the queue. These workloads need far more threads than there are cores, so that some threads use the CPU while the others wait.
Platform threads are expensive to create in large numbers, which is exactly the problem newVirtualThreadPerTaskExecutor() solves on Java 21+: virtual threads are cheap enough to create one per task, and a virtual thread blocked on I/O releases its underlying OS thread for other work.
long start = System.currentTimeMillis();
// One virtual thread per task: 10,000 concurrent "requests" without sizing a pool
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(1_000); // simulates waiting for a network response
return "done";
});
}
} // close() waits for all 10,000 tasks to finish
System.out.println("10,000 I/O-bound tasks finished in "
+ (System.currentTimeMillis() - start) + " ms");
10,000 I/O-bound tasks finished in 1043 ms
Because every task sleeps concurrently on its own virtual thread, the whole batch completes in about one second. A fixed pool of 8 platform threads would need around 1,250 seconds for the same work, since only 8 tasks could wait at a time.
Conclusion
ExecutorService replaces manual Thread creation with a managed pool: you submit tasks, the pool decides how to run them, and you get Future handles back for results and errors. execute() fits fire-and-forget work, submit() fits anything where you need the outcome, and invokeAll()/invokeAny() express the common “wait for all” and “wait for the first” patterns without hand-rolled Future bookkeeping. Whatever factory method you choose, always pair it with a shutdown — ideally through try-with-resources — so the pool’s threads do not outlive the work they were created for.
You can find the complete code of this article on GitHub
