Why can a pool from Executors.newFixedThreadPool lead to OutOfMemoryError under load even though the number of threads is fixed?
answer
- Fixed threads, UNbounded LinkedBlockingQueue
- Backlog grows on heap → OOM
- No back-pressure: submit never blocks/rejects
- Fix: ThreadPoolExecutor + ArrayBlockingQueue + RejectedExecutionHandler
basics
~20 sIts threads are fixed, but the queue that holds waiting tasks is unbounded. If tasks arrive faster than the fixed threads can finish them, the queue grows without limit until the JVM runs out of memory.
solid answer
~40 snewFixedThreadPool(n) wires up a ThreadPoolExecutor with corePoolSize = maxPoolSize = n and a LinkedBlockingQueue with no capacity argument, which means it is unbounded (capacity Integer.MAX_VALUE). The thread count is capped, but the work queue is not. Under sustained overload — producers submitting faster than n threads can drain — tasks pile up in that queue indefinitely. Each queued task (its Runnable/Callable plus whatever it closes over) occupies heap, so the queue grows until the JVM throws OutOfMemoryError. There is also no back-pressure: submit() never blocks or rejects, so the caller has no signal that the system is overloaded. The fix is to build a ThreadPoolExecutor directly with a bounded queue (e.g. ArrayBlockingQueue) and an explicit RejectedExecutionHandler, so overload fails fast or applies back-pressure instead of silently consuming memory.
code
java · 9 lines// Hazard: thread count fixed, queue unbounded
ExecutorService risky = Executors.newFixedThreadPool(4);
// Under overload the internal LinkedBlockingQueue grows until OOM.
// Safer: explicit bounded queue + back-pressure
ThreadPoolExecutor safe = new ThreadPoolExecutor(
4, 4, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500),
new ThreadPoolExecutor.CallerRunsPolicy());go deeper
Understand that 'fixed' refers to threads, not the queue, and that an unbounded queue can grow until memory runs out.
Explain the corePoolSize==maxPoolSize + unbounded LinkedBlockingQueue wiring and that there's no back-pressure; know the bounded-queue fix exists.
Walk through the ThreadPoolExecutor enqueue/grow/reject decision order, why the grow/reject branches are unreachable with an unbounded queue, and choose an appropriate queue size + RejectedExecutionHandler.
Reason about end-to-end back-pressure and load-shedding across services, queue sizing vs latency/throughput trade-offs, and observability (queue depth metrics, rejection alerts) so overload is detected before OOM.
## Background: what a fixed pool really is `Executors.newFixedThreadPool(n)` is a one-line convenience over the real engine, **`ThreadPoolExecutor`**. A `ThreadPoolExecutor` has four key knobs: - **corePoolSize** — threads kept alive even when idle. - **maximumPoolSize** — the hard cap on threads. - **workQueue** — a `BlockingQueue<Runnable>` where tasks wait when all core threads are busy. - **RejectedExecutionHandler** — what happens when a task can't be accepted. The fixed-pool factory sets `corePoolSize == maximumPoolSize == n` and supplies `new LinkedBlockingQueue<Runnable>()`. ## The hidden detail: the queue is unbounded A **`BlockingQueue`** is a queue that can block a producer when full or a consumer when empty. A **`LinkedBlockingQueue`** created **without a capacity argument** defaults to a capacity of `Integer.MAX_VALUE` — for all practical purposes, **unbounded**. So while the *thread* count is fixed at `n`, the number of *waiting tasks* is not bounded by anything except heap memory. ## How a ThreadPoolExecutor decides where a task goes When you `submit` a task, the executor: 1. If fewer than `corePoolSize` threads exist, start a new thread for it. 2. Else, try to **enqueue** it. 3. Only if the queue is *full* does it try to grow toward `maximumPoolSize`. 4. If it can't grow and can't enqueue, it **rejects** the task via the handler. Because the fixed pool's queue is never full, step 2 always succeeds. Steps 3 and 4 are unreachable. So every task that the `n` threads can't immediately run just sits in the queue. ## Why that becomes an OutOfMemoryError Suppose producers submit 1,000 tasks/sec but the `n` threads can only complete 600/sec. The backlog grows by 400 tasks every second. Each queued task is a live object on the **heap** (the managed memory region), and it retains everything it references (the closure/captured variables, request payloads, etc.). The backlog grows linearly forever, heap fills, and eventually the JVM cannot allocate and throws **`OutOfMemoryError: Java heap space`**. Threads were never the problem — the *queue* was. ## The second, subtler problem: no back-pressure **Back-pressure** means a slow consumer can signal a fast producer to slow down. With an unbounded queue, `submit()` always returns instantly and never blocks or fails, so the producer gets **no feedback** that the system is falling behind. The application looks healthy (low rejection, low latency on submit) right up until it dies. By the time the OOM hits, you may have lost in-flight work and have no graceful degradation. ## The remedy Build the pool explicitly with a **bounded** queue and an explicit rejection policy: ```java ThreadPoolExecutor pool = new ThreadPoolExecutor( 8, 8, // core == max 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), // BOUNDED queue new ThreadPoolExecutor.CallerRunsPolicy()); // back-pressure on overload ``` Now when 1,000 tasks are already waiting, the next `submit` triggers the `RejectedExecutionHandler`. `CallerRunsPolicy` runs the task on the submitting thread, which naturally slows the producer (true back-pressure); `AbortPolicy` (the default) throws `RejectedExecutionException` so overload fails fast and visibly. Either way the system degrades predictably instead of silently exhausting memory. This is the core reason experts often **avoid the `Executors` fixed/single factories in favour of configuring `ThreadPoolExecutor` directly**.
- What does CallerRunsPolicy do and why is it a form of back-pressure?When the pool and its bounded queue are both full, the rejected task is executed synchronously on the thread that called submit/execute. That thread is then busy and can't submit more work, so the producer is automatically throttled to the rate the pool can sustain.
- Does the OOM risk also apply to newSingleThreadExecutor?Yes. Single-thread executor uses the same unbounded LinkedBlockingQueue with just one worker, so it is even easier to overwhelm — the backlog can grow without limit and exhaust the heap.
It's a call centre with a fixed number of agents but an infinitely long hold queue: nobody ever gets a busy signal, callers just keep stacking up until the building (memory) collapses.
saying these in an interview costs you the question
- Believing 'fixed threads' implies a bounded amount of queued work
- Saying the queue is an ArrayBlockingQueue (it's an unbounded LinkedBlockingQueue)
- Claiming submit() will block or throw under overload (it won't with the default factory)
- Thinking adding more threads fixes it — the queue, not thread count, is the leak