Definition
- Unbounded queue –
new LinkedBlockingQueue<>()has capacityInteger.MAX_VALUE. Under sustained overload, tasks queue indefinitely. EachRunnableoccupies heap memory. Eventually: millions of tasks, heap exhausted, OutOfMemoryError. The rejection handler never fires because the queue never reports as full. - Latency tail – a task submitted during overload may wait 30 minutes in the queue before a worker picks it up. The client already timed out, so the processing is wasted work.
- Bounded queue –
new LinkedBlockingQueue<>(500)fills up at 500 tasks. The 501st task triggers the rejection handler immediately. Predictable, fail-fast behavior. - Custom handler – production handlers should: (1) increment a rejection counter (Prometheus/Micrometer), (2) log queue depth for ELK alerts, (3) alert PagerDuty if rejection rate exceeds threshold, (4) optionally fall back to CallerRunsPolicy for back-pressure.
An unbounded queue is like an infinite waiting line outside National Bank of India. Ten thousand customers wait in line for 3 tellers. The first customer is served in 1 minute. The last waits 5 hours. Eventually the line wraps around the block and the building collapses (OOM). A bounded queue (100 seats) turns the 101st customer away immediately: "Sorry, line full! Come back in 10 minutes." The customer gets a fast, predictable answer.
Understanding the Concepts
What is the problem with unbounded queues?
Executors.newFixedThreadPool(n) internally creates a ThreadPoolExecutor with an unbounded LinkedBlockingQueue. Under sustained overload, tasks accumulate without limit. Each Runnable and its captured state occupy heap memory. Millions of queued tasks exhaust the heap.
package com.bank.concurrency;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class UnboundedDangerDemo {
public static void main(String[] args) {
// DANGEROUS: unbounded queue
ExecutorService pool = Executors.newFixedThreadPool(10);
// Internally: new LinkedBlockingQueue<>() → Integer.MAX_VALUE capacity
for (int i = 0; i < 10_000_000; i++) {
pool.submit(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) { }
});
}
// 10 workers, 10M tasks queued → heap fills → OOM!
}
}
How do bounded queues fix this?
package com.bank.concurrency;
import java.util.concurrent.*;
public class BoundedSafeDemo {
public static void main(String[] args) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, 20, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500), // BOUNDED
new ThreadPoolExecutor.CallerRunsPolicy());
// Queue full + 20 workers busy → CallerRunsPolicy
// Caller thread executes → natural back-pressure
// Client sees higher latency but no OOM
}
}
What should a production rejection handler look like?
package com.bank.concurrency;
import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;
public class ProductionHandlerDemo {
private static final LongAdder rejectionCount = new LongAdder();
static final RejectedExecutionHandler handler = (task, executor) -> {
rejectionCount.increment(); // Prometheus counter
System.err.println("REJECTED: queue=" + executor.getQueue().size()
+ " active=" + executor.getActiveCount()
+ " max=" + executor.getMaximumPoolSize());
if (rejectionCount.sum() > 100) {
// Alert PagerDuty: pool saturated
}
if (!executor.isShutdown()) {
task.run(); // CallerRuns fallback
}
};
}
How is this different from each built-in policy?
| Policy | Behavior | Production use |
|---|---|---|
| AbortPolicy | Throws RejectedExecutionException | When caller must know about rejection |
| CallerRunsPolicy | Caller executes the task | Best default: natural back-pressure |
| DiscardPolicy | Silently drops | NEVER (silent data loss) |
| DiscardOldestPolicy | Drops oldest queued task | Only for real-time dashboards |
| Custom | Metrics + logging + fallback | Always in production |
What are common interview follow-up questions?
- Is CallerRunsPolicy always safe? Almost. The risk is that the caller thread (e.g., Tomcat's HTTP thread) blocks while executing the task, increasing request latency. But this is better than OOM.
- How do you monitor pool saturation? Track: queue size, active thread count, rejection rate, and task completion rate. Alert when rejection rate exceeds threshold.
Visual Overview
Unbounded vs Bounded Queue ══════════════════════════ Unbounded (OOM risk): Task 1 → queue (queue: 1) Task 2 → queue (queue: 2) ... ... Task 1M → queue (queue: 1,000,000) → OOM! Rejection: NEVER fires! Bounded (fail-fast): Task 1 → queue (queue: 1/500) Task 500 → queue (queue: 500/500) Task 501 → REJECTED → handler fires → metrics + fallback Client: immediate 503 → retry or error
Key Takeaway
Under overload, unbounded queues are a production disaster. Tasks pile up indefinitely, latency grows from milliseconds to minutes, and eventually the heap is exhausted. A task processed 30 minutes after submission is wasted work because the client already timed out. Bounded queues provide predictable fail-fast behavior: the queue fills, the rejection handler fires, and the client gets an immediate error.
In production, always use a bounded queue with a custom RejectedExecutionHandler that increments a Prometheus counter, logs the queue depth for alerts, and falls back to CallerRunsPolicy for natural back-pressure. Never use Executors.newFixedThreadPool() in production because its unbounded queue is an OOM time bomb. Never use DiscardPolicy because silent task loss is unacceptable.
At National Bank of India, a notification service OOM-crashed during a peak event because its unbounded queue grew to 200,000 tasks. After switching to a bounded queue of 1,000 tasks with a custom handler that logged rejections and applied CallerRunsPolicy, the service degraded gracefully under load with zero OOM incidents.
Remember one sentence: always use bounded queues with a custom rejection handler in production because unbounded queues hide pool saturation until they cause OutOfMemoryError.
---