ThreadPoolExecutor and Queue Management
1. Bulk Processing with a ThreadPoolExecutor
Context
We are building a Spring Boot service that processes bulk data (e.g., uploading and processing customer records). We divide incoming data into batches and submit each batch as a task to an ExecutorService
backed by a ThreadPoolExecutor
.
We configure:
A thread pool with
corePoolSize = 10
,maxPoolSize = 50
An unbounded queue (
LinkedBlockingQueue
)Each task processes a batch of 200 records
You process 10,000 records total
The tasks perform database operations or external API calls. You want to ensure queue memory is handled safely and avoid system overload.
This scenario highlights common challenges and behavior related to queue management when using ThreadPoolExecutor
.
Why a Queue is Used in ThreadPoolExecutor
The queue acts as a buffer between task submission and task execution.
If all threads are busy, submitted tasks are added to the queue.
As threads become free, they pull tasks from the queue.
This helps absorb sudden spikes in workload and prevents rejection (unless configured to reject).
Types of Queues and Their Behavior
a. Unbounded Queue (LinkedBlockingQueue
)
LinkedBlockingQueue
)Tasks are never rejected.
Queue size grows as needed.
Good for bursty loads only if the processing rate can catch up.
Risk: uncontrolled memory usage.
b. Bounded Queue (ArrayBlockingQueue
, LinkedBlockingQueue(capacity)
)
ArrayBlockingQueue
, LinkedBlockingQueue(capacity)
)Caps the number of waiting tasks.
Helps control memory usage.
When full, new task submissions can be rejected or handled based on the
RejectedExecutionHandler
.
Memory Usage Estimation for Unbounded Queues
Each submitted task (e.g., a lambda or Runnable
) holds:
The batch data (e.g., 200 strings)
References to variables from enclosing scope (closures)
Overhead from internal wrapping by the executor
Example Estimate:
200 strings × ~60 bytes = 12,000 bytes (~12 KB)
Lambda, CompletableFuture, and Runnable overhead: ~3–5 KB
Total memory per task ≈ 15–20 KB
100 tasks
~1.5–2 MB
1,000 tasks
~15–20 MB
10,000 tasks
~150–200 MB
100,000 tasks
~1.5–2 GB
If task submission greatly exceeds task execution rate, the queue size can grow rapidly, consuming large amounts of heap memory.
What Happens When Threads Pick Up Tasks
When a thread becomes free, it dequeues the next task.
Once dequeued, the task is removed from the queue.
After execution, the task and any batch data it holds become eligible for garbage collection, assuming no other references exist.
So, yes — the queue size decreases as tasks are picked up and executed.
Submission Rate vs Execution Rate
The queue size is directly influenced by this ratio:
If submission rate > execution rate → queue grows
If execution rate ≥ submission rate → queue drains or stays stable
Unbounded queues do not apply backpressure, so if our producer code is fast, we can accidentally create memory pressure or even out-of-memory errors.
Monitoring and Observability
We can track queue size during runtime:
int queueSize = ((ThreadPoolExecutor) executorService).getQueue().size();
log.info("Current task queue size: {}", queueSize);
Consider logging this periodically or exposing it via an actuator endpoint or Prometheus metrics.
Alternative Backpressure Techniques Without a Bounded Queue
If you must use an unbounded queue but want to avoid overload:
Use a
Semaphore
to limit the number of concurrent in-flight tasks.Use
RateLimiter
(e.g., Guava or Bucket4j) to throttle task submission.Divide task submission into "waves" and wait for one wave to complete before submitting more.
Example using Semaphore
:
Semaphore semaphore = new Semaphore(10);
for (...) {
semaphore.acquire();
CompletableFuture.runAsync(() -> {
try {
doWork();
} finally {
semaphore.release();
}
}, executor);
}
Best Practices for Queue Management
Avoid unbounded queues in high-throughput systems unless:
You control the number of submitted tasks
The system load is predictable
Estimate memory impact based on batch size and task data.
Monitor queue size and thread utilization regularly.
Limit concurrency via thread count or a semaphore, especially if queue must remain unbounded.
Use
CallerRunsPolicy
or custom rejection logic if using a bounded queue to safely handle overload.Do not assume GC will save you — heap pressure from queue growth can lead to longer GC pauses or OOMs.
When to Prefer Unbounded vs Bounded Queues
Controlled batch jobs
Unbounded
Safe if submission is throttled
Real-time APIs with limited latency
Bounded
Prevents queue overload
Resource-intensive processing
Bounded + Rejection
Controls memory and CPU usage
You want to avoid task rejection
Unbounded + Semaphore
Use a concurrency limiter to backpressure
Last updated
Was this helpful?