Back to Thread Pool & Executor Framework
Q7Question 7 of 8

Queueing vs rejection?

Key Concepts
  • Unbounded queues absorb all tasks but grow until the heap is exhausted, causing OutOfMemoryError with no rejection ever firing.
  • A task queued during overload and processed 30 minutes later is wasted work because the client already timed out.
  • Bounded queues provide predictable fail-fast behavior: queue fills, rejection fires, client gets an immediate error.
  • A custom RejectedExecutionHandler should increment a Prometheus counter, log queue depth, alert ops, and optionally fall back to CallerRuns.
  • Executors.newFixedThreadPool() uses an unbounded queue internally, making it unsafe for production.
  • DiscardPolicy should never be used in production because it silently drops tasks with no exception or log entry.
  • The right production pattern is: bounded queue plus custom handler with metrics plus CallerRunsPolicy fallback.

Definition

Unbounded queues cause OOM and latency tail disasters because tasks pile up forever; bounded queues provide predictable fail-fast rejection; production systems need custom handlers with metrics, logging, and back-pressure.
  • Unbounded queue – new LinkedBlockingQueue<>() has capacity Integer.MAX_VALUE. Under sustained overload, tasks queue indefinitely. Each Runnable occupies 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.

JAVA
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?

JAVA
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?

JAVA
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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate