---
title: 'Executor Services'
source: 'https://academia.sh/en/courses/jvm-and-concurrency/executor-services'
course: 'JVM, Concurrency and Performance'
language: en
updated: '2026-09-13T05:09:02+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Shows, by labeling which side runs it, that the same execute call runs on its own thread when the pool is idle and on the calling thread when it is full; also counts the distinct thread count the pool opens'
    - 'Compares, by running it, that the rejection policy is a parameter chosen by the caller, contrasting the separate responses CallerRunsPolicy and AbortPolicy give at the same moment of fullness'
    - 'Measures that an exception thrown via execute reaches the uncaught exception handler, while one thrown via submit only reaches the caller once get is called'
---

# Executor Services

The same execute call runs on its own thread when the pool is idle and on the calling thread when it is full; distinct thread identity count in a fixed pool stays bounded by the core count, and the rejection policy is a parameter chosen by the caller. An exception thrown via execute reaches the uncaught exception handler, while one thrown via submit only reaches the caller once get is called.

The previous lesson measured how much of the work concurrency utilities keep with the caller and
how much they hand off elsewhere: with `AtomicInteger` the retry stayed with the caller, with
`CountDownLatch` and `Semaphore` the waiter was blocked and the count was dropped by another
side — but the caller itself still decided **which thread** the task would run on, by choosing
whether to call it with `run()` or `start()`. This lesson hands that choice off to a service. An
executor service takes a task and decides for itself who will run it; the question is the same —
which thread runs this step — but the answer is no longer in the call line, it is in the
executor's state at that moment.

The course's sharpest example sits right here, because the code for the two states is
**identical**. The same `execute` call is written whether the pool is idle or full; the only
thing that makes the difference is something that never appears at the call site at all — the
pool's fullness at that moment.

An executor service is built from three parts: a bounded number of threads, a queue holding
waiting tasks, and a rejection policy that kicks in once the queue is also full. In the previous
lesson, the difference between `run()` and `start()` was a single method name; here the
difference shrinks even further — the same method name, the same signature, the same call line,
but two different results. These three sections measure those three parts in turn: how many
distinct threads actually run, who the task falls to when the pool is full, and which side
manages the moment of fullness.

## Who Runs the Task: Distinct Identity Count

`ExecutorService` is an abstraction that takes a list of tasks and decides for itself which
thread will run each one. The caller writes the task as a `Callable` or `Runnable`; the
executor's own rule determines who runs it. To see this rule as a number, distinct thread
identity is measured: each task adds the thread it ran on to a shared set.

**CC16.** Distinct thread identity is counted with a set; each item added to the set is
distinguished only by object identity (`==` equivalence), no name or number is ever printed.
**CC17.** A fixed-size executor creates a new thread rather than reusing an existing idle one
until it reaches the core thread count; this rule belongs to the executor itself, not to the
scheduler.
**CC18.** When the core and maximum thread counts are given as equal, the pool never exceeds
that number; tasks entering the queue are processed in order by the existing threads.

```java
// Identities.java — a set that counts distinct thread identity using == equivalence (helper source, no main)
class Identities {
    static java.util.Set<Thread> newSet() {
        return java.util.Collections.synchronizedSet(
                java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()));
    }
}
```

```java
// DistinctIdentityCount.java — even as the task count grows in a fixed-size pool, distinct identity count stays fixed
import java.util.*;
import java.util.concurrent.*;

public class DistinctIdentityCount {
    static int distinctIdentities(ExecutorService executor, int taskCount) throws Exception {
        Set<Thread> seen = Identities.newSet();
        List<Callable<Void>> tasks = new ArrayList<>();
        for (int i = 0; i < taskCount; i++) {
            tasks.add(() -> { seen.add(Thread.currentThread()); return null; });
        }
        executor.invokeAll(tasks);
        return seen.size();
    }

    public static void main(String[] args) throws Exception {
        try (ExecutorService fourPool1 = Executors.newFixedThreadPool(4)) {
            System.out.println("4 tasks, fixed pool(4)  -> distinct identities: " + distinctIdentities(fourPool1, 4));
        }
        try (ExecutorService fourPool2 = Executors.newFixedThreadPool(4)) {
            System.out.println("8 tasks, fixed pool(4)  -> distinct identities: " + distinctIdentities(fourPool2, 8));
        }
        try (ExecutorService onePool = Executors.newFixedThreadPool(1)) {
            System.out.println("4 tasks, fixed pool(1)  -> distinct identities: " + distinctIdentities(onePool, 4));
        }
    }
}
```

```
4 tasks, fixed pool(4)  -> distinct identities: 4
8 tasks, fixed pool(4)  -> distinct identities: 4
4 tasks, fixed pool(1)  -> distinct identities: 1
```

The measurement uses the `invokeAll` method rather than calling `submit` one by one and waiting
on the leftover futures — because `invokeAll` does not return until every given task has
finished. This is the way to safely compare all three setups without leaving the order to the
scheduler: once the set's final size is read, it is certain that every task in that setup
actually ran and added its own identity to the set.

The `ExecutorService` inside the `try` block's parentheses joins the try-with-resources pattern
established in the Standard Library and Streams course through an `AutoCloseable`
implementation; the closing call here too is implicit and automatic once the block ends. Three
lines ask the same question across three separate setups: four tasks in a pool sized four
produce four distinct identities — each task finds its own thread (CC17). Eight tasks in a pool
of the same size produce, **again**, four distinct identities; the remaining four tasks do not
spawn a new thread, they fall to one of the existing four (CC18). In a single-thread pool, four
tasks fall into a single identity — pool size is an upper bound independent of how many tasks
arrive. The number stays fixed because the rule belongs not to the scheduler but to the
executor's own constructor: `newFixedThreadPool(4)` sets this bound by giving both the core and
maximum thread count as `4`.

## While the Pool Is Full, the Giving Side Runs It

To ask the same question while the pool is full, a bounded queue and a single-thread pool are
set up. The first task is deliberately held on a `CountDownLatch`; this is the way to keep the
pool's one thread busy and **run-time model** the queue's fullness — the order is not left to
the scheduler.

```java
// PoolSaturation.java — the same execute call, the difference is decided by the pool's current fullness
import java.util.concurrent.*;

public class PoolSaturation {
    static String who(Thread caller) {
        return Thread.currentThread() == caller ? "caller" : "created";
    }

    public static void main(String[] args) throws Exception {
        Thread caller = Thread.currentThread();
        CountDownLatch hold = new CountDownLatch(1);
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                1, 1, 0, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(1),
                task -> new Thread(task),
                new ThreadPoolExecutor.CallerRunsPolicy());

        pool.execute(() -> { try { hold.await(); } catch (InterruptedException e) { } });
        pool.execute(() -> System.out.println("task given to the pool     -> " + who(caller)));
        pool.execute(() -> System.out.println("rejected in a full pool    -> " + who(caller)));

        hold.countDown();
        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);
    }
}
```

```
rejected in a full pool    -> caller
task given to the pool     -> created
```

The three `execute` calls are syntactically identical. The first call occupies the pool's single
thread with `hold.await()`. When the second call arrives, the thread is still busy but there is
an open spot in the queue — the task enters the queue and, later, after `hold.countDown()` is
called, runs on the pool's own thread: **created**. When the third call arrives, both the thread
is busy and the queue is full; the executor has nowhere to grow and the task falls to the
rejection policy. Since the policy is `CallerRunsPolicy`, the rejected task runs on the thread
that wrote the `execute` call, **right there**, synchronously — this is why that line appears
first in the output: the third task runs before `hold.countDown()` is called, before the second
task has even left the queue. There is no branching in the code at all; the only thing creating
the difference is the fullness at the moment of the call.

## The Rejection Policy Is a Parameter Chosen by the Caller

`CallerRunsPolicy` silently loads the rejected task onto the caller; this is only one of four
ready-made options. The standard library defines three more: one silently drops the rejected
task without ever running it, one discards the **oldest** waiting task in the queue and takes the
new arrival in, and one — shown here — throws an exception. All four answer the same moment —
thread busy, queue full — with a different decision; which one gets chosen comes not from the
task's kind but from whether the party building the executor can afford to lose that task.

**CC19.** When a bounded queue is full and the pool is at its upper bound, an incoming task
falls to the rejection policy; the first task is deliberately held with a `CountDownLatch` so
that the queue's and the pool's fullness is modeled at run time.
**CC20.** The rejection policy is a parameter chosen by the party that builds the executor, not
determined by the task; the same moment of fullness gives two different results under two
different policies.

```java
// RejectionPolicy.java — the same full-pool moment gets two different results from two rejection policies
import java.util.concurrent.*;

public class RejectionPolicy {
    static String who(Thread caller) {
        return Thread.currentThread() == caller ? "caller" : "created";
    }

    static ThreadPoolExecutor fullPool(RejectedExecutionHandler policy, CountDownLatch hold) {
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                1, 1, 0, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(1),
                task -> new Thread(task),
                policy);
        pool.execute(() -> { try { hold.await(); } catch (InterruptedException e) { } });
        pool.execute(() -> { });
        return pool;
    }

    public static void main(String[] args) throws Exception {
        Thread caller = Thread.currentThread();

        CountDownLatch hold1 = new CountDownLatch(1);
        ThreadPoolExecutor callerRunsPool = fullPool(new ThreadPoolExecutor.CallerRunsPolicy(), hold1);
        try {
            callerRunsPool.execute(() -> System.out.println("CallerRunsPolicy  -> " + who(caller)));
        } finally {
            hold1.countDown();
            callerRunsPool.shutdown();
            callerRunsPool.awaitTermination(10, TimeUnit.SECONDS);
        }

        CountDownLatch hold2 = new CountDownLatch(1);
        ThreadPoolExecutor abortsPool = fullPool(new ThreadPoolExecutor.AbortPolicy(), hold2);
        try {
            abortsPool.execute(() -> System.out.println("this line is never printed"));
            System.out.println("AbortPolicy       -> did not drop");
        } catch (RejectedExecutionException e) {
            System.out.println("AbortPolicy       -> " + e.getClass().getSimpleName());
        } finally {
            hold2.countDown();
            abortsPool.shutdown();
            abortsPool.awaitTermination(10, TimeUnit.SECONDS);
        }
    }
}
```

```
CallerRunsPolicy  -> caller
AbortPolicy       -> RejectedExecutionException
```

The same fullness setup — one task occupying the thread, one task waiting in the queue — is
identical in both executors. The only thing that changes is the fourth parameter given to the
constructor. `CallerRunsPolicy` silently hands the rejected task to the caller; `AbortPolicy`
turns the same moment into an exception, and the rejected task **never runs** — the third line
is never printed, and the `catch` block confirms it. Both are a response to the "full" state, but
the response is not chosen by the task, it is chosen by the code that builds the executor.

Why bother with this decision instead of leaving the queue and the pool unbounded? Because every
thread carries a cost — its own call frame, its own stack, a record the scheduler keeps track
of. An unbounded queue only postpones this problem: if task production is faster than
consumption, the queue grows forever and memory runs out; an unbounded pool carries the same
problem over to the thread count instead. A bounded queue and a bounded pool make the moment of
fullness **visible** and force a decision — the rejection policy is the name of that decision.

## The Exception's Path: execute or submit

If a task throws an exception while running, where does that exception land? The answer depends
on which method the task was given through — and only one of the two methods shows the exception
on its own.

**CC21.** An exception thrown by a task given with `execute` reaches that thread's uncaught
exception handler. An exception thrown by a task given with `submit` is stored in the future
object and reaches the caller only if `get` is called; if `get` is never called, the exception
never shows up anywhere.

```java
// ExceptionPath.java — an exception thrown via execute shows up on its own, one via submit stays silent until get is called
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class ExceptionPath {
    public static void main(String[] args) throws Exception {
        AtomicInteger caught = new AtomicInteger(0);
        CountDownLatch doneA = new CountDownLatch(1);

        ExecutorService poolA = Executors.newSingleThreadExecutor(task -> {
            Thread t = new Thread(task);
            t.setUncaughtExceptionHandler((th, error) -> caught.incrementAndGet());
            return t;
        });
        poolA.execute(() -> {
            try {
                throw new RuntimeException("task error");
            } finally {
                doneA.countDown();
            }
        });
        doneA.await();
        poolA.shutdown();
        poolA.awaitTermination(10, TimeUnit.SECONDS);
        System.out.println("execute()  - reached the uncaught exception handler: " + (caught.get() == 1));

        ExecutorService poolB = Executors.newSingleThreadExecutor();
        Future<?> future = poolB.submit(() -> { throw new RuntimeException("task error"); });
        poolB.shutdown();
        poolB.awaitTermination(10, TimeUnit.SECONDS);
        System.out.println("submit()   - still silent after shutdown            : true (get not called)");
        try {
            future.get();
            System.out.println("get() result -> did not drop");
        } catch (ExecutionException e) {
            System.out.println("get() result -> " + e.getClass().getSimpleName()
                    + " wrapping: " + e.getCause().getClass().getSimpleName());
        }
    }
}
```

```
execute()  - reached the uncaught exception handler: true
submit()   - still silent after shutdown            : true (get not called)
get() result -> ExecutionException wrapping: RuntimeException
```

When a task given with `execute()` throws an exception, that exception reaches its own thread's
uncaught exception handler — with no `get` called at all, on its own. The same task given with
`submit()` throws the same exception, but the `Future` does not swallow that exception, it
**stores** it: even if the pool is shut down and awaited, the exception lands nowhere until
`get` is called on the future object. Once `get` is called, the exception is wrapped inside
`ExecutionException` and carried to the calling thread. Only one of the two ways of giving a task
shows the exception on its own — the count: **1** of **2**. This is also a warning about the
executor's own shutdown: `shutdown()` and `awaitTermination()` guarantee that tasks have
finished, but they say nothing about which task failed; that information only comes if it is
requested from the future objects.

The difference comes from how the two ways of giving a task wrap it. `execute(Runnable)` runs
the task directly; the thread's own run loop does not catch an exception coming out of the task,
it terminates the loop and carries it to the thread's uncaught exception handler. `submit(...)`
instead wraps the task in a future object first; that object's own run method catches every
exception coming out of the task **inside itself** and stores it, never letting it leak into the
thread's run loop — the loop continues as if the task finished successfully. Where the exception
lands is, therefore, a side effect of how the task was called: the same exception, thrown from
the same line, passes through two different paths.

All four measurements point the same way: giving a task to an executor service takes the choice
of who runs it out of the caller's hands, but it does not make it invisible. Distinct identity
count does not exceed the pool's size, the giving thread steps in in a full pool, the rejection
policy determines who meets this moment, and where the exception lands is also a consequence of
how it was given. In all four, a party that does not appear in the source line makes the
decision; this lesson brought that party into view every time, by running it.

## Summary

- `ExecutorService` is an abstraction that decides for itself which thread runs a task;
  distinct thread identity stays bounded by the core count in a fixed pool, even if the task
  count exceeds that bound.
- While the pool is idle, the pool's own thread runs the task; while the pool is full, the same
  `execute` call runs the task on the **giving** thread — the code is identical, the fullness
  at the moment of the call makes the difference.
- The rejection policy is a parameter chosen not by the task but by the party building the
  executor; `CallerRunsPolicy` hands the task to the caller, `AbortPolicy` turns the same
  moment into an exception and the task never runs.
- An exception thrown via `execute` reaches the uncaught exception handler on its own; the same
  exception thrown via `submit` is stored in the future object and only reaches the caller if
  `get` is called.
- `ExecutorService`'s `AutoCloseable` implementation joins the try-with-resources pattern; the
  closing call is automatic for the executor too.

## Next Step

In this lesson, the pool's threads were a bounded resource: a fixed number existed, they filled
up, they were reused in turn, and fullness put the caller to work. This bound comes from the
thread itself being **expensive**. The next lesson lifts that premise: the same question is asked
with an executor that uses a far cheaper kind of thread instead of a platform thread — if the
thread itself is cheap instead of the pool, how does the question of who runs the task change?
