> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Configure a Thread Pool With ExecutorService in Java
- URL: https://www.ggorantala.dev/how-to-configure-thread-pool-executorservice-java-2/
- Published: 2026-08-21T03:25:00.000Z
- Updated: 2026-10-31T04:29:33.000Z
- Description: Build a bounded thread pool for a support-ticket desk in Java 21: pick the size, cap the queue, name threads, count rejections, and shut down cleanly.
- Author: Gopi Gorantala
- Tags: java-concurrency, java-tutorials, how-to-tutorials

## Ten tickets fit, four don't

This is a thread pool for a support desk with four agents and room for six waiting tickets. I threw fourteen tickets at it at once:

```log
-- 14 tickets arrive at once (4 agents + 6 waiting = 10 fit) --
after ticket  4: pool=4 active=4 queued=0 done=0
after ticket 10: pool=4 active=4 queued=6 done=0
ticket 11 REJECTED: desk is full: 4 busy, 6 waiting
ticket 12 REJECTED: desk is full: 4 busy, 6 waiting
ticket 13 REJECTED: desk is full: 4 busy, 6 waiting
ticket 14 REJECTED: desk is full: 4 busy, 6 waiting
[... lines cut]
closed cleanly=true ok=9 failed=1 rejected=4 largestPool=4

```

Nothing ran away. Ten tickets were accepted, four were turned down loudly, one ticket that threw an exception was counted instead of vanishing, and the pool shut down without leaving a thread behind. That's what "configure a thread pool in Java" should mean: you decide how many threads, how much waiting room, what happens when both are full, and how it all stops. The defaults in `Executors.newFixedThreadPool(4)` quietly decide two of those four for you, and not in your favour (more on that below).

You'll build this in plain Java 21, about 370 lines across seven small files, with no libraries. It's for you if you've used `new Thread(...)` or `executor.submit(...)` and now want a pool you'd be happy to see in a production service. Knowing what a lambda is and what `Future.get()` does is enough.

The build took me two rounds of compiling. The first draft of the rejection counter used a pair of one-element arrays to smuggle a reference past the constructor. It was ugly, I rewrote it before it ran, and the version below is the one that compiled first time.

## Check your setup

| Tool      | Version I used                   | Check it with    |
| --------- | -------------------------------- | ---------------- |
| JDK       | OpenJDK 21.0.12.1 (Ubuntu build) | java -version    |
| Gradle    | 8.14.3                           | gradle --version |
| Machine   | 2 vCPUs, Linux                   | nproc            |
| Libraries | none                             | n/a              |

Java 19 or later is needed for one nicety (`ExecutorService` implementing `AutoCloseable`, used in `Sizing.java`). Everything else runs on Java 11, with `record` and the `switch` arrows being the exceptions: those need 16 and 14.

Limits, up front. My build environment couldn't reach Maven Central, so the checks are plain Java with a PASS or FAIL line each, not JUnit; each one maps straight onto a JUnit 5 `@Test`. I ran Gradle with `--offline` (there's nothing to download). I didn't open IntelliJ IDEA or Eclipse, so the IDE steps are from the vendors' documentation and labelled that way. And I ran on JDK 21 only.

## Create the ticket-desk project (javac, Gradle, IntelliJ IDEA, Eclipse)

I used the command line with Gradle as the main path. Make the folders and two build files:

```bash
mkdir -p desk/src/main/java/com/example/desk
cd desk

```

`settings.gradle.kts`:

```kotlin
rootProject.name = "desk"

```

`build.gradle.kts`:

```kotlin
plugins {
    java
    application
}

java {
    toolchain { languageVersion = JavaLanguageVersion.of(21) }
}

repositories { mavenCentral() }   // nothing to download; JUnit would come from here

tasks.withType<JavaCompile> {
    options.compilerArgs.add("-Xlint:all")
}

application {
    // pick the demo with: gradle -q run -Pmain=com.example.desk.Checks
    mainClass = providers.gradleProperty("main").orElse("com.example.desk.Main")
}

```

`-Xlint:all` makes the compiler grumble about anything suspicious, and I want that now rather than in review. The `main` property lets one build file run every demo in this article: `gradle -q run -Pmain=com.example.desk.Checks`. Without `-P` it runs `Main`.

The tree when you're finished:

```log
desk/
├── build.gradle.kts
├── settings.gradle.kts
└── src/main/java/com/example/desk/
    ├── Ticket.java
    ├── NamedThreadFactory.java
    ├── DeskPool.java
    ├── Main.java
    ├── Sizing.java
    ├── Traps.java
    └── Checks.java

```

### With plain javac

No build tool at all works too:

```bash
javac -Xlint:all -d out $(find src -name '*.java')
java -cp out com.example.desk.Main

```

Silence from `javac` is a clean compile. I ran this as well; the checks gave the same result. (On Windows PowerShell, list the files explicitly instead of using `$(find ...)`; I didn't test that.)

### In IntelliJ IDEA

*Verified from the docs, not executed.* **File → New → Project…**, pick **Java**, set the build system to **Gradle** with **Kotlin** as the DSL and JDK 21, call it `desk`, then paste in the files. Any class with a `main` method gets a green arrow in the gutter.

### In Eclipse

*Verified from the docs, not executed.* Use **File → Import → Gradle → Existing Gradle Project** (Buildship ships with the common Eclipse packages; install it from the Marketplace if yours lacks it) and point it at the `desk` folder. Run a class with **Run As → Java Application**.

### Podman and Docker

Not needed. Everything runs inside one JVM.

## How to configure a thread pool with ExecutorService, one decision at a time

A pool is five decisions: how many threads, how many tickets may wait, what to do when both are full, what the threads are called, and how to stop. The `Executors` factory methods make some of those for you. We'll make all of them ourselves, one step each.

### Step 1: Describe the work

**Goal:** a task that takes a known time and can be told to fail, so the pool has something to chew on.

`src/main/java/com/example/desk/Ticket.java`:

```java
package com.example.desk;

/** One support ticket. workMs is how long the agent takes; fails=true makes the work throw. */
public record Ticket(int id, String customer, int workMs, boolean fails) {

    public Ticket(int id, String customer, int workMs) {
        this(id, customer, workMs, false);
    }

    /** Pretend to work on the ticket. */
    public String resolve() throws InterruptedException {
        Thread.sleep(workMs);
        if (fails) {
            throw new IllegalStateException("ticket " + id + " from " + customer + " could not be resolved");
        }
        return "ticket " + id + " resolved by " + Thread.currentThread().getName();
    }
}

```

`workMs` is how long the agent "works" (a `Thread.sleep`, standing in for a database call or an HTTP request). `fails = true` makes `resolve()` throw, which we'll need in Step 4\. The second constructor keeps the happy-path call short.

### Step 2: Name your threads

**Goal:** every thread in the pool says who it is in logs and thread dumps.

`src/main/java/com/example/desk/NamedThreadFactory.java`:

```java
package com.example.desk;

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

/** Gives every pool thread a readable name: desk-1, desk-2, ... */
public final class NamedThreadFactory implements ThreadFactory {
    private final String prefix;
    private final AtomicInteger counter = new AtomicInteger(1);   // a field, not a new one per call

    public NamedThreadFactory(String prefix) {
        this.prefix = prefix;
    }

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, prefix + "-" + counter.getAndIncrement());
        t.setDaemon(false);   // a pool nobody shuts down should be loud, not silent
        return t;
    }
}

```

The default factory names threads `pool-1-thread-1`, which tells you nothing at 3 a.m. when you're looking at a dump with six pools in it. The counter is a **field**. I've seen this class written with `new AtomicInteger()` inside `newThread`, which gives every thread the name `agent-1`; a pool where every thread has the same name is close to undebuggable.

`setDaemon(false)` is the default anyway. I wrote it out so the next line of this article makes sense: threads like these keep the JVM alive until you shut the pool down.

### Step 3: Build the pool

**Goal:** a `ThreadPoolExecutor` with a fixed number of threads, a bounded queue, a rejection policy, and counters.

`src/main/java/com/example/desk/DeskPool.java`:

```java
package com.example.desk;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * The ticket desk's thread pool: fixed number of threads, a bounded queue,
 * and counters for the two things you want to alert on: rejections and failures.
 */
public final class DeskPool extends ThreadPoolExecutor {
        private final AtomicInteger failed = new AtomicInteger();
    private final AtomicInteger completed = new AtomicInteger();

    /** What to do when both the threads and the queue are full. */
    public enum Overload { ABORT, CALLER_RUNS }

    /** Counts a rejection, then applies the real policy. */
    private static final class CountingHandler implements RejectedExecutionHandler {
        final AtomicInteger count = new AtomicInteger();
        final Overload mode;

        CountingHandler(Overload mode) {
            this.mode = mode;
        }

        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor ex) {
            count.incrementAndGet();
            if (ex.isShutdown()) {
                throw new RejectedExecutionException("desk is closed");
            }
            switch (mode) {
                case ABORT -> throw new RejectedExecutionException("desk is full: "
                        + ex.getPoolSize() + " busy, " + ex.getQueue().size() + " waiting");
                case CALLER_RUNS -> r.run();   // the submitting thread does the work itself
            }
        }
    }

    private final CountingHandler handler;

    public DeskPool(String name, int threads, int queueSize, Overload mode) {
        this(name, threads, queueSize, new CountingHandler(mode));
    }

    private DeskPool(String name, int threads, int queueSize, CountingHandler handler) {
        super(threads, threads,                       // core == max: the queue decides, so don't pretend otherwise
                0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(queueSize),  // bounded, so overload becomes visible
                new NamedThreadFactory(name),
                handler);
        this.handler = handler;
    }

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        super.afterExecute(r, t);
        Throwable problem = t;
        // submit() wraps the task in a FutureTask, which swallows the exception. Dig it out.
        if (problem == null && r instanceof Future<?> f && f.isDone()) {
            try {
                f.get();
            } catch (CancellationException e) {
                problem = e;
            } catch (ExecutionException e) {
                problem = e.getCause();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        if (problem != null) {
            failed.incrementAndGet();
            System.out.println("  [" + Thread.currentThread().getName() + "] FAILED: " + problem.getMessage());
        } else {
            completed.incrementAndGet();
        }
    }

    public int rejectedCount() { return handler.count.get(); }
    public int failedCount() { return failed.get(); }
    public int okCount() { return completed.get(); }

    public String snapshot() {
        return "pool=" + getPoolSize() + " active=" + getActiveCount()
                + " queued=" + getQueue().size() + " done=" + getCompletedTaskCount();
    }
}

```

This is the heart of the article, so here's what each choice does.

- `super(threads, threads, ...)`: core size equals maximum size. I'll explain in "Why the queue decides" why a different maximum usually does nothing.
- `new ArrayBlockingQueue<>(queueSize)`: bounded. When it fills, the *next* submit is refused. An unbounded queue never refuses anything, and that is how a slow database turns into an out-of-memory error an hour later.
- `Overload.ABORT` throws `RejectedExecutionException` to the caller, who can return a 503\. `Overload.CALLER_RUNS` makes the submitting thread do the work itself, which slows the producer down. Both count the rejection first.
- `afterExecute` is the part people miss. When you call `submit(...)`, the pool wraps your task in a `FutureTask`, and the `FutureTask` swallows any exception until somebody calls `get()`. Without this method, a failing task fails in silence. The method digs the exception out of the finished future and counts it.

If you want a bit more on the rules the pool follows, [ThreadPoolExecutor explained](https://ggorantala.dev/java-threadpoolexecutor/?ref=ggorantala.dev) takes it apart with lab output. Here we use it.

**Checkpoint:** compile with `gradle --offline -q compileJava`. No output means a clean build. If you pass `true` instead of an `Overload` value somewhere, you'll get this, and I forced it to be sure:

```log
Bad.java:2: error: no suitable constructor found for DeskPool(String,int,int,boolean)
    constructor DeskPool.DeskPool(String,int,int,Overload) is not applicable
      (argument mismatch; boolean cannot be converted to Overload)

```

### Step 4: Open the desk

**Goal:** submit fourteen tickets to four agents and see all four outcomes (running, waiting, rejected, failed).

`src/main/java/com/example/desk/Main.java`:

```java
package com.example.desk;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;

public class Main {
    public static void main(String[] args) throws Exception {
        // 4 agents, room for 6 waiting tickets, then the desk says no.
        DeskPool desk = new DeskPool("agent", 4, 6, DeskPool.Overload.ABORT);
        List<Future<String>> receipts = new ArrayList<>();

        System.out.println("-- 14 tickets arrive at once (4 agents + 6 waiting = 10 fit) --");
        for (int id = 1; id <= 14; id++) {
            Ticket t = new Ticket(id, "customer-" + id, 300, id == 3);   // ticket 3 will blow up
            try {
                receipts.add(desk.submit(t::resolve));
            } catch (RejectedExecutionException e) {
                System.out.printf("ticket %2d REJECTED: %s%n", id, e.getMessage());
            }
            if (id == 4 || id == 10 || id == 11) {
                System.out.printf("after ticket %2d: %s%n", id, desk.snapshot());
            }
        }

        System.out.println("-- collecting results --");
        for (Future<String> f : receipts) {
            try {
                System.out.println(f.get());
            } catch (ExecutionException e) {
                System.out.println("caller sees: " + e.getCause());
            }
        }

        desk.shutdown();                                              // stop taking tickets, finish the queue
        boolean clean = desk.awaitTermination(5, TimeUnit.SECONDS);   // wait, but not forever
        System.out.printf("closed cleanly=%b ok=%d failed=%d rejected=%d largestPool=%d%n",
                clean, desk.okCount(), desk.failedCount(), desk.rejectedCount(), desk.getLargestPoolSize());
    }
}

```

Two lines are doing the real work. `desk.submit(t::resolve)` hands the pool a `Callable` and returns a `Future` you can ask about later. `desk.awaitTermination(5, TimeUnit.SECONDS)` waits for the pool to drain, but only for five seconds; a shutdown that waits forever is a hang in waiting.

## See what 14 tickets do to 4 agents

```bash
gradle --offline -q run

```

```log
-- 14 tickets arrive at once (4 agents + 6 waiting = 10 fit) --
after ticket  4: pool=4 active=4 queued=0 done=0
after ticket 10: pool=4 active=4 queued=6 done=0
ticket 11 REJECTED: desk is full: 4 busy, 6 waiting
after ticket 11: pool=4 active=4 queued=6 done=0
ticket 12 REJECTED: desk is full: 4 busy, 6 waiting
ticket 13 REJECTED: desk is full: 4 busy, 6 waiting
ticket 14 REJECTED: desk is full: 4 busy, 6 waiting
-- collecting results --
ticket 1 resolved by agent-1
ticket 2 resolved by agent-2
caller sees: java.lang.IllegalStateException: ticket 3 from customer-3 could not be resolved
ticket 4 resolved by agent-4
  [agent-3] FAILED: ticket 3 from customer-3 could not be resolved
ticket 5 resolved by agent-2
ticket 6 resolved by agent-4
ticket 7 resolved by agent-1
ticket 8 resolved by agent-3
ticket 9 resolved by agent-4
ticket 10 resolved by agent-2
closed cleanly=true ok=9 failed=1 rejected=4 largestPool=4

```

Read it top to bottom. After ticket 4 the pool has four threads and an empty queue: each of the first four tickets got its own thread. After ticket 10, still four threads, and six tickets waiting. Tickets 11 to 14 are rejected with a message that says what the desk looked like at that moment. That message is the one I'd want in a log.

Ticket 3 is the failure. Notice "caller sees" prints *before* the `[agent-3] FAILED` line. The caller learns about the exception when it calls `get()`, which can happen before the pool thread finishes its own bookkeeping in `afterExecute`. Don't rely on the order of those two lines.

And the final line: `ok=9 failed=1 rejected=4`. Ten accepted tickets, nine resolved and one failed, and four turned away. `largestPool=4` confirms the pool never grew past what we configured.

### How many threads should it have?

The honest answer is "it depends what the work is doing". `Sizing.java` runs 48 tickets through pools of different sizes, once with tickets that *wait* (a 100 ms sleep, like a network call) and once with tickets that *compute* (a tight arithmetic loop).

`src/main/java/com/example/desk/Sizing.java`:

```java
package com.example.desk;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/** Same 48 tickets, different pool sizes. Two workloads: waiting (sleep) and number-crunching. */
public class Sizing {
    static long spin(long iterations) {
        long x = 0;
        for (long i = 0; i < iterations; i++) x += i ^ (x >>> 3);
        return x;
    }

    static long run(int threads, Callable<Object> job, int tickets) throws Exception {
        long start = System.nanoTime();
        try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {   // Java 19+: close() waits
            List<Callable<Object>> jobs = new ArrayList<>();
            for (int i = 0; i < tickets; i++) jobs.add(job);
            pool.invokeAll(jobs);
        }
        return (System.nanoTime() - start) / 1_000_000;
    }

    public static void main(String[] args) throws Exception {
        System.out.println("cores = " + Runtime.getRuntime().availableProcessors());
        spin(300_000_000L);   // warm up the JIT so the first row isn't unfairly slow
        Callable<Object> waiting = () -> { Thread.sleep(100); return null; };
        Callable<Object> crunching = () -> spin(150_000_000L);
        System.out.println("threads | waiting tickets (100 ms sleep) | crunching tickets");
        for (int threads : new int[]{1, 2, 4, 8, 16, 48}) {
            long w = run(threads, waiting, 48);
            long c = run(threads, crunching, 48);
            System.out.printf("%7d | %26d ms | %14d ms%n", threads, w, c);
        }
    }
}

```

```bash
gradle --offline -q run -Pmain=com.example.desk.Sizing

```

```log
cores = 2
threads | waiting tickets (100 ms sleep) | crunching tickets
      1 |                       4820 ms |           7978 ms
      2 |                       2408 ms |           4083 ms
      4 |                       1203 ms |           4049 ms
      8 |                        602 ms |           4027 ms
     16 |                        307 ms |           4134 ms
     48 |                        121 ms |           4087 ms

```

On the waiting tickets, doubling the threads halves the time all the way up: 4,821 ms with one thread, 112 ms with 48\. The threads spend nearly all their time asleep, so there's nothing for them to fight over. On the computing tickets, two threads halve the time (7,909 to 4,037 ms) and then nothing improves. My machine has two cores, and once both are busy, extra threads just take turns. At 48 threads it was marginally slower than at 2.

So the rule I use: for work that computes, start at the number of cores. For work that mostly waits on something else, you can go well above it, and the right number depends on how long it waits against how long it computes. A common rule of thumb is cores × (1 + wait time ÷ compute time); I haven't measured that formula here, so treat it as a starting point and load-test. Your queue size, not your thread count, is what caps memory.

## Prove it with 15 checks

JUnit wasn't reachable from my environment, so these are plain Java: a `check(name, condition)` method that prints PASS or FAIL and exits with 1 on any failure. The trick for testing a pool without sleeping everywhere is a `blocked(latch)` task that holds a thread until the test releases it. That way the test, not the clock, decides when threads become free.

`src/main/java/com/example/desk/Checks.java`:

```java
package com.example.desk;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/** Plain checks: no framework, exit code 1 on any failure. Each one turns into a JUnit 5 @Test. */
public class Checks {
    static int passed, failed;

    static void check(String name, boolean ok) {
        if (ok) passed++; else failed++;
        System.out.println((ok ? "PASS  " : "FAIL  ") + name);
    }

    /** A job that blocks until released, so we control exactly when threads become free. */
    static Runnable blocked(CountDownLatch release) {
        return () -> { try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } };
    }

    public static void main(String[] args) throws Exception {
        // pool growth: threads first, then queue, then rejection
        CountDownLatch release = new CountDownLatch(1);
        DeskPool pool = new DeskPool("agent", 2, 3, DeskPool.Overload.ABORT);
        for (int i = 0; i < 2; i++) pool.execute(blocked(release));
        check("first 2 tasks start 2 threads", pool.getPoolSize() == 2 && pool.getQueue().isEmpty());
        for (int i = 0; i < 3; i++) pool.execute(blocked(release));
        check("next 3 tasks wait in the queue; pool stays at 2", pool.getPoolSize() == 2 && pool.getQueue().size() == 3);
        boolean rejected = false;
        try { pool.execute(blocked(release)); } catch (RejectedExecutionException e) { rejected = true; }
        check("task 6 (core + queue + 1) is rejected", rejected && pool.rejectedCount() == 1);
        check("largestPoolSize never exceeds 2", pool.getLargestPoolSize() == 2);
        release.countDown();
        pool.shutdown();
        check("shutdown() lets queued work finish", pool.awaitTermination(2, TimeUnit.SECONDS) && pool.getCompletedTaskCount() == 5);

        // names
        DeskPool named = new DeskPool("agent", 3, 20, DeskPool.Overload.ABORT);
        Set<String> names = java.util.Collections.synchronizedSet(new HashSet<>());
        CountDownLatch all = new CountDownLatch(3);
        for (int i = 0; i < 3; i++) named.execute(() -> { names.add(Thread.currentThread().getName()); all.countDown(); try { all.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } });
        all.await();
        check("threads are named agent-1..agent-3", names.equals(Set.of("agent-1", "agent-2", "agent-3")));
        named.shutdown();

        // submit exceptions
        DeskPool p2 = new DeskPool("agent", 1, 5, DeskPool.Overload.ABORT);
        Future<String> bad = p2.submit(() -> new Ticket(7, "ada", 1, true).resolve());
        try { bad.get(); check("get() rethrows", false); }
        catch (ExecutionException e) { check("get() wraps the task's exception in ExecutionException", e.getCause() instanceof IllegalStateException); }
        p2.shutdown();
        p2.awaitTermination(2, TimeUnit.SECONDS);
        check("afterExecute counted the swallowed failure", p2.failedCount() == 1 && p2.okCount() == 0);

        // caller runs
        DeskPool cr = new DeskPool("agent", 1, 1, DeskPool.Overload.CALLER_RUNS);
        CountDownLatch hold = new CountDownLatch(1);
        cr.execute(blocked(hold));          // takes the thread
        cr.execute(blocked(hold));          // takes the queue slot
        AtomicReference<String> ranOn = new AtomicReference<>();
        cr.execute(() -> ranOn.set(Thread.currentThread().getName()));   // overload: runs right here
        check("CALLER_RUNS runs the overflow task on the submitting thread", "main".equals(ranOn.get()) && cr.rejectedCount() == 1);
        hold.countDown();
        cr.shutdown();
        cr.awaitTermination(2, TimeUnit.SECONDS);

        // after shutdown
        DeskPool closed = new DeskPool("agent", 1, 1, DeskPool.Overload.ABORT);
        closed.shutdown();
        try { closed.execute(() -> { }); check("closed desk rejects", false); }
        catch (RejectedExecutionException e) { check("a shut-down pool rejects new tasks", e.getMessage().contains("closed")); }

        // shutdownNow
        DeskPool now = new DeskPool("agent", 1, 5, DeskPool.Overload.ABORT);
        CountDownLatch never = new CountDownLatch(1);
        for (int i = 0; i < 4; i++) now.execute(blocked(never));
        TimeUnit.MILLISECONDS.sleep(100);
        List<Runnable> pending = now.shutdownNow();
        check("shutdownNow() returns the 3 tasks that never started", pending.size() == 3);
        check("shutdownNow() interrupts the running one and the pool terminates", now.awaitTermination(2, TimeUnit.SECONDS));

        // a pool of 4 beats a pool of 1 on waiting work
        long one = time(1), four = time(4);
        check("16 x 100 ms tickets: 1 thread >= 1.5 s, 4 threads < 0.8 s (" + one + " ms vs " + four + " ms)", one >= 1500 && four < 800);

        // bad arguments
        try { new DeskPool("agent", 0, 5, DeskPool.Overload.ABORT); check("0 threads rejected", false); }
        catch (IllegalArgumentException e) { check("0 threads is an IllegalArgumentException", true); }
        try { new DeskPool("agent", 2, 0, DeskPool.Overload.ABORT); check("queue of 0 rejected", false); }
        catch (IllegalArgumentException e) { check("queue size 0 is an IllegalArgumentException", true); }

        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }

    static long time(int threads) throws Exception {
        DeskPool p = new DeskPool("agent", threads, 100, DeskPool.Overload.ABORT);
        long start = System.nanoTime();
        List<Future<String>> fs = new ArrayList<>();
        for (int i = 0; i < 16; i++) fs.add(p.submit(new Ticket(i, "c", 100)::resolve));
        for (Future<String> f : fs) f.get();
        long ms = (System.nanoTime() - start) / 1_000_000;
        p.shutdown();
        return ms;
    }
}

```

```bash
gradle --offline -q run -Pmain=com.example.desk.Checks

```

```log
PASS  first 2 tasks start 2 threads
PASS  next 3 tasks wait in the queue; pool stays at 2
PASS  task 6 (core + queue + 1) is rejected
PASS  largestPoolSize never exceeds 2
PASS  shutdown() lets queued work finish
PASS  threads are named agent-1..agent-3
PASS  get() wraps the task's exception in ExecutionException
  [agent-1] FAILED: ticket 7 from ada could not be resolved
PASS  afterExecute counted the swallowed failure
PASS  CALLER_RUNS runs the overflow task on the submitting thread
PASS  a shut-down pool rejects new tasks
PASS  shutdownNow() returns the 3 tasks that never started
PASS  shutdownNow() interrupts the running one and the pool terminates
PASS  16 x 100 ms tickets: 1 thread >= 1.5 s, 4 threads < 0.8 s (1625 ms vs 404 ms)
PASS  0 threads is an IllegalArgumentException
PASS  queue size 0 is an IllegalArgumentException
15 passed, 0 failed

```

The first four checks are the ones I'd keep if I could only keep four. They pin down that tasks 1 and 2 get threads, tasks 3 to 5 queue, and task 6 (core + queue + 1) is rejected. If somebody later "improves" the pool by changing the queue type, those checks fail the same day. The last timing check uses wide margins (1,607 ms against 401 ms, with limits of 1,500 and 800) so it doesn't flake on a busy machine.

In JUnit 5 each `check(...)` becomes an `@Test` with an AssertJ assertion; the [JUnit 5 tutorial](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) is the next stop for that move.

## Why the queue decides how big the pool gets

`ThreadPoolExecutor` makes the same decisions in the same order for every task you hand it. If fewer than the core number of threads exist, it starts a new one. Otherwise it tries to put the task in the queue. Only if the queue refuses does it start a thread beyond core, up to the maximum. Only if that fails does it reject.

The queue sits between core and maximum. A queue that never says no, like the `LinkedBlockingQueue` inside `Executors.newFixedThreadPool`, means threads past core never appear, so a larger maximum is dead configuration. That's why `DeskPool` sets core equal to maximum and puts a hard limit on the queue: the sizes mean exactly what they say.

The unbounded queue deserves a look, because it's the default in the factory methods. `Traps.java` shows it, along with three other mistakes:

`src/main/java/com/example/desk/Traps.java`:

```java
package com.example.desk;

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/** The mistakes I see most. Each block prints what really happens. */
public class Traps {
    public static void main(String[] args) throws Exception {
        // 1. Executors.newFixedThreadPool: the queue has no limit
        ThreadPoolExecutor fixed = (ThreadPoolExecutor) Executors.newFixedThreadPool(2);
        for (int i = 0; i < 100_000; i++) {
            fixed.execute(() -> { try { Thread.sleep(50); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } });
        }
        System.out.println("1. fixed pool of 2, 100,000 tickets submitted, none rejected: queued=" + fixed.getQueue().size());
        fixed.shutdownNow();

        // 2. submit() hides the exception until somebody calls get()
        ExecutorService pool = Executors.newFixedThreadPool(1);
        Future<?> ignored = pool.submit(() -> { throw new IllegalStateException("boom from submit"); });
        TimeUnit.MILLISECONDS.sleep(200);
        System.out.println("2. submit(): task threw, nothing printed above this line. isDone=" + ignored.isDone());

        // 3. execute() lets the exception kill the thread; the pool quietly makes a new one
        ThreadPoolExecutor one = (ThreadPoolExecutor) Executors.newFixedThreadPool(1, new NamedThreadFactory("agent"));
        one.execute(() -> System.out.println("3. before: " + Thread.currentThread().getName()));
        one.execute(() -> { throw new IllegalStateException("boom from execute"); });
        TimeUnit.MILLISECONDS.sleep(200);
        one.execute(() -> System.out.println("3. after:  " + Thread.currentThread().getName()));
        TimeUnit.MILLISECONDS.sleep(200);

        // 4. shutdownNow returns what it never started, and interrupts what it did
        DeskPool desk = new DeskPool("agent", 2, 10, DeskPool.Overload.ABORT);
        for (int i = 1; i <= 8; i++) desk.execute(() -> { try { new Ticket(0, "x", 1_000).resolve(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } });
        TimeUnit.MILLISECONDS.sleep(100);
        List<Runnable> neverStarted = desk.shutdownNow();
        System.out.println("4. shutdownNow(): never started=" + neverStarted.size() + ", terminated within 1s=" + desk.awaitTermination(1, TimeUnit.SECONDS));

        pool.shutdown();
        one.shutdown();
    }
}

```

```bash
gradle --offline -q run -Pmain=com.example.desk.Traps

```

```log
1. fixed pool of 2, 100,000 tickets submitted, none rejected: queued=99998
2. submit(): task threw, nothing printed above this line. isDone=true
3. before: agent-1
Exception in thread "agent-1" java.lang.IllegalStateException: boom from execute
	at com.example.desk.Traps.lambda$main$3(Traps.java:30)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1583)
3. after:  agent-2
4. shutdownNow(): never started=6, terminated within 1s=true

```

Reading the four results:

1. A fixed pool of two threads accepted 100,000 tickets without complaint. 99,998 are sitting in memory. Each of those is a real object, and in a service they'd be requests with payloads. Nobody is told anything until the heap is full.
2. The `submit()` task threw, and nothing was printed. `isDone` is `true` because it *finished*, with an exception stored inside the `Future`. If nobody calls `get()`, nobody ever knows.
3. With `execute()` the exception does surface, on stderr, and then the thread dies. The pool starts a replacement: the thread that ran "before" was `agent-1` and the one that ran "after" was `agent-2`. The pool quietly kept working. Your thread names changed and your error went to a default handler.
4. `shutdownNow()` returned six tasks that had never started, and interrupted the two running ones. That list is how you'd re-queue or log the lost work.

Related reading: [ExecutorService: submit, invokeAll and invokeAny](https://ggorantala.dev/java-executorservice/?ref=ggorantala.dev) covers the factory methods in depth, and [shutting down executors](https://ggorantala.dev/java-executor-shutdown/?ref=ggorantala.dev) covers `shutdown` versus `shutdownNow`.

## If it hangs, swallows errors or rejects too much

**The program finishes `main` and never exits.** I forced it:

```java
ExecutorService p = Executors.newFixedThreadPool(2);
p.submit(() -> System.out.println("ticket done"));
System.out.println("main finished");

```

```log
main finished
ticket done
rc=124

```

Both lines print, and then the JVM sits there until my 5-second `timeout` killed it (exit code 124). Cause: pool threads are non-daemon, so they keep the JVM alive. Fix: call `shutdown()` when you're done, or on Java 19+ use try-with-resources, as `Sizing.java` does, because `close()` waits for the work to finish.

**A task failed and nothing in the logs says so.** Cause: `submit()` swallowed it. Fix: call `get()` on the future, or use `afterExecute` as in `DeskPool`, or use `execute()` and set an uncaught-exception handler on the thread factory. Pick one place and keep it consistent.

**`RejectedExecutionException: desk is full: 4 busy, 6 waiting`.** That's the pool doing its job. Cause: arrival rate is higher than the pool can absorb. Fix, in order of preference: slow the producer, raise the queue only if you've worked out the memory cost, add threads only if the work waits rather than computes (see the sizing table). Don't "fix" it with an unbounded queue.

**Threads all called the same thing.** Cause: `new AtomicInteger()` inside `newThread`. Fix: make it a field, as in Step 2.

**`CALLER_RUNS` made my web endpoint slow.** Cause: it works by running the overflow task on the thread that submitted it, which here would be your request thread. That's back-pressure, and it's only acceptable where the caller is allowed to be slowed down. Where it isn't, use `ABORT` and return an error.

**Tasks silently dropped after shutdown.** A shut-down pool rejects new tasks with a `RejectedExecutionException`, which my handler words as "desk is closed". If you see that during a deploy, requests are still arriving after you started closing the pool; stop the intake first.

## Before this serves real traffic

- **Size from measurements.** Use the table above as a way of thinking, then load-test with your own work. [How to size a Java thread pool](https://ggorantala.dev/java-thread-pool-sizing/?ref=ggorantala.dev) goes deeper.
- **Export the numbers.** Pool size, active count, queue depth and rejections are the four to graph and alert on. `DeskPool.snapshot()` shows the getters; Micrometer can bind an executor for you, though I didn't try it for this article.
- **Shut down with a deadline.** Call `shutdown()`, wait with `awaitTermination`, and only then call `shutdownNow()`. Plan for lost work and log what `shutdownNow()` returns.
- **Don't share one pool between unrelated work.** If slow report generation and fast logins share four threads, the reports will starve the logins. Separate pools cost a few thread names and buy you isolation.
- **Don't block a pool thread waiting on another task in the same pool.** With every thread waiting, nothing is left to run what they wait for. That's the pool-starvation deadlock.
- **Virtual threads** (Java 21) change the sizing story for waiting work: `Executors.newVirtualThreadPerTaskExecutor()` gives each task its own cheap thread, so you limit concurrency with a semaphore instead of a pool size. I didn't run that variant here. Read [virtual thread pinning](https://ggorantala.dev/java-virtual-thread-pinning-synchronized-jep-491/?ref=ggorantala.dev) before you rely on it.
- **Next up:** for results that depend on each other, [CompletableFuture](https://ggorantala.dev/completablefuture-for-async-programming/?ref=ggorantala.dev); for work on a timer, [ScheduledExecutorService](https://ggorantala.dev/how-to-use-scheduledexecutorservice-for-periodic-tasks-in-java/?ref=ggorantala.dev); for a hand-rolled producer and consumer, [BlockingQueue](https://ggorantala.dev/how-to-use-blockingqueue-producer-consumer-java/?ref=ggorantala.dev).

## What to remember

- Decide the five things yourself: threads, queue size, overload policy, thread names, shutdown.
- Make the queue bounded, and set core equal to maximum so the numbers mean what they say.
- Count rejections and failures; `submit()` hides the second one.
- Computing work needs about one thread per core. Waiting work can use many more.
- A pool that nobody shuts down keeps the JVM alive.

## Thread pool questions people ask

**What is the difference between execute() and submit()?** `execute()` takes a `Runnable`, returns nothing, and lets an exception reach the thread's uncaught-exception handler (which kills that thread, and the pool starts a replacement). `submit()` takes a `Runnable` or `Callable`, returns a `Future`, and stores any exception inside it until you call `get()`.

**How many threads should a Java thread pool have?** For CPU-bound work, about the number of cores (in my run, two threads gave the full speed-up on two cores and more threads gave nothing). For work that waits on I/O, more, found by measuring. Start with the table in this article, not a number from a blog.

**Why is my ThreadPoolExecutor not creating more than the core number of threads?** Because the queue isn't full. Extra threads only start when the queue refuses a task, and an unbounded queue never refuses.

**Do I need to call shutdown() on an ExecutorService?** Yes. Pool threads aren't daemon threads, so without `shutdown()` the JVM doesn't exit (my test hung until it was killed). On Java 19 and later, try-with-resources calls `close()` for you.

**Is Executors.newFixedThreadPool safe for production?** The threads are fine. The unbounded queue isn't: my test queued 99,998 tickets behind two threads without any error. Build a `ThreadPoolExecutor` with a bounded queue, as `DeskPool` does.