Java ExecutorService and Thread Pools Explained with Examples

ExecutorService accepts tasks and manages their execution through workers.

Lesson 90 of 127Advanced13 min read

ExecutorService accepts tasks and manages their execution through workers. It separates the work you want done from the mechanics of creating threads for each request. A fixed thread pool reuses a bounded number of worker threads, while Future represents a submitted task's eventual result. The key obligations are to obtain results or handle failures and to shut down the executor when its owner is finished. These Java 17 examples use explicit shutdown rather than newer-version lifecycle syntax.

Create a pool with an appropriate size

Executors.newFixedThreadPool creates an executor with a fixed maximum number of workers. Tasks beyond available workers wait in its queue. This limits active threads, but the standard fixed pool's queue is unbounded, so unlimited submission can still consume memory. For production workloads requiring backpressure, consider an explicitly configured ThreadPoolExecutor with a bounded queue and rejection policy. Pool size should reflect the work and environment rather than an arbitrary large number.

Submit Runnable or Callable work

Runnable performs an action and returns no result. Callable<T> can return a T and throw a checked exception. submit returns a Future for either form; execute accepts a Runnable without returning a Future. Use Callable when the caller needs a calculation result and Future.get to retrieve it. Keeping task code independent of executor creation makes the operation easier to test and lets the application decide its scheduling policy.

Read results through Future

Future.get waits for completion if needed, then returns the result or throws a relevant exception. It can throw InterruptedException while waiting and ExecutionException if the task failed. The latter wraps the task's original cause. A timed get can limit the caller's wait, but timing out does not automatically cancel the task. Do not discard futures for important work, because failures can otherwise disappear from the application's visible control flow.

Shut down without abandoning accepted work

shutdown stops acceptance of new tasks and allows already submitted work to finish. It does not block until termination. awaitTermination can wait for that later boundary. shutdownNow attempts interruption and returns queued tasks that did not begin; it cannot force an uncooperative task to stop. Define an application-specific shutdown policy and preserve interruption when appropriate. The simple examples wait on every submitted Future before their finally block calls shutdown.

Avoid blocking a pool on itself

A task running in a one-worker executor should not submit another task to that same executor and wait for it to finish. The only worker is occupied by the waiting task, leaving the child task unable to run. Similar starvation can occur in larger pools. Keep dependency relationships explicit, use a suitable composition design, and avoid nested blocking when workers are limited. Increasing pool size may only postpone the underlying design problem.

Know the Java version boundary

Java 17 ExecutorService does not implement AutoCloseable, so it cannot be used directly in try-with-resources. Later Java versions added that lifecycle support. Java 21 also offers virtual-thread executors for suitable blocking workloads. Neither feature changes the need to observe failures or define cancellation. Use the version actually installed when copying tutorial code; explicit try/finally shutdown keeps this lesson's programs compatible with Java 17.

Practice tasks and checks

Submit three Callable tasks that compute independent invoice totals. Print their results from main in submission order, even if you add different delays inside them. Explain why scheduling order and display order are separate. Change one task to throw an IllegalArgumentException and inspect the ExecutionException cause without treating the other successful results as failures. Keep shutdown in finally. Next, add a timeout to one get call and decide whether the application should cancel that task after the timeout; write that policy explicitly rather than assuming timed get stops it. Finally, identify who owns the executor. A reusable service should not shut down a caller-owned pool after every request, while an application-owned pool needs a clear shutdown boundary.

Java Summary

  • Future connects a submitted task to its result or failure.
  • shutdown rejects new work but allows accepted work to finish.
  • Java 17 executors need explicit lifecycle cleanup.
  • Continue with shared-state protection when these examples are clear.

Java Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Get two task results

Save this program as PoolTotals.java. Compile it with javac PoolTotals.java and run it with java PoolTotals.

Expected output:

The pool may finish the two tasks in either order. Output is nevertheless deterministic because main calls and prints first.get before second.get. Each Future links a submitted calculation with its result, and get waits when necessary. The finally block executes even if obtaining a result fails, preventing the owner from forgetting shutdown. Printing inside the tasks instead would make message order depend on scheduling.

Java
import java.util.concurrent.*;
public class PoolTotals {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        try {
            Future<Integer> first = pool.submit(() -> 18 + 7);
            Future<Integer> second = pool.submit(() -> 9 * 4);
            System.out.println(first.get());
            System.out.println(second.get());
        } finally {
            pool.shutdown();
        }
    }
}
Output
25
36
Example 2

Read the cause of a failed task

Save this program as PoolFailure.java. Compile it with javac PoolFailure.java and run it with java PoolFailure.

Expected output:

The parser runs in the worker and fails because bad is not an integer. submit does not immediately throw that parsing error in main. The Future records the failure, and get reports it through ExecutionException. Reading the cause identifies the original NumberFormatException. This is why merely submitting a task is not proof it succeeded and why the owner should observe futures for operations whose failures matter.

Java
import java.util.concurrent.*;
public class PoolFailure {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        try {
            Future<Integer> result = pool.submit(() -> Integer.parseInt("bad"));
            try {
                result.get();
            } catch (ExecutionException error) {
                System.out.println(error.getCause().getClass().getSimpleName());
            }
        } finally {
            pool.shutdown();
        }
    }
}
Output
NumberFormatException
Example 3

Wait for a submitted action

Save this program as PoolAction.java. Compile it with javac PoolAction.java and run it with java PoolAction.

Expected output:

The Runnable prints Saved and has no application result, so its Future is used for completion rather than a value. After get returns, main prints Acknowledged. Both messages therefore follow the intended completion order. The single worker is sufficient for this demonstration, but the ordering comes from waiting on the Future, not merely from choosing a pool with one worker.

Java
import java.util.concurrent.*;
public class PoolAction {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        try {
            Future<?> action = pool.submit(() -> System.out.println("Saved"));
            action.get();
            System.out.println("Acknowledged");
        } finally {
            pool.shutdown();
        }
    }
}
Output
Saved
Acknowledged

Java FAQ

Does shutdown cancel submitted tasks?

No. It rejects new submissions while allowing accepted tasks to complete. Use a documented cancellation or immediate-shutdown policy when work must be stopped, understanding that interruption is cooperative.

Why does this lesson avoid try-with-resources for the executor?

ExecutorService is not AutoCloseable in Java 17. The explicit finally block is compatible with that baseline. Use newer lifecycle syntax only when the project targets a Java version that supports it.

How many threads should a pool have?

For CPU-heavy work, a number close to the processor count, Runtime.getRuntime().availableProcessors(), is a good start. For tasks that mostly wait on networks or files, more threads help, and on Java 21 virtual threads are often simpler. Measure with realistic workloads rather than guessing.

What is the difference between submit and execute?

execute(runnable) starts a task and returns nothing; an uncaught exception goes to the thread's uncaught exception handler. submit(task) returns a Future, and any exception is stored inside it and rethrown, wrapped in an ExecutionException, when you call get(). A submitted task's failure is invisible if you never check its Future.

What is CompletableFuture?

A Future that you can chain and combine: supplyAsync starts a task, thenApply transforms its result, thenCombine merges two results and exceptionally handles failures, all without blocking a thread to wait. It's useful for workflows such as calling two services in parallel and combining their answers.

Java Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choiceeasy

Which task interface can return a value?

Multiple choicemedium

Where does a submitted task failure become visible to its caller?

Java Quiz

2 questions to check what you learned.

  1. What does shutdown do first?

    • Stop accepting new tasks
    • Erase all results
    • Immediately kill every thread
  2. What is risky in a one-worker pool?

    • A worker waiting for a child task submitted to the same pool
    • Reading a completed Future
    • Calling shutdown after results