How do you size a thread pool, and what does an unbounded queue cost you?

Asked constantlysenior2–12 yrs12 min readJava 5 (1.5)Java 8LTSJava 19

A ThreadPoolExecutor only creates a thread beyond corePoolSize when the queue is FULL — so with the unbounded queue Executors.newFixedThreadPool gives you, maximumPoolSize is never reached and the rejection policy never fires. The pool does not shed load, it accumulates it, until the heap runs out. Bounding the queue is what turns a pool into something that can say no.

The Answer

  • A ThreadPoolExecutor has three dials — corePoolSize, maximumPoolSize, and the queue — and they interact in an order almost nobody guesses.
  • The rule: fill core threads, then queue, and only create threads up to maximumPoolSize when the queue is full. Reject once both are full.
  • So with an unbounded queue, maximumPoolSize is dead configuration. It is never reached, and the rejection policy never fires.
  • Executors.newFixedThreadPool(n) gives you exactly that — a LinkedBlockingQueue with room for Integer.MAX_VALUE tasks.
  • The consequence is not a slow service, it is a memory leak with a queue in front of it. Latency climbs, the heap fills, and nothing sheds load.
  • newCachedThreadPool() fails the other way: maximumPoolSize is Integer.MAX_VALUE, so a burst creates a thread per task.
  • Size it from what the tasks do: CPU-bound ≈ cores + 1; I/O-bound is higher and should be derived from measured wait time, not guessed.

Understand It

What the factory methods actually build

Executors is a convenience class, and every one of its shortcuts hides the dial that matters:

Compiled and run on this buildEdit and run
System.out.println(describePool("newFixedThreadPool(2)", Executors.newFixedThreadPool(2)));
System.out.println(describePool("newCachedThreadPool()", Executors.newCachedThreadPool()));
System.out.println(describePool("newSingleThreadExecutor", Executors.newSingleThreadExecutor()));
Output
newFixedThreadPool(2)    core=2  max=2           queue=LinkedBlockingQueue (unbounded)
newCachedThreadPool()    core=0  max=unbounded   queue=SynchronousQueue (holds 0)
newSingleThreadExecutor  wrapped — not a ThreadPoolExecutor, so it cannot be reconfigured

Two different ways to have no limit. newFixedThreadPool bounds the threads and leaves the queue unbounded; newCachedThreadPool bounds the queue at zero and leaves the threads unbounded. Neither can refuse work, and refusing work is the only thing that keeps a service up when it is overloaded.

The third line is a different lesson and worth a moment. newSingleThreadExecutor() deliberately returns a wrapper rather than the pool itself, so the cast fails and the pool cannot be reconfigured later. newFixedThreadPool(1) looks identical from the outside and is not wrapped, so this works:

ThreadPoolExecutor pool = (ThreadPoolExecutor) Executors.newFixedThreadPool(1);
pool.setMaximumPoolSize(5);     // order matters — core may never exceed max,
pool.setCorePoolSize(5);        //   so raising core first throws IllegalArgumentException
// no longer single-threaded, and every caller relying on that is now wrong

Two factory methods, the same apparent behaviour, opposite intent: one lets you take the guarantee away and one does not. If a caller depends on "exactly one thread" for ordering or for single-threaded confinement, that wrapper is the thing protecting them.

This is why Effective Java says to prefer ThreadPoolExecutor directly. Not because the factories are wrong, but because they make the decision for you and then hide it.

The growth rule, and why maximumPoolSize is usually a lie

Here is the ordering, and it is the single most useful thing on this page:

What execute() does with a task
Task submittedFewer than corePoolSizethreads? Start a new one.this is the only easy caseOtherwise try to QUEUE itnot "start another thread" — queuefirstQueue accepted it? Done.with an unbounded queue, it alwaysdoesQueue FULL — now grow tomaximumPoolSizeonly reachable if the queue canfillAt maximumPoolSize too?Reject.

The emphasised steps are where the intuition breaks. Most people read new ThreadPoolExecutor(1, 10, …) as "between one and ten threads, as load demands". It means "one thread, until the queue is full, then up to ten". If the queue never fills, the pool never grows.

Watch it happen. Fifty tasks, core 1, maximum 10, unbounded queue:

Compiled and run on this buildEdit and run
ThreadPoolExecutor unbounded = new ThreadPoolExecutor(
        1, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
System.out.println("unbounded queue : " + saturate(unbounded, 50));

ThreadPoolExecutor bounded = new ThreadPoolExecutor(
        1, 10, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(5));
System.out.println("queue of 5      : " + saturate(bounded, 50));
Output
unbounded queue : threads=1   queued=49  rejected=0
queue of 5      : threads=10  queued=5   rejected=35

Same core, same maximum, same fifty tasks. The only difference is the queue, and it decides everything: one thread or ten, and nothing rejected or thirty-five rejected.

The first line is what a newFixedThreadPool does all day. The second is a pool that can actually say no.

"Rejected" is not the failure. It is the feature.

Thirty-five rejections looks like the worse outcome, which is exactly the instinct that produces the first line in production.

Think about what the two rows mean at 3am. The bounded pool refused 35 tasks immediately, at the point where a caller can still do something about it: retry elsewhere, fail the request fast, return a 503 the load balancer understands. The unbounded pool accepted all fifty and now owes fifty answers it cannot deliver. Every queued task holds its arguments alive on the heap, and the queue is a GC root by way of the pool.

So the queue has a second meaning nobody writes down: queue depth is latency you have already promised. A thousand queued tasks on a pool that completes ten a second is a hundred seconds of debt, and the clients waiting on them have almost certainly timed out already — so the pool is doing work whose results nobody will read, while new work waits behind it.

That is the argument for a small queue, and it is the same reason connection-pool-sizing argues for a small pool.

submit() swallows exceptions and execute() does not

The second thing that bites, and it is silent:

Compiled and run on this buildEdit and run
ExecutorService pool = Executors.newFixedThreadPool(1);

Future<?> submitted = pool.submit(() -> {
    throw new IllegalStateException("the task failed");
});

Thread.sleep(200);
System.out.println("after submit(), the stack trace printed : no");

try {
    submitted.get();
} catch (ExecutionException e) {
    System.out.println("and Future.get() finally reveals it   : " + e.getCause());
}

pool.shutdown();
Output
after submit(), the stack trace printed : no
and Future.get() finally reveals it   : java.lang.IllegalStateException: the task failed

submit() wraps the task in a FutureTask, which catches everything and stores it in the Future. If you never call get() — and for a fire-and-forget task you never do — the exception is gone. No log, no stack trace, no uncaught-exception handler. The task simply did not happen.

execute() has no Future to hide it in, so the exception reaches the thread's uncaught-exception handler and gets printed.

The practical rule: use execute() for fire-and-forget, submit() only when you will actually consume the Future. If you must use submit() for a task nothing waits on, wrap the body in a try/catch that logs — nothing else will.

Sizing, without the folklore

The formula people quote — threads = cores × (1 + wait/compute) — is real, from Java Concurrency in Practice, and useless without measuring wait and compute. What the formula is actually telling you is that there are only two kinds of task and they want opposite things:

CPU-boundI/O-bound
Wantsroughly cores + 1many more than cores
Whyextra threads only add context switchesthreads are parked, not working
Bigger poolslowerfaster, up to the downstream limit
The real ceilingyour CPUwhatever you are waiting on

The last row is the one that matters and the one sizing discussions skip. If eighty threads all call the same database with a connection pool of ten, the answer is not eighty threads — seventy of them are queued on the connection pool instead of on yours, and you have moved the queue rather than removed it. Size the pool to the narrowest downstream limit, and put the queue where you can see it.

Runtime.getRuntime().availableProcessors() is the starting point for the CPU case, with one caveat: inside a container it reports the cgroup CPU limit, not the host's cores. That is correct behaviour and has been since Java 10, but it means the number changes when someone edits a Kubernetes manifest.

Java 19+For I/O-bound work the question is going away. Executors.newVirtualThreadPerTaskExecutor() is not a pool — it creates a virtual thread per task, and a parked virtual thread costs a few hundred bytes of heap rather than a megabyte of stack. Pool sizing exists to ration platform threads; where the tasks block rather than compute, virtual threads remove the thing being rationed. It does not help CPU-bound work, where the ceiling is the CPU and always was.

Reference

A pool you would actually ship

// Named threads, a bounded queue, and a rejection policy that is a decision
// rather than a default.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
        8, 8,                                   // core == max: a fixed pool,
        0L, TimeUnit.MILLISECONDS,              //   so keep-alive is moot
        new ArrayBlockingQueue<>(100),          // the number that decides everything
        new ThreadFactory() {                   // names show up in every thread dump
            private final AtomicInteger n = new AtomicInteger(1);
            public Thread newThread(Runnable r) {
                Thread t = new Thread(r, "orders-" + n.getAndIncrement());
                t.setUncaughtExceptionHandler((thread, e) ->
                        log.error("uncaught in {}", thread.getName(), e));
                return t;
            }
        },
        new ThreadPoolExecutor.CallerRunsPolicy());

// Shutdown, in the order that actually works.
pool.shutdown();                                 // stop accepting, finish what is queued
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
    pool.shutdownNow();                          // interrupt the stragglers
    pool.awaitTermination(10, TimeUnit.SECONDS); // and give them a moment
}

Naming the threads is the cheapest operational win available. pool-1-thread-7 in a thread dump tells you nothing; orders-7 tells you which subsystem is stuck.

The four rejection policies

PolicyOn rejectionUse when
AbortPolicy (default)throws RejectedExecutionExceptionthe caller can handle failure — usually right
CallerRunsPolicyruns it on the calling threadyou want backpressure: the submitter slows down because it is now doing the work
DiscardPolicydrops it silentlynever, unless the task is genuinely optional and you have said so in a comment
DiscardOldestPolicydrops the oldest queued taska "latest value wins" feed, and almost nothing else

CallerRunsPolicy is the underrated one. It turns rejection into throttling: the thread that submitted the work gets conscripted into doing it, so the producer cannot outrun the pool. For a request thread accepting uploads, that is precisely the behaviour you want.

Choosing an executor

// Fixed work, CPU-bound.
Executors.newFixedThreadPool(cores + 1)            // fine, but see the queue caveat

// Anything user-facing where overload is possible: build it yourself.
new ThreadPoolExecutor(...)                        // so the queue is a decision

// Scheduled work. Note it is core-sized only and the queue is unbounded.
Executors.newScheduledThreadPool(2)

// Blocking I/O, Java 21+. Not a pool; no sizing question.
Executors.newVirtualThreadPerTaskExecutor()

// Divide-and-conquer over a data structure. Work stealing, not a queue.
new ForkJoinPool(parallelism)

What to watch in production

// ThreadPoolExecutor exposes everything worth alerting on. Export these.
pool.getActiveCount();        // threads currently running a task
pool.getQueue().size();       // THE metric — queue depth is promised latency
pool.getPoolSize();           // grows only when the queue fills
pool.getCompletedTaskCount();
pool.getLargestPoolSize();    // did it ever reach maximumPoolSize? often: no

Alert on queue depth, not on pool size. Pool size is a lagging indicator that with an unbounded queue never moves at all.

Scenarios

A service gets slower under load and then OOMs, with no thread count change. The classic shape of an unbounded queue: getPoolSize() sits flat at the core size while getQueue().size() climbs into the hundreds of thousands, each queued task holding its request object alive. The heap dump shows a huge LinkedBlockingQueue. The fix is a bounded queue and a rejection policy, and the uncomfortable part of that fix is that the service will now visibly reject requests it previously accepted and quietly failed to answer in time.

Someone proposes raising maximumPoolSize because the pool seems stuck at its core size. It will change nothing, and knowing why is the whole entry: threads beyond the core are only created when the queue is full, and the queue is unbounded. Raising the maximum is a no-op until the queue is bounded. Bound the queue first; then the maximum starts meaning something, and you can decide whether you wanted it.

Tasks submitted with submit() are vanishing. No errors, no logs, work simply not done. FutureTask caught the exception and put it in a Future nobody reads. Switch to execute() for fire-and-forget, or keep submit() and wrap the body in try/catch. This one is worth a lint rule: an ignored return value from submit() is nearly always a bug, and it is silent in exactly the cases you care about.

A pooled task submits to the same pool and waits. A fixed pool of eight, where each task submits a subtask to that pool and calls get(). Under load, all eight threads are blocked waiting on subtasks that can never start, because every thread is occupied waiting. This is thread-pool deadlock, and no pool size fixes it — eight becomes eighty and it deadlocks at eighty. Use a separate pool for the nested stage, or compose without blocking via CompletableFuture. See deadlock.

Interviewer's Next Move

1. "What is wrong with Executors.newFixedThreadPool?" Its queue is a LinkedBlockingQueue with capacity Integer.MAX_VALUE. It can never reject, so under sustained overload it accumulates tasks until the heap is gone — and because threads beyond the core are only created when the queue fills, maximumPoolSize is unreachable too. Build a ThreadPoolExecutor with a bounded queue instead.

2. "In what order does ThreadPoolExecutor use its three settings?" Core threads first, then the queue, then growth up to maximumPoolSize, then rejection. The step people get wrong is the second: it queues before creating threads beyond the core, so an unbounded queue means the pool never grows and the rejection handler never runs.

3. "What is the difference between submit() and execute()?" execute() takes a Runnable and lets exceptions reach the thread's uncaught handler. submit() returns a Future and stores the exception inside it, so a task that throws is silent unless someone calls get(). Use execute() for fire-and-forget; if you use submit(), consume the Future or catch inside the task.

4. "How would you size a pool for an endpoint that calls two services and a database?" By measuring, and by finding the narrowest downstream limit first. The work is I/O-bound so it wants more threads than cores, but if the database connection pool is ten then more than about ten concurrent database calls just moves the queue somewhere I cannot see. I would start from the connection pool size, measure wait versus compute, and alert on queue depth rather than tune the number once and forget it.

5. "When would you choose CallerRunsPolicy?" When I want backpressure rather than failure. It makes the submitting thread run the rejected task, so the producer is slowed by exactly the amount the pool is behind — which for a request thread reading uploads is self-limiting in the right direction. It is a poor choice when the calling thread must not block, such as an event-loop or a scheduler thread.

6. "Do virtual threads make this obsolete?" For blocking I/O, largely yes — newVirtualThreadPerTaskExecutor() is not a pool, so there is no size to choose, and a parked virtual thread costs heap rather than a megabyte of stack. For CPU-bound work, no: the ceiling was the CPU, not the thread, and a million virtual threads does not add cores. You also still need to bound admission somewhere, or you have moved the unbounded queue rather than removed it.

Code traps

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        2, 50, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());

for (int i = 0; i < 10_000; i++) {
    pool.execute(this::slowTask);
}
// How many threads does this pool have?
Answer

Two. Never more, no matter how long the queue gets, because threads beyond corePoolSize are only created when the queue is full and a LinkedBlockingQueue built with no capacity holds Integer.MAX_VALUE tasks. The 50 is decoration. Replace the queue with new ArrayBlockingQueue<>(100) and the same code will use all fifty threads and start rejecting — which is what the author thought they had configured.

ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> riskyCleanup());
pool.shutdown();
Answer

If riskyCleanup() throws, nothing is logged anywhere. submit() captures the exception into a Future that is discarded on the next line, so the failure is unobservable — the cleanup silently did not happen. execute(() -> riskyCleanup()) would at least print it via the uncaught-exception handler. This is the most common real bug on this topic, and it survives review because the code looks tidier than the correct version.

Common wrong answers

Said in interviewsReality
"newFixedThreadPool is the safe default."Its queue is unbounded, so it cannot shed load. It fails by filling the heap.
"maximumPoolSize is how big the pool can grow."Only once the queue is full. With an unbounded queue it is never reached.
"The pool creates a new thread when all threads are busy."It queues first. Threads beyond the core come after the queue is full.
"submit() and execute() are the same."submit() swallows the exception into a Future; execute() lets it reach the uncaught handler.
"Rejection means the pool is misconfigured."Rejection is the pool doing its job. Silent unbounded queueing is the misconfiguration.
"Bigger pool, more throughput."Past the narrowest downstream limit you are only moving the queue somewhere less visible.
"Virtual threads mean you never size anything again."They remove pool sizing for blocking I/O. CPU-bound limits and admission control remain.

Check Yourself

Q1. new ThreadPoolExecutor(1, 10, …, new LinkedBlockingQueue<>()) is given fifty slow tasks. How many threads run, and why?

AnswerOne. A pool only creates threads beyond corePoolSize when the queue is full, and that queue holds Integer.MAX_VALUE. The other 49 tasks queue behind the single thread and maximumPoolSize is never consulted. Bound the queue and the same code uses all ten.

Q2. Why is a rejected task better news than a queued one?

AnswerBecause rejection happens immediately, at the point where the caller can still retry elsewhere, fail fast or return a 503. A queued task is latency you have already promised — by the time the pool reaches it the client has usually timed out, so the work is done for nobody while new work waits behind it.

Q3. A fire-and-forget task throws and nothing appears in the logs. What is the likely cause?

AnswerIt was handed to submit() rather than execute(). submit() wraps the task in a FutureTask, which catches everything and stores it in the Future; with nobody calling get(), the exception is discarded. Use execute(), or catch and log inside the task.


Practice

TierExerciseTime
Warm-upCount the threads5 min
ChallengeGive the pool a voice20 min
ProductionThe queue that ate the heap45 min
InterviewFull round replay — thread pools10 min

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 5 (1.5)

    ExecutorService and ThreadPoolExecutor arrive, separating what work is from which thread runs it.

    Before Java 5 (1.5): You wrote `new Thread(task).start()` and managed lifetime, naming and limits by hand — so nothing bounded how many threads a burst of traffic could create.

  2. Java 8LTS

    CompletableFuture makes the result of a submitted task composable, which is what finally made submit() worth preferring over execute() for anything with a result.

    Before Java 8: Future.get() blocked, so composing two async results meant blocking a pooled thread to wait for another pooled thread — the classic way to deadlock a fixed pool.

  3. Java 19

    Executors.newVirtualThreadPerTaskExecutor() — not a pool at all. It creates a virtual thread per task, so pool sizing stops being the question for blocking I/O.

    Before Java 19: Every blocking call held a platform thread, so the pool size had to be tuned against how long tasks blocked rather than how much CPU they used.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

Questions that lead here

  • What does a circuit breaker actually do?

    It stops calling a dependency that is clearly failing, so the caller fails fast instead of queueing behind a timeout. Three states — and the half-open one is the whole design, because it is how the breaker finds out the dependency recovered without letting all the traffic back in at once.

    Asked constantlyintermediate2–12 yrs11 min readResilience
  • How does ConcurrentHashMap achieve thread safety?

    Since Java 8 there are no segments. It locks one bin at a time — a CAS to install the first node in an empty bin, and synchronized on the bin's head node otherwise — while reads never lock at all. The catch is that per-key atomicity does not make your sequence of calls atomic.

    Asked constantlyintermediate2–12 yrs12 min readConcurrency
  • What does volatile guarantee, and what does it not?

    volatile guarantees visibility and ordering: a write is seen by any later read, and operations are not reordered across it. It never guarantees atomicity, so volatile count++ still loses updates — it is three operations, and volatile makes each of them visible without making the trio indivisible.

    Asked constantlyintermediate1–12 yrs12 min readConcurrency
  • What is the happens-before relationship?

    A guarantee, not a statement about time: if A happens-before B, everything A wrote is visible to B. Without an edge between two threads there is no guarantee at all, whatever the clock says. The practical payoff is the opposite of what people expect — most fields crossing an existing edge need no volatile.

    Asked constantlyintermediate2–12 yrs12 min readConcurrency
  • When is parallelStream() a mistake?

    It runs on one ForkJoinPool shared by the whole JVM, so a slow task in one place starves parallel work everywhere else — including anything a library does. Add the usual problems of shared mutable state and non-associative reductions, and the honest default is not to use it until you have measured.

    Asked oftenintermediate2–12 yrs11 min readJava8

Know someone preparing for this?

Every example on this page was compiled and run before it was published.

https://codedepth.pages.dev/java/concurrency/executor-service

Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.