ExecutorService in Java
-
Last Updated: July 15, 2024
-
By: javahandson
-
Series

ExecutorService in Java lets you stop creating threads by hand and hand your tasks to a reusable thread pool instead. This guide covers why pools beat new Thread(), the Executors factory methods, submit() vs execute(), shutdown() vs shutdownNow(), awaitTermination(), and the full executor lifecycle.
So far in this series, we have created every thread ourselves. We wrote a Runnable, wrapped it in a Thread, and called start(). If you missed that part, Creating Threads in Java walks through it step by step.
That approach works fine for one or two tasks. But real programs rarely have just two tasks. A web server handles thousands of requests. A batch job may process a million records. Creating a fresh thread for each one quickly turns into a problem.
Think of a busy restaurant. Would the owner hire a new chef for every single order, then fire that chef once the plate goes out? Of course not. The owner keeps a small kitchen team. Orders pile up on a rail, and each chef grabs the next ticket when their hands are free.
ExecutorService in Java works exactly like that kitchen. You keep a fixed team of worker threads, called a thread pool. You drop tasks into the pool, and the workers pick them up one after another. Nobody hires or fires a chef per order.
Our goal is simple. By the end, you will replace manual thread creation with pools. Here is the plan:
Two topics sit just outside this article. We touch Future only lightly here, since Article 13 in this series covers Future in depth. Choosing pool sizes, queues, and rejection policies belongs to Article 14, which covers thread pool tuning. We will point to both when we get close.
Let us start where we left off. Say the main thread has a task that should run in the background. We create a child thread, hand it the task, and start it. Here is the task:
package com.javahandson;
public class Task implements Runnable {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}And here is the program that runs it on a child thread:
package com.javahandson;
public class Demo {
public static void main(String[] args) {
// Main thread starts execution
Thread childThread = new Thread(new Task());
childThread.start(); // start() begins the child thread with a new instance of Task
// Main thread continues its execution
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
// Output (the order changes from run to run):
// Thread-0:0
// main:0
// Thread-0:1
// Thread-0:2
// main:1
// main:2
// main:3
// main:4
// Thread-0:3
// Thread-0:4The main thread and the child thread run side by side. Neither one waits for the other, so their lines mix together. That mixed output is normal. It proves both threads really run at the same time.
Now suppose we have five tasks instead of one. The obvious move is a loop that creates five child threads:
package com.javahandson;
public class Demo {
public static void main(String[] args) {
// Main thread starts execution
for (int i = 0; i < 5; i++) { // This loop runs 5 times, so it creates 5 threads
Thread childThread = new Thread(new Task());
childThread.start(); // start() begins the child thread with a new instance of Task
}
// Main thread continues its execution
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
// Output (the order changes from run to run):
// Thread-4:0
// Thread-3:0
// Thread-3:1
// Thread-3:2
// Thread-1:0
// Thread-3:3
// Thread-0:0
// Thread-4:1
// Thread-0:1
// Thread-0:2
// Thread-0:3
// Thread-0:4
// main:0
// main:1
// main:2
// Thread-3:4
// Thread-1:1
// Thread-2:0
// Thread-1:2
// Thread-4:2
// Thread-1:3
// Thread-2:1
// Thread-1:4
// Thread-4:3
// main:3
// main:4
// Thread-2:2
// Thread-2:3
// Thread-4:4
// Thread-2:4Six threads now compete for the CPU: five children plus main. It still works. Nothing looks wrong yet.
So what happens when the task count grows to 500? Following the same logic, we just raise the loop limit:
for (int i = 0; i < 500; i++) { // This loop runs 500 times, so it creates 500 threads
Thread childThread = new Thread(new Task());
childThread.start();
}This code compiles and runs. On a small test, it may even look fine. But it hides a real cost, and that cost grows with every extra task.
A regular Java thread, also called a platform thread, maps one-to-one onto an operating system thread. The OS schedules it, and the JVM reserves a separate stack for it. That makes each thread far heavier than a normal object. Here is what goes wrong at scale:
There is a management problem too. How do you know when all 500 tasks finish? What about collecting their results? And how do you stop them cleanly? With raw threads, you build all of that yourself. That is exactly the gap ExecutorService fills.
Instead of 500 threads, we create a fixed team of, say, 10 threads. Then we hand all 500 tasks to that team. The tasks wait in a queue, like order tickets on the kitchen rail.
Each of the 10 workers takes a task from the queue and runs it. When a worker finishes, it does not die. It walks back to the queue and grabs the next task. That repeats until the queue runs empty.
On average, each worker ends up running about 50 tasks. The split will not be exactly even, though. A worker that lands on quick tasks simply finishes more of them. Either way, 10 threads complete all 500 tasks, and the program never creates thread number 11.
A pool fixes every problem from the previous section. Here is the short version:
That last point matters more than it looks. With a raw Thread, a failure inside run() just prints a stack trace and vanishes. A pool gives you a proper handle on every task you hand it.
Java ships all of this in the java.util.concurrent package. A few types work together, and it helps to see how they relate:
There is one more implementation worth knowing. ForkJoinPool also implements ExecutorService, and parallel streams run on it. You can read about it in ForkJoinPool in Java. For now, we will stay with the everyday pools.
You rarely build a pool from scratch. The Executors class hands you a ready pool in a single line. Three factory methods cover most everyday needs: fixed, cached, and single.
ExecutorService executorService = Executors.newFixedThreadPool(10);
This creates a pool with exactly 10 worker threads. It never grows past 10.
If all 10 workers are busy and another task arrives, that task waits in a queue. As soon as a worker frees up, it takes the next task from the queue. If a worker dies because a task threw an error, the pool starts a replacement.
Reach for a fixed pool when you know roughly how much work you have. CPU-heavy work is a classic fit, since more threads than cores only adds switching overhead.
ExecutorService executorService = Executors.newCachedThreadPool();
A cached pool starts with zero threads. When a task arrives, it reuses an idle thread if one exists. Otherwise, it creates a new one.
Idle threads do not live forever. A thread that sits unused for 60 seconds leaves the pool. So a quiet cached pool shrinks back to nothing on its own.
This pool shines with many short, quick tasks. There is a catch, though. It sets no upper limit on threads. A flood of slow tasks can make it create thousands of them.
ExecutorService executorService = Executors.newSingleThreadExecutor();
This pool has exactly one worker thread. Since only one thread exists, tasks never run in parallel. They run one after another, in the order you submit them.
Why would you want a pool with a single thread? Because sometimes order matters more than speed. Think of writing events to a log file, or applying updates that must not overlap.
You still get the pool benefits. The thread gets reused, tasks queue up cleanly, and a replacement thread takes over if the worker dies.
There is a fourth factory method worth a quick look. It builds a pool that can run tasks after a delay:
ScheduledExecutorService executorService = Executors.newScheduledThreadPool(5);
// This task prints "Hello World" after a delay of 5 seconds
Runnable task = () -> System.out.println("Hello World");
executorService.schedule(task, 5, TimeUnit.SECONDS);Here the pool holds 5 threads. The schedule() method waits 5 seconds, then runs the task on one of them. It also supports repeating tasks, which makes it a neat replacement for the old Timer class.
Here is a side-by-side view of the three main factory methods:
| Factory method | Number of threads | When all threads are busy | Best for |
|---|---|---|---|
| newFixedThreadPool(n) | Exactly n | New tasks wait in a queue | Steady, predictable workloads |
| newCachedThreadPool() | 0 up to no fixed limit | Creates a new thread | Many short, bursty tasks |
| newSingleThreadExecutor() | Exactly 1 | New tasks wait in a queue | Tasks that must run in order |
If you are unsure, start with a fixed pool. It keeps the thread count under control, and it behaves the same way on every run.
Every factory method above builds a ThreadPoolExecutor behind the scenes. You can also create one directly:
ExecutorService executorService = new ThreadPoolExecutor(corePoolSize,
maximumPoolSize, keepAliveTime, TimeUnit.SECONDS,
new LinkedBlockingQueue<>());Choosing good values for these settings is a topic of its own. Article 14 in this series covers thread pool tuning in detail. For this article, the Executors factory methods give us everything we need.
A pool without tasks does nothing. ExecutorService gives you two main ways to hand work over: execute() and submit(). They look similar, but they behave differently in two important ways.
The execute() method comes from the parent Executor interface. It accepts a Runnable and returns nothing. You hand the task over and move on.
package com.javahandson;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo {
public static void main(String[] args) {
// creating ExecutorService with fixed thread pool of size 2
ExecutorService executorService = Executors.newFixedThreadPool(2);
// creating a Runnable task
Runnable runnableTask1 = () -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
};
// creating a Runnable task
Runnable runnableTask2 = () -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
};
// executing the task using executorService
executorService.execute(runnableTask1);
executorService.execute(runnableTask2);
// shutting down the executorService
executorService.shutdown();
}
}
// Output (the order changes from run to run):
// pool-1-thread-2:0
// pool-1-thread-2:1
// pool-1-thread-2:2
// pool-1-thread-2:3
// pool-1-thread-1:0
// pool-1-thread-1:1
// pool-1-thread-1:2
// pool-1-thread-1:3
// pool-1-thread-1:4
// pool-1-thread-2:4Notice the thread names. The default pool names its workers pool-1-thread-1, pool-1-thread-2, and so on. Only two worker threads exist, and they share the work between them. We never wrote new Thread() once.
The submit() method belongs to ExecutorService itself. It accepts the same Runnable, but it hands back a Future. A Future is a receipt for the task. You can use it to ask whether the task has finished, or to wait for it.
package com.javahandson;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Demo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
// creating ExecutorService with fixed thread pool of size 2
ExecutorService executorService = Executors.newFixedThreadPool(2);
// creating a Runnable task
Runnable runnableTask1 = () -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
};
// creating a Runnable task
Runnable runnableTask2 = () -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
};
// submitting the tasks to executorService
Future<?> future1 = executorService.submit(runnableTask1);
Future<?> future2 = executorService.submit(runnableTask2);
boolean isTask1Done = future1.isDone();
System.out.println("Task1 has finished executing : " + isTask1Done);
boolean isTask2Done = future2.isDone();
System.out.println("Task2 has finished executing : " + isTask2Done);
// get() blocks until the task has finished executing
future1.get();
future2.get();
isTask1Done = future1.isDone();
System.out.println("Task1 has finished executing : " + isTask1Done);
isTask2Done = future2.isDone();
System.out.println("Task2 has finished executing : " + isTask2Done);
// shutting down the executorService
executorService.shutdown();
}
}
// Output (a typical run; the order can vary):
// Task1 has finished executing : false
// Task2 has finished executing : false
// pool-1-thread-1:0
// pool-1-thread-1:1
// pool-1-thread-1:2
// pool-1-thread-1:3
// pool-1-thread-1:4
// pool-1-thread-2:0
// pool-1-thread-2:1
// pool-1-thread-2:2
// pool-1-thread-2:3
// pool-1-thread-2:4
// Task1 has finished executing : true
// Task2 has finished executing : trueRight after submitting, isDone() usually returns false. The workers have barely started. Then get() makes the main thread wait until each task completes. After that, isDone() returns true.
For a Runnable, get() returns null on success, because a Runnable has no result to give back. Future can do a lot more than this, including timeouts and cancellation. Article 13 in this series digs into all of it.
What if the task needs to return a value? That is where Callable comes in. Its call() method returns a result, and it may throw a checked exception. The execute() method cannot take a Callable at all. Only submit() can.
package com.javahandson;
import java.util.concurrent.*;
public class Demo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
// creating ExecutorService with fixed thread pool of size 1
ExecutorService executorService = Executors.newFixedThreadPool(1);
// creating a Callable task
Callable<Integer> task = () -> {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
return sum;
};
// submitting the task to executorService
Future<Integer> future = executorService.submit(task);
boolean isTaskDone = future.isDone();
System.out.println("Task has finished executing : " + isTaskDone);
// printing the result of the task
System.out.println("Sum: " + future.get());
isTaskDone = future.isDone();
System.out.println("Task has finished executing : " + isTaskDone);
// shutting down the executorService
executorService.shutdown();
}
}
// Output (a typical run):
// Task has finished executing : false
// Sum: 45
// Task has finished executing : trueThe task adds up 0 through 9 and returns 45. The pool runs it on a worker thread, and future.get() hands the 45 back to main. This is the cleanest way to get a result out of a background thread.
ExecutorService offers three versions of submit(). We have already used the first two:
<T> Future<T> submit(Callable<T> task) // get() returns the Callable's result Future<?> submit(Runnable task) // get() returns null on success <T> Future<T> submit(Runnable task, T result) // get() returns the result you passed in
The third one is handy. A Runnable returns nothing, so you supply the value that get() should return once the task succeeds:
package com.javahandson;
import java.util.concurrent.*;
public class Demo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Runnable runnableTask = () -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
};
String returnValue = "Task has finished successfully";
Future<String> future = executorService.submit(runnableTask, returnValue);
String result = future.get();
System.out.println(result);
executorService.shutdown();
}
}
// Output:
// pool-1-thread-1:0
// pool-1-thread-1:1
// pool-1-thread-1:2
// pool-1-thread-1:3
// pool-1-thread-1:4
// Task has finished successfullyExecutorService has no method that takes a whole list of Runnable tasks. A simple loop does the job:
// creating a list of tasks
List<Runnable> tasks = Arrays.asList(runnableTask1, runnableTask2, runnableTask3);
// submitting each task to the executorService
for (Runnable task : tasks) {
executorService.submit(task);
}For Callable tasks, you get two batch methods. The invokeAll() method runs every task and waits for all of them. It returns a list of Futures in the same order as your tasks:
package com.javahandson;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.*;
public class Demo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
// creating ExecutorService with fixed thread pool of size 2
ExecutorService executorService = Executors.newFixedThreadPool(2);
// creating Callable tasks
Callable<Integer> task1 = () -> {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
return sum;
};
Callable<Integer> task2 = () -> {
int sum = 0;
for (int i = 10; i < 20; i++) {
sum += i;
}
return sum;
};
List<Callable<Integer>> tasks = Arrays.asList(task1, task2);
List<Future<Integer>> futures = executorService.invokeAll(tasks);
int counter = 1;
for (Future<Integer> future : futures) {
System.out.println("Sum of task " + counter + " : " + future.get());
counter++;
}
// shutting down the executorService
executorService.shutdown();
}
}
// Output:
// Sum of task 1 : 45
// Sum of task 2 : 145The invokeAny() method takes the same list but returns just one result. It hands back the value of whichever task finishes successfully first, and cancels the rest:
package com.javahandson;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.*;
public class Demo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
// creating ExecutorService with fixed thread pool of size 2
ExecutorService executorService = Executors.newFixedThreadPool(2);
// creating Callable tasks
Callable<Integer> task1 = () -> {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
return sum;
};
Callable<Integer> task2 = () -> {
int sum = 0;
for (int i = 10; i < 20; i++) {
sum += i;
}
return sum;
};
List<Callable<Integer>> tasks = Arrays.asList(task1, task2);
Integer result = executorService.invokeAny(tasks);
System.out.println("Sum of task : " + result);
// shutting down the executorService
executorService.shutdown();
}
}
// Output (either 45 or 145, depending on which task wins):
// Sum of task : 45If every task fails, invokeAny() throws an ExecutionException. Use it when any one answer will do, such as asking three mirror servers for the same file.
Here is the difference that trips people up in real projects. What happens when a task throws an exception?
package com.javahandson;
import java.util.concurrent.*;
public class ExceptionDemo {
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(2);
// execute(): the exception escapes and prints a stack trace
pool.execute(() -> {
throw new IllegalStateException("boom from execute");
});
// submit(): the exception hides inside the Future
Future<?> future = pool.submit(() -> {
throw new IllegalStateException("boom from submit");
});
try {
future.get(); // only now do we see the failure
} catch (ExecutionException e) {
System.out.println("Caught: " + e.getCause().getMessage());
}
pool.shutdown();
}
}
// Output (stack trace trimmed):
// Exception in thread "pool-1-thread-1" java.lang.IllegalStateException: boom from execute
// Caught: boom from submitWith execute(), the exception escapes the task. The worker thread prints a stack trace and dies, and the pool starts a new worker in its place.
With submit(), the pool catches the exception and stores it inside the Future. Nothing prints at all. You only see the error when you call get(), which wraps it in an ExecutionException. If you never call get(), the failure disappears without a trace.
| Point | execute() | submit() |
|---|---|---|
| Declared in | Executor | ExecutorService |
| Accepts | Runnable only | Runnable or Callable |
| Returns | void | A Future |
| Result of the task | Not available | Available through Future.get() |
| If the task throws | Stack trace prints, worker thread dies | Stored in the Future, rethrown by get() |
| Good for | Fire-and-forget work | Work whose result or failure you care about |
A simple rule of thumb: use submit() when you care about the outcome. Use execute() for true fire-and-forget work, and log errors inside the task itself.
Here is a surprise for many beginners. Pool threads do not stop on their own. A fixed pool keeps its workers alive, waiting for the next task, forever.
Those workers are regular non-daemon threads. The JVM refuses to exit while any non-daemon thread is still alive. So if you forget to shut the pool down, main() finishes but the program keeps running.
That is why every example above ends with a shutdown call. ExecutorService gives you two of them, and they differ in how politely they stop.
Calling shutdown() starts an orderly shutdown. The pool stops accepting new tasks. But every task already in the pool still runs, including the ones waiting in the queue.
One detail catches people out. The shutdown() method does not wait. It returns immediately, while the workers keep finishing their tasks in the background. What if you try to submit a task after shutdown?
ExecutorService pool = Executors.newFixedThreadPool(2);
pool.submit(() -> System.out.println("Task A"));
pool.shutdown();
pool.submit(() -> System.out.println("Task B")); // throws RejectedExecutionException
// Output:
// Task A
// Exception in thread "main" java.util.concurrent.RejectedExecutionException: ...Task A still runs, since it arrived before the shutdown. Task B never gets in. The pool rejects it with a RejectedExecutionException.
Sometimes you cannot wait. Maybe the user pressed cancel, or the application is closing. The shutdownNow() method does three things:
package com.javahandson;
import java.util.List;
import java.util.concurrent.*;
public class Demo {
public static void main(String[] args) {
ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
try {
System.out.println("Running task 1...");
Thread.sleep(10000); // simulate a long running task
System.out.println("Completed task 1");
} catch (InterruptedException e) {
System.out.println("Task 1 was interrupted");
}
});
executorService.submit(() -> {
System.out.println("This is task 2, which might never start if shutdownNow " +
"is called before it gets a chance to run.");
});
List<Runnable> neverStartedTasks = executorService.shutdownNow();
System.out.println("Number of tasks never started: " + neverStartedTasks.size());
}
}
// Output (the order of these lines can vary):
// Running task 1...
// Task 1 was interrupted
// Number of tasks never started: 1The single worker picks up task 1 and starts sleeping. Task 2 still sits in the queue. When shutdownNow() runs, it pulls task 2 out of the queue and interrupts the worker. The sleep() call throws InterruptedException, so task 1 prints its interrupted message.
Pay attention to the word interrupt. The shutdownNow() method cannot force a thread to stop. It only sends a polite signal. A task that ignores interrupts, like a tight loop that never checks, simply keeps running.
The isShutdown() method tells you whether someone has already called shutdown() or shutdownNow():
package com.javahandson;
import java.util.concurrent.*;
public class Demo {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(1);
boolean isShutDown = executorService.isShutdown();
System.out.println("Executor has been shut down : " + isShutDown);
executorService.shutdown(); // shutdown the executor
isShutDown = executorService.isShutdown();
System.out.println("Executor has been shut down : " + isShutDown);
}
}
// Output:
// Executor has been shut down : false
// Executor has been shut down : trueNote that isShutdown() only reports that shutdown has started. It says nothing about whether the tasks have finished. For that, you need isTerminated() or awaitTermination().
| Point | shutdown() | shutdownNow() |
|---|---|---|
| Accepts new tasks | No | No |
| Queued tasks | Still run | Removed and returned as a list |
| Running tasks | Allowed to finish | Interrupted |
| Return type | void | List<Runnable> |
| Waits for tasks to finish | No | No |
| Use it when | You want a clean, graceful stop | You need to stop quickly |
Neither method waits for anything. Both return right away. Waiting is the job of the next method.
Say main() must not move on until every task finishes. One way is to keep asking the pool with isTerminated(). That method returns true only after a shutdown has started and every task has completed.
package com.javahandson;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.*;
public class Demo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
// creating ExecutorService with fixed thread pool of size 2
ExecutorService executorService = Executors.newFixedThreadPool(2);
// creating Callable tasks
Callable<Integer> task1 = () -> {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
return sum;
};
Callable<Integer> task2 = () -> {
int sum = 0;
for (int i = 10; i < 20; i++) {
sum += i;
}
return sum;
};
List<Callable<Integer>> tasks = Arrays.asList(task1, task2);
List<Future<Integer>> futures = executorService.invokeAll(tasks);
for (Future<Integer> future : futures) {
System.out.println("Sum of task : " + future.get());
}
// shutting down the executorService
executorService.shutdown();
while (!executorService.isTerminated()) {
System.out.println("Waiting for all tasks to complete...");
Thread.sleep(1000);
}
boolean allTasksCompleted = executorService.isTerminated();
System.out.println("All tasks completed : " + allTasksCompleted);
}
}
// Output (the "Waiting" line may appear zero or more times):
// Sum of task : 45
// Sum of task : 145
// Waiting for all tasks to complete...
// All tasks completed : trueThis works, but it is clumsy. The loop sleeps a full second even if the pool finishes a millisecond later. It also has no upper limit, so a stuck task would keep main() waiting forever.
The awaitTermination() method does the waiting for you. Here is its signature:
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException
It blocks the calling thread until one of three things happens:
Now the polling loop shrinks to one clean call:
executorService.shutdown();
boolean finished = executorService.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("All tasks completed : " + finished);
// Output:
// All tasks completed : trueAlways call shutdown() first. Without a shutdown, the pool never terminates. The awaitTermination() call would then just sit through the full timeout and return false.
Real applications combine all three methods. First ask politely, then wait, then force the issue if needed. The official Javadoc suggests a pattern very close to this one:
static void shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // stop taking new tasks
try {
// give running tasks a chance to finish
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt whatever is still running
// give tasks a moment to respond to the interrupt
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
}
}
} catch (InterruptedException e) {
pool.shutdownNow(); // someone interrupted us, so cancel now
Thread.currentThread().interrupt(); // keep the interrupt status
}
}Read it from top to bottom. We stop new work, then wait up to a minute. If tasks still run, we interrupt them and wait again. If our own thread gets interrupted while waiting, we cancel everything and restore the interrupt flag, so the caller can still see it.
Since Java 19, ExecutorService extends AutoCloseable. Its new close() method calls shutdown() and then waits until every task finishes. That means a try-with-resources block can manage the whole thing:
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
pool.submit(() -> System.out.println("Task 1"));
pool.submit(() -> System.out.println("Task 2"));
} // close() runs here: shutdown() plus a wait for all tasks
System.out.println("Pool is closed");This is the tidiest option on a modern JDK. Keep in mind that close() waits with no timeout. If a task hangs, close() hangs with it, so the recipe above still matters for long-running services.
Every ExecutorService moves through three stages, always in the same direction. It can never go back:
Here is how the two status methods answer in each stage:
| Stage | Accepts new tasks | isShutdown() | isTerminated() |
|---|---|---|---|
| Running | Yes | false | false |
| Shutting down | No | true | false |
| Terminated | No | true | true |
A terminated pool cannot restart. If you need to run more work, create a fresh pool.
Under the hood, ThreadPoolExecutor splits these stages a little more finely. It tracks five internal states:
You never set these states yourself. They simply explain what happens between calling shutdown() and seeing isTerminated() return true.
Let us watch a pool move through every stage in one small program:
package com.javahandson;
import java.util.concurrent.*;
public class LifecycleDemo {
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(2);
printState("Created", pool);
for (int i = 1; i <= 4; i++) {
int id = i;
pool.submit(() -> {
try {
Thread.sleep(500); // pretend to do some work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + id + " done on " + Thread.currentThread().getName());
});
}
pool.shutdown();
printState("After shutdown()", pool);
pool.awaitTermination(5, TimeUnit.SECONDS);
printState("After awaitTermination()", pool);
}
static void printState(String label, ExecutorService pool) {
System.out.println(label + " -> isShutdown=" + pool.isShutdown()
+ ", isTerminated=" + pool.isTerminated());
}
}
// Output (task order and thread names can vary):
// Created -> isShutdown=false, isTerminated=false
// After shutdown() -> isShutdown=true, isTerminated=false
// Task 1 done on pool-1-thread-1
// Task 2 done on pool-1-thread-2
// Task 3 done on pool-1-thread-1
// Task 4 done on pool-1-thread-2
// After awaitTermination() -> isShutdown=true, isTerminated=trueRight after shutdown(), the pool reports shut down but not terminated, because four tasks still need about a second to finish. Once awaitTermination() returns, both flags read true. Just two workers handled all four tasks.
This is the most common slip. The main() method ends, but the program never exits, because idle pool threads keep the JVM alive. Always shut the pool down, ideally in a finally block, or use try-with-resources on Java 19 and later.
Some code creates a pool inside a method that runs thousands of times. That brings back the very problem pools solve. Create the pool once, share it, and shut it down when the application stops.
A task handed to submit() that throws an exception fails silently. If nobody calls get() on its Future, the error never shows up anywhere. Either call get() and handle the ExecutionException, or catch and log errors inside the task itself.
The awaitTermination() method waits for termination, and termination only follows a shutdown. Skip shutdown(), and the call just burns the whole timeout before it returns false.
Both common factory pools have an unbounded side. A cached pool has no limit on threads. A fixed pool has no limit on its queue.
Under a steady flood of tasks, the cached pool can create thousands of threads. The fixed pool can queue millions of tasks. Either way, memory runs out. Article 14 in this series shows how to set sensible limits for production.
The shutdownNow() method relies on interrupts. If your task catches InterruptedException and just ignores it, the task keeps going and the pool cannot stop it. Either exit the task, or restore the flag with Thread.currentThread().interrupt().
Let us tie everything together. Imagine a small reporting job. It must build six monthly reports, and each report takes a moment to generate. Doing them one by one is slow. Starting six raw threads works, but it does not scale.
Instead, we will use a fixed pool of three threads. We submit six Callable tasks, collect the results, and then shut the pool down properly.
package com.javahandson;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class ReportJob {
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(3);
String[] months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun"};
// 1. Submit one Callable per month
List<Future<String>> results = new ArrayList<>();
for (String month : months) {
results.add(pool.submit(() -> buildReport(month)));
}
// 2. Collect results in the order we submitted them
for (Future<String> result : results) {
try {
System.out.println(result.get());
} catch (ExecutionException e) {
System.out.println("Report failed: " + e.getCause().getMessage());
}
}
// 3. Shut down and wait
pool.shutdown();
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
System.out.println("All reports done. Terminated: " + pool.isTerminated());
}
static String buildReport(String month) throws InterruptedException {
Thread.sleep(300); // pretend to query a database
if (month.equals("Apr")) {
throw new IllegalStateException("no data for " + month);
}
return month + " report built by " + Thread.currentThread().getName();
}
}
// Output (thread names can differ between runs):
// Jan report built by pool-1-thread-1
// Feb report built by pool-1-thread-2
// Mar report built by pool-1-thread-3
// Report failed: no data for Apr
// May report built by pool-1-thread-2
// Jun report built by pool-1-thread-3
// All reports done. Terminated: trueThe whole job takes about 600 milliseconds instead of 1.8 seconds, since three workers build reports at the same time. It also handles a failure gracefully and always shuts the pool down. That is the full lifecycle in one small program.
A: ExecutorService is an interface in java.util.concurrent that manages a pool of worker threads. You hand it tasks, and it runs them on reusable threads. It also offers methods to collect results, run batches of tasks, and shut the pool down cleanly.
A: Each platform thread maps to an OS thread and costs time and memory to create. A pool creates a fixed set of threads once and reuses them for many tasks. It also caps the thread count, queues extra work, and gives you lifecycle control.
A: execute() comes from Executor, takes only a Runnable, and returns nothing. submit() comes from ExecutorService, takes a Runnable or a Callable, and returns a Future. An exception in execute() prints a stack trace, while submit() stores it in the Future until you call get().
A: shutdown() stops new tasks but lets running and queued tasks finish. shutdownNow() stops new tasks, removes queued tasks and returns them as a list, and interrupts running tasks. Neither method waits for the tasks to finish.
A: It blocks the calling thread until all tasks finish after a shutdown, or until the timeout runs out. It returns true if the pool terminated and false if the timeout came first. Always call shutdown() before it.
A: A fixed pool keeps exactly n threads and queues extra tasks. Cached pools create threads on demand, reuses idle ones, and removes threads idle for 60 seconds. A single thread executor uses one thread, so tasks run one at a time in submission order.
A: The pool rejects it. With the default settings, submit() or execute() throws a RejectedExecutionException.
A: isShutdown() returns true as soon as someone calls shutdown() or shutdownNow(). isTerminated() returns true only after that, once every task has finished and all worker threads have exited.
A: Pool worker threads are non-daemon threads, and the JVM waits for all non-daemon threads before exiting. If you never shut the pool down, its idle workers keep the program alive.
A: ExecutorService now extends AutoCloseable and has a close() method. close() calls shutdown() and then waits for all tasks to finish, so you can manage a pool with try-with-resources.
Let us wrap up what we covered. Creating a new Thread for every task costs time and memory, and it sets no limit on the thread count. ExecutorService in Java replaces that with a pool of reusable worker threads.
The Executors class builds the common pools for you. A fixed pool keeps a set number of threads, a cached pool grows and shrinks on demand, and a single thread executor runs tasks in order.
You hand work over with execute() for fire-and-forget jobs, or with submit() when you want a Future back. Remember that submit() hides exceptions until you call get().
Every pool must end its life properly. The shutdown() method stops politely, shutdownNow() interrupts, and awaitTermination() waits with a timeout. Together they move the pool from running, to shutting down, to terminated.
Next in this series, Article 13 looks closely at Future, and Article 14 shows how to tune a thread pool for production.