The full picture
Task submission order inside ThreadPoolExecutor: (1) fewer than corePoolSize threads → create a new thread; (2) otherwise offer to the queue; (3) only if the queue is full → grow toward maxPoolSize; (4) queue full and at max → RejectedExecutionHandler. That's why Executors.newFixedThreadPool (unbounded LinkedBlockingQueue) can hide an OOM-by-queue, and newCachedThreadPool (synchronous hand-off, max = Integer.MAX_VALUE) can explode thread counts under a spike. Seniors build their own with explicit bounds.
var pool = new ThreadPoolExecutor(
8, 32, // core, max — measured, not guessed
60, TimeUnit.SECONDS, // reclaim idle non-core threads
new ArrayBlockingQueue<>(500), // BOUNDED: backpressure, not OOM
Thread.ofPlatform().name("orders-", 0).factory(),
new ThreadPoolExecutor.CallerRunsPolicy() // natural throttle at saturation
);- CPU-bound: threads ≈ cores (
Runtime.getRuntime().availableProcessors()— container-aware since JDK 10). - I/O-bound: threads ≈ cores × (1 + waitTime/computeTime) — Little's Law in disguise; measure, don't fold-lore.
- Isolation: use separate pools per downstream dependency (bulkhead pattern) so one slow dependency can't starve everything.
- Rejection:
CallerRunsPolicygives graceful backpressure;AbortPolicy+ a metric + fallback is better when latency SLOs forbid blocking the caller.