Table of Contents

ExecutorService in Java

  • Last Updated: July 15, 2024
  • By: javahandson
  • Series
img

ExecutorService in Java

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.

1. Introduction

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.

1.1 What This Article Covers

Our goal is simple. By the end, you will replace manual thread creation with pools. Here is the plan:

  • Why creating a new Thread for every task breaks down
  • Why thread pools beat new Thread() every time
  • The Executors factory methods: fixed, cached, and single
  • The difference between submit() and execute()
  • Shutting a pool down with shutdown() and shutdownNow()
  • Waiting for a pool to finish with awaitTermination()
  • The basic lifecycle of an executor, from running to terminated
  • Common mistakes, a practical walkthrough, and interview questions

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.

2. The Problem With new Thread()

2.1 One Task, One Thread

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:4

The 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.

2.2 Five Tasks, Five Threads

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:4

Six threads now compete for the CPU: five children plus main. It still works. Nothing looks wrong yet.

2.3 What About 500 Tasks?

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.

2.4 Why This Breaks Down

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:

  • Creation costs time. The JVM asks the OS for a new thread each time. For a tiny task, setup can take longer than the work itself.
  • Each thread costs memory. Every thread reserves its own stack, often around 1 MB on 64-bit systems. Five hundred threads reserve a lot of memory for very little benefit.
  • Cores stay limited. A machine with 8 cores runs at most 8 threads at the same instant. The other 492 simply wait their turn.
  • Switching adds overhead. The CPU keeps swapping threads in and out. With too many threads, it spends real time on the swaps themselves.
  • Nothing sets a limit. If tasks arrive faster than they finish, the thread count keeps climbing. Eventually the JVM fails with an OutOfMemoryError: unable to create native thread.
  • Nothing gets reused. Each thread runs one task and dies. The next task pays the full setup cost again.

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.

3. Why Thread Pools Beat new Thread() Every Time

3.1 The Thread Pool Idea

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.

3.2 What You Gain

A pool fixes every problem from the previous section. Here is the short version:

  • Thread reuse: you pay the creation cost once per worker, not once per task.
  • A hard cap on threads: a pool of 10 never grows to 500, so memory stays predictable.
  • A built-in queue: extra tasks wait their turn instead of spawning new threads.
  • Cleaner code: you describe the work, and the pool decides which thread runs it.
  • Lifecycle control: one call stops the whole pool, and another waits for it to finish.
  • Results and errors: submit() gives back a Future, so you can read a result or catch a failure later.

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.

3.3 Where ExecutorService Fits

Java ships all of this in the java.util.concurrent package. A few types work together, and it helps to see how they relate:

  • Executor: the simplest interface. It has one method, execute(Runnable).
  • ExecutorService: extends Executor. It adds submit(), invokeAll(), invokeAny(), and the shutdown methods.
  • ScheduledExecutorService: extends ExecutorService with methods that run tasks after a delay or on a schedule.
  • ThreadPoolExecutor: the main class that actually implements a thread pool.
  • Executors: a utility class full of factory methods that build ready-made pools for you.

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.

4. Creating a Pool With the Executors Factory Methods

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.

4.1 newFixedThreadPool()

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.

4.2 newCachedThreadPool()

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.

4.3 newSingleThreadExecutor()

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.

4.4 newScheduledThreadPool()

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.

4.5 Fixed vs Cached vs Single at a Glance

Here is a side-by-side view of the three main factory methods:

Factory methodNumber of threadsWhen all threads are busyBest for
newFixedThreadPool(n)Exactly nNew tasks wait in a queueSteady, predictable workloads
newCachedThreadPool()0 up to no fixed limitCreates a new threadMany short, bursty tasks
newSingleThreadExecutor()Exactly 1New tasks wait in a queueTasks 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.

4.6 A Peek at ThreadPoolExecutor

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<>());
  • Core pool size: the number of threads the pool keeps even when they sit idle.
  • Maximum pool size: the largest number of threads the pool may ever hold.
  • Keep-alive time: how long an extra idle thread waits for work before it exits.
  • Work queue: where tasks wait until a worker is free. Here, a 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.

5. Handing Tasks to the Pool: execute() vs submit()

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.

5.1 execute() for Fire-and-Forget

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:4

Notice 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.

5.2 submit() With a Runnable

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 : true

Right 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.

5.3 submit() With a Callable

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 : true

The 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.

5.4 The Three submit() Overloads

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 successfully

5.5 Submitting Many Tasks at Once

ExecutorService 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 : 145

The 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 : 45

If 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.

5.6 How Exceptions Behave Differently

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 submit

With 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.

5.7 execute() vs submit() at a Glance

Pointexecute()submit()
Declared inExecutorExecutorService
AcceptsRunnable onlyRunnable or Callable
ReturnsvoidA Future
Result of the taskNot availableAvailable through Future.get()
If the task throwsStack trace prints, worker thread diesStored in the Future, rethrown by get()
Good forFire-and-forget workWork 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.

6. Shutting Down: shutdown() vs shutdownNow()

6.1 Why a Pool Must Be Shut Down

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.

6.2 shutdown(): Finish the Work, Take No More

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.

6.3 shutdownNow(): Stop As Soon As Possible

Sometimes you cannot wait. Maybe the user pressed cancel, or the application is closing. The shutdownNow() method does three things:

  • Just like shutdown(), it stops accepting new tasks.
  • It removes every task still waiting in the queue and returns them to you as a List<Runnable>.
  • Worker threads that are running tasks right now receive an interrupt.
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: 1

The 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.

6.4 Checking the State With isShutdown()

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 : true

Note that isShutdown() only reports that shutdown has started. It says nothing about whether the tasks have finished. For that, you need isTerminated() or awaitTermination().

6.5 shutdown() vs shutdownNow() at a Glance

Pointshutdown()shutdownNow()
Accepts new tasksNoNo
Queued tasksStill runRemoved and returned as a list
Running tasksAllowed to finishInterrupted
Return typevoidList<Runnable>
Waits for tasks to finishNoNo
Use it whenYou want a clean, graceful stopYou need to stop quickly

Neither method waits for anything. Both return right away. Waiting is the job of the next method.

7. Waiting for the Pool: awaitTermination()

7.1 The Polling Loop Problem

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 : true

This 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.

7.2 awaitTermination() to the Rescue

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:

  • Every task finishes after a shutdown. The method returns true.
  • The timeout runs out first. You get false back.
  • Another thread interrupts the waiting thread. The method throws InterruptedException.

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 : true

Always call shutdown() first. Without a shutdown, the pool never terminates. The awaitTermination() call would then just sit through the full timeout and return false.

7.3 The Standard Shutdown Recipe

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.

7.4 close() in Java 19 and Later

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.

8. The Basic Lifecycle of an Executor

8.1 Three Stages

Every ExecutorService moves through three stages, always in the same direction. It can never go back:

  • Running: the pool accepts new tasks and runs them. Every pool starts here.
  • Shutting down: someone called shutdown() or shutdownNow(). The pool rejects new tasks but may still finish old ones.
  • Terminated: every task has finished and every worker thread has exited. The pool is dead for good.

Here is how the two status methods answer in each stage:

StageAccepts new tasksisShutdown()isTerminated()
RunningYesfalsefalse
Shutting downNotruefalse
TerminatedNotruetrue

A terminated pool cannot restart. If you need to run more work, create a fresh pool.

8.2 Inside ThreadPoolExecutor

Under the hood, ThreadPoolExecutor splits these stages a little more finely. It tracks five internal states:

  • RUNNING: accepts new tasks and processes queued ones.
  • SHUTDOWN: reached through shutdown(). No new tasks, but it still processes queued ones.
  • STOP: reached through shutdownNow(). No new tasks, the queue gets cleared, and running tasks get interrupted.
  • TIDYING: all tasks have ended and the worker count is zero. The pool runs its terminated() hook.
  • TERMINATED: the terminated() hook has finished. This is the end of the road.

You never set these states yourself. They simply explain what happens between calling shutdown() and seeing isTerminated() return true.

8.3 One Program, Whole Lifecycle

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=true

Right 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.

9. Common Mistakes and Pitfalls

9.1 Forgetting to Shut Down

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.

9.2 Creating a New Pool for Every Request

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.

9.3 Losing Exceptions With submit()

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.

9.4 Calling awaitTermination() Without shutdown()

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.

9.5 Trusting the Default Pools Under Heavy Load

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.

9.6 Swallowing InterruptedException

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().

10. A Practical Walkthrough

10.1 The Scenario

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.

10.2 The Program

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: true

10.3 What Each Part Does

  • The pool: three threads handle six reports. We never call new Thread() anywhere.
  • Step 1: submit() returns a Future right away, so the loop finishes almost instantly. The work happens in the background.
  • Step 2: get() waits for each report in turn. The April failure surfaces here as an ExecutionException, and the loop carries on.
  • Step 3: we shut down, wait up to ten seconds, and fall back to shutdownNow() if something hangs.

The 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.

11. Interview Questions

Q: What is ExecutorService in Java?

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.

Q: Why should you use a thread pool instead of creating a new Thread for each task?

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.

Q: What is the difference between execute() and submit()?

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().

Q: What is the difference between shutdown() and shutdownNow()?

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.

Q: What does awaitTermination() do?

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.

Q: What is the difference between newFixedThreadPool, newCachedThreadPool, and newSingleThreadExecutor?

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.

Q: What happens if you submit a task after calling shutdown()?

A: The pool rejects it. With the default settings, submit() or execute() throws a RejectedExecutionException.

Q: What is the difference between isShutdown() and isTerminated()?

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.

Q: Why does a Java program sometimes not exit after main() finishes when it uses an ExecutorService?

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.

Q: What changed for ExecutorService in Java 19?

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.

12. Conclusion

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.

Further Reading

Leave a Comment