ExecutorService in Java: Guide With Java 25 Examples

Learn ExecutorService in Java with Java 25 examples for submit(), invokeAll(), Future results, close() with try-with-resources and virtual threads.

Tasks submitted to an ExecutorService wait in a queue, run on worker threads and complete a Future

An ExecutorService is a Java interface that runs our tasks on a managed group of threads and gives back a Future for each task, so we never create or join threads by hand. It is part of the java.util.concurrent package since Java 5, and in Java 25 it is also AutoCloseable, so a try-with-resources block shuts it down for us.

We use an ExecutorService whenever an app has independent work that can run in parallel, such as calling three remote services at once, resizing uploaded images or sending emails without making the user wait.

The following example submits one task to a pool of two threads and reads its result after the pool is closed.

Future<Integer> price;
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
    price = pool.submit(() -> 40 + 2);          // runs on a pool thread
}                                               // close() waits for the task
int result = price.resultNow();                 // 42

Notice that we did not call shutdown() anywhere. The close() method at the end of the try block did it and waited for the task, so resultNow() is safe on the next line. We look at how to create an executor, the ways to submit tasks and read results, shutting down, virtual thread executors and the mistakes that cause leaks and hangs.

1. What Is the Executor Framework?

Before Java 5, every background job meant a new Thread, a start() call and some shared variable to get the result back. The Executor framework splits that work in two. Our code describes the task as a Runnable or a Callable, and an executor decides which thread runs it and when.

Tasks submitted to an ExecutorService wait in a queue, run on worker threads and complete a Future
The caller gets a Future right away, while the executor runs the task on one of its threads

The framework is a small hierarchy of interfaces with a few JDK implementations behind them. Most code only declares the ExecutorService type and gets an instance from the Executors factory class.

TypeKindWhat it adds
ExecutorInterfaceOne method, execute(Runnable), with no result and no lifecycle
ExecutorServiceInterfacesubmit(), invokeAll(), invokeAny(), shutdown methods and close()
ScheduledExecutorServiceInterfaceDelayed and periodic tasks, see ScheduledExecutorService
ThreadPoolExecutorClassA configurable pool of platform threads with a task queue
ForkJoinPoolClassWork-stealing pool for many small, recursive tasks
ExecutorsUtility classFactory methods that return ready-made executors

A food delivery app shows a restaurant page that needs the menu, the reviews and the delivery estimate from three different services. Called one after another, the page waits for the sum of the three calls. Submitted to an ExecutorService, the three calls run at the same time, and the page waits only for the slowest one.

2. Creating an ExecutorService

The Executors class has a factory method for each common setup. Each one returns an ExecutorService that is already configured, so we pick the method by the kind of work our tasks do.

ExecutorService fixed = Executors.newFixedThreadPool(4);
ExecutorService single = Executors.newSingleThreadExecutor();
ExecutorService cached = Executors.newCachedThreadPool();
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();
ScheduledExecutorService timer = Executors.newScheduledThreadPool(1);
fixed.close();
single.close();
cached.close();
virtual.close();
timer.close();

Each factory method gives a different trade-off between the number of threads and how long tasks can wait.

Factory methodThreadsGood for
newFixedThreadPool(n)A constant n platform threads; extra tasks wait in an unbounded queueCPU-bound work with a known limit
newSingleThreadExecutor()One thread; tasks run one at a time in submission orderWork that must not run in parallel, such as writes to one file
newCachedThreadPool()Grows on demand, idle threads end after 60 secondsMany short tasks with bursts
newVirtualThreadPerTaskExecutor()One new virtual thread per task, no poolBlocking I/O such as HTTP and JDBC calls
newScheduledThreadPool(n)n threads that run tasks after a delay or periodicallyTimers, polling and cache refreshes

When none of these fits, we create a ThreadPoolExecutor with its constructor and choose the core size, the maximum size and the queue ourselves. The thread pool guide compares the factory pools in depth, and the ThreadPoolExecutor with BlockingQueue article explains how the queue choice changes the way the pool grows.

3. Submitting Tasks With execute(), submit() and invokeAll()

An ExecutorService accepts two kinds of tasks. A Runnable returns nothing and cannot throw checked exceptions, whereas a Callable returns a value and may throw any exception. The Runnable vs Callable article compares them in detail.

The submit methods differ in what they give back to the caller.

MethodAcceptsReturns
execute(Runnable)RunnableNothing
submit(Runnable)RunnableFuture<?> whose get() returns null
submit(Runnable, T result)Runnable plus a valueFuture<T> whose get() returns the given value
submit(Callable<T>)CallableFuture<T> with the task result
invokeAll(tasks)Collection of CallableList<Future<T>> after all tasks finish
invokeAny(tasks)Collection of CallableThe result of one task that finished without an exception

The following example sends one task of each kind to a pool of two threads. The counter is an AtomicInteger because two threads update it, and the Runnable tasks passed to submit() use a block lambda with braces. An expression lambda such as () -> visits.incrementAndGet() returns a value, so the compiler picks the Callable overload of submit().

AtomicInteger visits = new AtomicInteger();
Future<?> logged;
Future<String> tagged;
Future<Integer> sum;
List<Future<Integer>> all;
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
    pool.execute(() -> visits.incrementAndGet());
    logged = pool.submit(() -> { visits.incrementAndGet(); });
    tagged = pool.submit(() -> { visits.incrementAndGet(); }, "done");
    sum = pool.submit(() -> 2 + 3);
    all = pool.invokeAll(List.of(() -> 1, () -> 2, () -> 3));
}
int count = visits.get();                        // 3
Object nothing = logged.resultNow();             // null
String tag = tagged.resultNow();                 // "done"
int five = sum.resultNow();                      // 5
int size = all.size();                           // 3

The invokeAll() call blocks until every task in the list has finished, and the futures come back in task order. The invokeAll() and invokeAny() guide covers both methods with timeouts.

The execute() and submit() methods handle task exceptions differently. An exception thrown in an execute() task reaches the uncaught exception handler of the worker thread, which by default prints the stack trace, and the pool replaces that worker with a new thread. An exception thrown in a submit() task is stored in the Future, and nobody sees it until some code reads that Future.

Future<Integer> failed;
try (ExecutorService pool = Executors.newSingleThreadExecutor()) {
    failed = pool.submit(() -> 10 / 0);
}
Future.State state = failed.state();             // FAILED
String message = failed.exceptionNow().getMessage();   // "/ by zero"
int value = failed.get();                        // ExecutionException: ArithmeticException

So whenever we use submit() and ignore the returned Future, a failing task disappears without a trace. Either keep the future and check it, or catch and log exceptions inside the task.

4. Reading Results From a Future

A Future represents a result that may not exist yet. The get() method blocks the calling thread until the task completes, and it either returns the value or throws an ExecutionException that wraps the task’s exception.

Java 19 added three methods to Future that make the code after invokeAll() or close() shorter, because they never block.

  • The state() method returns RUNNING, SUCCESS, FAILED or CANCELLED.
  • The resultNow() method returns the value of a successful task and throws IllegalStateException for any other state.
  • The exceptionNow() method returns the exception of a failed task and throws IllegalStateException otherwise.

While a task may still be running, we call get() with a timeout. A plain get() waits forever when a remote service hangs, and the thread that called it hangs too.

Future<String> report;
String text;
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
    report = pool.submit(() -> "ready");
    text = report.get(2, TimeUnit.SECONDS);
}
String received = text;                          // "ready"
boolean done = report.isDone();                  // true

When the timeout passes, get() throws TimeoutException and the task keeps running. To stop the task as well, we call cancel(true) on the future, as the task cancellation article explains.

5. Shutting Down an ExecutorService

The threads of a pool are non-daemon threads by default, so the JVM cannot exit while a pool is still open. A program that forgets to shut down its executor keeps running after main() returns, and a web app that creates an executor per request leaks threads until it runs out of memory.

Since Java 19, ExecutorService extends AutoCloseable, and its close() method works in two steps.

  • It calls shutdown(), so the executor accepts no new tasks and finishes the queued ones.
  • It waits until all tasks have completed. If the waiting thread is interrupted, it calls shutdownNow(), keeps waiting and restores the interrupt flag at the end.
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<Integer> answer = pool.submit(() -> 6 * 7);
pool.close();
boolean terminated = pool.isTerminated();        // true
int answerValue = answer.resultNow();            // 42
Future<Integer> late = pool.submit(() -> 1);     // RejectedExecutionException

A try-with-resources block is the right choice for short-lived executors inside one method. A long-lived executor in a server app, such as a pool shared by many requests, is shut down once when the app stops, with shutdown(), awaitTermination() and a timeout. The ExecutorService shutdown article covers that pattern and the difference between shutdown() and shutdownNow().

6. Virtual Thread Executors

The virtual threads of Java 21 (JEP 444) are cheap threads managed by the JVM. They let a blocking call wait without holding an operating system thread. Calling Executors.newVirtualThreadPerTaskExecutor() gives us an ExecutorService that creates one virtual thread per submitted task, with no pooling.

List<Future<Integer>> lengths = new ArrayList<>();
try (ExecutorService vexec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String city : List.of("Rome", "Oslo", "Lima")) {
        lengths.add(vexec.submit(() -> city.length()));
    }
}
int total = lengths.stream().mapToInt(Future::resultNow).sum();   // 12

The API is the same, so code written against ExecutorService switches to virtual threads by changing one line. The choice depends on the work.

  • For tasks that mostly wait on I/O, such as REST calls or database queries, we use the virtual thread executor and let thousands of tasks run at once.
  • For CPU-bound tasks, such as image processing or hashing, we keep a fixed pool sized to the number of cores, because more threads than cores only add switching cost.
  • To limit how many tasks call a slow service at the same time, we use a Semaphore, not a small pool of virtual threads.

The newVirtualThreadPerTaskExecutor() example shows the executor with ten thousand blocking tasks.

7. Fetching Supplier Quotes in Parallel

Say a travel site asks three hotel suppliers for a room price and shows the results it gets within half a second. One supplier is slow today, and the page must not wait for it. The method quote() simulates the remote call, and pause() simulates network time.

static void pause(long ms) {
    LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(ms));
}
static int quote(String supplier, long delayMs) {
    pause(delayMs);
    return supplier.length() * 10;
}

The invokeAll() overload with a timeout returns when all tasks are done or the time is up, and it cancels every task that has not finished.

List<Callable<Integer>> calls = List.of(
        () -> quote("acme", 50),
        () -> quote("globex", 80),
        () -> quote("initech", 3_000));
List<Future<Integer>> quotes;
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
    quotes = pool.invokeAll(calls, 500, TimeUnit.MILLISECONDS);
}
int acme = quotes.get(0).resultNow();            // 40
int globex = quotes.get(1).resultNow();          // 60
Future.State slow = quotes.get(2).state();       // CANCELLED

We can see that the page shows two prices after half a second, whereas a loop that called the suppliers one by one would wait more than three seconds. The cancelled task gets an interrupt, so close() does not wait for the slow supplier either.

8. Mistakes to Avoid With ExecutorService

Most production problems with executors come from a few patterns that compile fine and pass small tests.

  • Creating a new executor per request and never closing it leaks threads on every call. Create the executor once, or use try-with-resources inside the method.
  • Using newFixedThreadPool() or newSingleThreadExecutor() for a never-ending stream of tasks fills the heap, because their queue is unbounded and tasks pile up in memory when producers are faster than workers. A bounded queue with a RejectedExecutionHandler fixes it.
  • Calling get() without a timeout on a task that calls another system blocks the caller for as long as that system hangs.
  • Ignoring the Future of a submit() call hides every exception the task throws.
  • Submitting a task that waits for another task in the same small pool. When all threads wait for queued tasks, no thread is left to run them, and the pool deadlocks.
  • Code that expects tasks to finish in submission order breaks on a multi-thread pool, because only a single-thread executor keeps that order.

9. ExecutorService FAQs

A first executor in a project raises questions about its rivals, its size and its life span, and these answers cover the ones people search for.

9.1. What is the difference between Executor and ExecutorService?

The Executor interface has one method, execute(Runnable), and nothing else. The ExecutorService interface extends it with methods that return a Future, run groups of tasks and shut the executor down. Code that only fires tasks can depend on Executor; code that needs results or a clean shutdown needs ExecutorService.

9.2. Do we have to shut down an ExecutorService?

Yes. Pool threads are non-daemon threads, so an executor that is never shut down keeps the JVM running and holds its threads. Use try-with-resources since Java 19, or call shutdown() and awaitTermination() when the app stops.

9.3. ExecutorService vs CompletableFuture, which one should we use?

Both, because they work together. A CompletableFuture describes what happens with a result, such as transforming it, combining two results or handling an error, and it runs its async stages on an executor. By default it uses the common ForkJoinPool, and for blocking calls we pass our own ExecutorService to supplyAsync(supplier, executor).

9.4. How many threads should an ExecutorService have?

For CPU-bound tasks, about the number of cores, which Runtime.getRuntime().availableProcessors() returns. For tasks that wait on I/O, a platform thread pool needs more threads than cores, and a virtual thread executor removes the question because it creates one thread per task. The sizing formula is in the thread pool guide linked in section 2.

9.5. Is ExecutorService thread-safe?

Yes. Any number of threads can submit tasks to the same ExecutorService at the same time. The tasks themselves still need thread-safe access to shared data, for example an AtomicInteger or a ConcurrentHashMap.

10. Conclusion

An ExecutorService runs Runnable and Callable tasks on threads it manages and returns a Future for each one. We create it with a factory method from Executors, or with the ThreadPoolExecutor constructor when we need control over the queue.

In Java 25 code, a short-lived executor belongs in a try-with-resources block, results are read with get() and a timeout or with resultNow() after the block, and tasks that wait on I/O run on a virtual thread executor. Fixed pools stay the right tool for CPU-bound work. More background on all the concurrency tools is in the Java concurrency overview.

11. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.