An Executor is a component that receives tasks and decides how to execute them. Instead of creating and controlling each thread manually, we hand the work over to a specialized manager.
This separation is important. The task defines what needs to be done, while the executor decides when and on which thread to do it.
So far we’ve created threads with new Thread(...). It works for learning, but managing dozens or thousands of tasks this way is expensive and quite easy to get wrong.
The problem of creating threads manually
Platform threads consume resources. Each one needs memory for its stack and the operating system has to schedule when it runs.
If an application creates a new thread for each job, it can end up with thousands of threads competing for the CPU. The cost of coordinating them can be greater than the work they actually perform.
A thread pool keeps a set of threads that are reused to execute many tasks. When a job arrives, it goes into a queue until one of the threads is available.
Imagine a workshop with three mechanics. We don’t hire and fire a person for every car that comes in: the cars wait and the same mechanics handle the jobs as they come.
Creating an ExecutorService
The Executors class provides several methods for creating pre-configured executors. The simplest one to get started is newFixedThreadPool().
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) {
try (ExecutorService executor = Executors.newFixedThreadPool(3)) {
for (int i = 1; i <= 6; i++) {
int taskId = i;
executor.submit(() -> {
String thread = Thread.currentThread().getName();
System.out.printf("Task %d executed by %s%n", taskId, thread);
});
}
}
}
}We’ve submitted six tasks to a pool of three threads. This means that there will never be more than three tasks running at the same time; the rest will wait their turn.
The order of the output may change on each execution. An executor coordinates tasks, but it does not guarantee order unless we use one specifically designed to run them serially.
In modern Java, ExecutorService implements AutoCloseable. That’s why we can use try-with-resources: when the block exits, it stops accepting new tasks and waits for the already submitted ones to finish.
execute() vs. submit()
An executor offers two common ways to submit a Runnable task:
execute(task)sends it and returns no object to track its status.submit(task)returns aFuture, which allows waiting, canceling, or checking if the task has finished.
executor.execute(() -> System.out.println("I don't need a result"));
Future<?> future = executor.submit(
() -> System.out.println("I can check when I finish")
);For simple jobs where we don’t need to observe the result, execute() is sufficient. As soon as we need to retrieve a value or handle an error, we’ll use submit().
Tasks that return values: Callable
Runnable returns nothing and its run() method cannot declare checked exceptions. When a task needs to return a result, we use Callable<T>.
When submitting a Callable, submit() returns a Future<T>. That object represents a result that may not exist yet.
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Main {
public static void main(String[] args) {
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Future<Integer> future = executor.submit(() -> {
Thread.sleep(500);
return 21 * 2;
});
System.out.println("The task is still running...");
try {
int result = future.get();
System.out.println("Result: " + result);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
System.err.println("The task failed: " + e.getCause());
}
}
}
}The call future.get() blocks the current thread until the result is available. If the task fails, get() throws an ExecutionException that wraps the original exception.
Don’t fill your program with immediate calls to get(). If you submit a task and wait for its result right after, you might turn a concurrent flow into a sequential one without realizing it.
Common executor types
The Executors class includes several ready-made configurations. Each one solves a different problem.
Fixed-size pool
Executors.newFixedThreadPool(n) keeps at most n active threads and queues pending tasks.
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(cores);This is a reasonable choice for CPU-intensive jobs, because it limits how many tasks compete for available cores.
Single thread
Executors.newSingleThreadExecutor() executes tasks one after another in queue order.
ExecutorService executor = Executors.newSingleThreadExecutor();It is useful when we want to offload an operation from the main thread, but need to maintain order and prevent two tasks from overlapping.
Elastic pool
Executors.newCachedThreadPool() reuses available threads and creates new ones when needed. It can grow large, so it should not be used without understanding the load limits.
Scheduled tasks
Executors.newScheduledThreadPool(n) returns a ScheduledExecutorService, capable of executing a task after a delay or periodically.
var scheduler = Executors.newScheduledThreadPool(1);
scheduler.schedule(
() -> System.out.println("Two seconds have passed"),
2,
TimeUnit.SECONDS
);Closing the executor correctly
A pool keeps threads alive. If we don’t close it, the application can keep running even after the main method has finished.
With modern Java, the cleanest option is to use try-with-resources. If we need to control shutdown manually, we have these methods:
shutdown()stops accepting new tasks but allows pending ones to finish.shutdownNow()attempts to interrupt active tasks and returns those that haven’t started.awaitTermination(...)waits for a specified time for the executor to terminate.
shutdownNow() cannot forcefully kill a thread. It only requests its interruption. The task must respond properly to that interruption in order to stop.
Virtual threads
Since Java 21, we can also create an executor that starts a virtual thread for each task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = executor.submit(() -> downloadData());
System.out.println(future.get());
}Virtual threads are very lightweight and are particularly well-suited for applications with many tasks that spend time waiting for I/O, such as HTTP requests, database queries, or file access.
They don’t form a pool, and they don’t make a mathematical operation faster. Their advantage is that they allow having a large number of concurrent blocking tasks without creating the same number of expensive operating system threads.
- For a few CPU-intensive tasks, use a fixed pool with a size close to the number of cores.
- To maintain strictly sequential order, use a single-thread executor.
- For many independent blocking tasks, consider virtual threads.