A dependency slows down, calls keep arriving, and somewhere in an old commit someone wired up an ExecutorService with an in-memory queue to absorb the overflow. Nobody explicitly decided how many items that queue can hold. It just accepts. It keeps accepting for as long as the JVM can find room for one more linked-list node. The actual size limit isn't set by any team decision — it's set by the garbage collector, on the day it can no longer find space.
This post walks through the mechanism with plain JDK: java.util.concurrent, no resilience library. Every number below came from a test run on this host with Java 25.0.4 (Temurin), Maven 3.9.12, JUnit Jupiter 6.1.3 via junit-bom, maven-surefire-plugin 3.5.6, maven-compiler-plugin 3.16.0, and maven.compiler.release=25. The JUnit User Guide 6.1.3 requires Java 17 or higher at runtime — 25 is well inside that range, so there's no upgrade path to document here.
The unbounded queue accepts everything — that's the problem
The "bad" control in this experiment is a ThreadPoolExecutor built with a bare new LinkedBlockingQueue<>():
public static ThreadPoolExecutor unboundedLinked(int poolSize) {
return new ThreadPoolExecutor(
poolSize,
poolSize,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>());
}
The LinkedBlockingQueue javadoc doesn't hide anything: it's an "optionally-bounded" queue, and the capacity, if unspecified, equals Integer.MAX_VALUE. The no-arg constructor builds the queue with exactly that capacity. Nodes get created dynamically on every insertion, unless that would push the queue past capacity — and Integer.MAX_VALUE is a capacity that, in practice, never gets exceeded before memory runs out first.
The ThreadPoolExecutor javadoc spells out exactly this behavior in its section on queuing: using an unbounded queue makes new tasks wait in line once all corePoolSize threads are busy — no thread beyond corePoolSize ever gets created, and maximumPoolSize stops mattering at all. The text openly admits the possibility of unbounded work-queue growth when commands keep arriving, on average, faster than they can be processed.
The test proves this with a counter, not a heap dump. With a pool of size 1 and one worker parked on a CountDownLatch, 16 submissions via execute were all accepted:
accepted=16 rejected=0 queueSize=15 remainingCapacity=2147483632
2147483647 − 15 = 2147483632: the 15 queued tasks are sitting on top of an Integer.MAX_VALUE capacity, not some small number someone actually chose. One of the 16 tasks is the one the worker already picked up, so it isn't in the queue.
The same thing happens with Executors.newFixedThreadPool(poolSize). Its public javadoc says the method builds a pool that reuses "a shared unbounded queue," and that extra tasks will wait in the queue until a thread frees up. It never names LinkedBlockingQueue anywhere in the text. Anyone who wants to know the concrete class behind it has to go look at the implementation — on this Temurin 25.0.4 build, it's the same capacity-less LinkedBlockingQueue, confirmed both in the JDK source and in the test:
accepted=16 rejected=0 queueClass=java.util.concurrent.LinkedBlockingQueue queueSize=15 remainingCapacity=2147483632
Same behavior, same class, same absurd remainingCapacity. newFixedThreadPool isn't a safer alternative to a hand-rolled LinkedBlockingQueue — it's the same mechanism wearing a friendlier factory name. The queue nobody sized is still unsized; it's just hiding behind a name that sounds like someone thought it through.
ArrayBlockingQueue + RejectedExecutionHandler: someone has to say no
The core swap is simple: replace the unbounded queue with a fixed-capacity one, and give the executor an explicit answer for what happens when it fills up.
public static ThreadPoolExecutor bounded(
int poolSize,
int queueCapacity,
RejectedExecutionHandler handler) {
return new ThreadPoolExecutor(
poolSize,
poolSize,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(queueCapacity),
handler);
}
ArrayBlockingQueue is documented as a bounded blocking queue backed by an array — a textbook bounded buffer. Once built, the capacity doesn't move. That's already the attitude shift versus LinkedBlockingQueue: here, someone picked the number, and the number stays put.
One detail trips people up here. The ArrayBlockingQueue javadoc describes put as the operation that inserts an element, waiting for space to open up if the queue is full — it blocks the caller. But ThreadPoolExecutor never calls put. It calls offer, which inserts at the tail if it can do so immediately without exceeding capacity, returning true on success and false if the queue is full. On a full queue, offer returns false right away — no blocking — and that false is exactly what triggers the RejectedExecutionHandler.
The ThreadPoolExecutor queuing rule, for a fixed pool (core equals maximum): with fewer than corePoolSize threads running, it prefers spinning up a new thread; with core or more running, it prefers the queue; if the task can't be queued and there's no room to grow up to maximumPoolSize, it gets rejected. With poolSize == maximumPoolSize, there's no intermediate "spin up one more thread" step — a full pool plus a full queue is a direct rejection.
AbortPolicy vs. CallerRunsPolicy: two answers to the same "no room"
Saying "reject" isn't a complete design decision by itself. ThreadPoolExecutor documents several rejection strategies, and the two most common ones behave in opposite ways.
ThreadPoolExecutor.AbortPolicy is the default handler. The javadoc doesn't waste words: it throws a RejectedExecutionException. In the test, with a pool of 2 and a queue capacity of 2, four blocking tasks fill everything up (2 active threads, 2 queued) and the fifth submission blows past the limit:
accepted=4 rejected=1 exception=java.util.concurrent.RejectedExecutionException
The executor's toString, captured inside the exception message itself, reads Running, pool size = 2, active threads = 2, queued tasks = 2, completed tasks = 0. After the throw: queueSize=2 remainingCapacity=0 poolSize=2 handler=java.util.concurrent.ThreadPoolExecutor$AbortPolicy. The exception propagates up to whoever called execute — the fifth task never runs.
ThreadPoolExecutor.CallerRunsPolicy does the opposite: the thread that invoked execute runs the task itself, unless the executor has already been shut down, in which case the task is discarded. The javadoc calls this a simple feedback-control mechanism that slows down the submission rate. In the same scenario — pool 2, queue 2, four blockers already accepted — the fifth task isn't rejected with an exception: it runs right there, on the calling thread.
acceptedBeforeExtra=4 callerThread=main runnerThread=main sameThread=true
callerThread and runnerThread are the same thread name. execute only returns once that extra task has already finished — for a moment, whoever called execute was, functionally, a worker of the pool itself.
That courtesy comes with a price, the kind of detail that only surfaces once someone's already hit it: if the rejected task were waiting on the same CountDownLatch as the blocked workers, CallerRunsPolicy would freeze the caller inside its own execute call, waiting for a signal that only the pool's own workers — now all busy — could give. The policy that exists to slow down the producer ends up strangling the producer. In the test, the extra task only records a thread name and returns — it never touches the latch, so it doesn't reproduce that deadlock; it's a design risk here, not a measured result.
The shutdown path — CallerRunsPolicy discarding the task because the executor was already terminated — wasn't exercised in this experiment; it's documented behavior, not a measured number.
The test that saturates the queue on purpose
Proving saturation without Thread.sleep means controlling exactly when each worker lets go. The way out is a shared CountDownLatch: every "slow" task just waits on it.
private static Runnable blocker(CountDownLatch release) {
return () -> {
try {
release.await();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
};
}
The fill pattern is always the same: submit poolSize + queueCapacity blockers — enough to occupy every thread and every queue slot — then submit one more, which is the one that actually tests rejection:
int accepted = submitBlockers(executor, release, poolSize + queueCapacity);
RejectedExecutionException rejected = assertThrows(
RejectedExecutionException.class,
() -> executor.execute(blocker(release)));
In each test's finally, the latch gets released and the executor is shut down:
private static void release(CountDownLatch release, ExecutorService executor) throws InterruptedException {
release.countDown();
executor.shutdownNow();
executor.awaitTermination(5, TimeUnit.SECONDS);
}
Skip that finally and the pool's threads — non-daemon by default — sit forever waiting on the latch, and the JVM never exits on its own. awaitTermination there is test cleanup, not proof of anything; the proof is the accepted/rejected counter and the queue state, read while the workers are still blocked.
Running mvn -B test against the evidence project, all four tests pass:
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
Process exit 0. unboundedLinkedQueueAcceptsEverySubmittedTask, fixedThreadPoolFactoryAlsoAcceptsEverySubmittedTask, abortPolicySurfacesRejectedExecutionException, and callerRunsPolicyRunsRejectedTaskOnCallerThread — the JUnit User Guide 6.1.3 doesn't change any of this behavior; it just confirms the minimum runtime (Java 17) is covered by the Java 25 used in this experiment.
This is not a circuit breaker
Worth saying plainly: bounding a queue is not a circuit breaker. There's no open/half-open state machine here, no probe call to decide when to start accepting traffic again. What exists is fixed capacity: when a worker finishes, a queue slot frees up, and that's it — no decision about whether the dependency has actually recovered enough to try again.
The Resilience4j bulkhead post covers a neighboring problem: isolating two named dependencies from each other, each with its own semaphore or thread pool, so a slow dependency can't eat the capacity reserved for another. This post bounds a single JDK queue in front of a single slow call, no framework involved — it's the mechanism underneath, for anyone who can't or doesn't want to add Resilience4j, or just wants to understand what the executor factory actually does before wrapping it in a library.
When to bound the queue, and when to reach for another tool
Any time an ExecutorService gets built in front of a dependency that can slow down — a database, an external queue, a synchronous HTTP call — the work queue deserves an explicit decision, not the default from a convenience factory. That applies just as much to Executors.newFixedThreadPool as to any hand-written new LinkedBlockingQueue<>() that nobody sized on purpose.
The concrete fix for each problem diagnosed here:
- Unbounded queue growing without limit: swap in
ArrayBlockingQueuewith an explicit capacity and aRejectedExecutionHandler—AbortPolicywhen the caller needs to know immediately it was rejected and decide what to do next (fallback, retry with backoff at another layer, an error back to the client);CallerRunsPolicywhen the caller can absorb the slowdown directly, and only when the rejected task doesn't depend on a resource the pool's own workers are also competing for — otherwise it's the deadlock described above. - Production observability: the fields
ThreadPoolExecutoralready exposes —getPoolSize(),getActiveCount(),getQueue().size(),getCompletedTaskCount()— are the raw material for any saturation log or metric. There's no need to invent a new counter; there's a need to read what the executor already computes and surface it, even if it starts as a structured log line before it becomes a real metric.
That said, bounding a queue solves one specific problem: fixed in-memory admission capacity. When the goal is something else — stop hammering a dependency that's consistently failing, give it time to recover, or isolate two independent dependencies from each other — the right tool is different. A circuit breaker with an open/half-open state exists to decide when to stop trying. A bulkhead like Resilience4j's exists to keep one slow dependency from eating the capacity another dependency also needs. Bounding a queue with ArrayBlockingQueue decides none of that — it just states, with a fixed number, how much waiting work is acceptable before it says no. Sometimes that's exactly what's missing; sometimes it's only half the problem.
For anything talking to a genuinely unreliable dependency, that half-problem framing is the honest recommendation: bound the queue first, since it's free and catches the unbounded-growth failure mode outright, then reach for a circuit breaker or bulkhead only once "how much can wait" stops being the question and "when do we stop trying" becomes the real one.