Interview Prep Zoneby CuriouserLabs

Question 5 of 5 · architect level

How do you size and configure a thread pool for a production service? Explain ExecutorService internals.

🎤 Say this first

A ThreadPoolExecutor is core threads + a work queue + max threads + a rejection policy — and the interactions are counter-intuitive: with an unbounded queue, maxPoolSize is never reached. Size by workload type: CPU-bound ≈ number of cores; blocking I/O ≈ cores × (1 + wait/compute ratio) — or eliminate the sizing problem with virtual threads. Always bound the queue, name your threads, define a rejection policy deliberately, and expose pool metrics.

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.

A production-grade pool
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: CallerRunsPolicy gives graceful backpressure; AbortPolicy + a metric + fallback is better when latency SLOs forbid blocking the caller.

🔄 Likely follow-up questions

  • When do virtual threads make pool sizing obsolete — and when do they not help (CPU-bound work)?
  • How does CompletableFuture decide which pool runs a stage?
  • What metrics would you alert on for a thread pool? (queue depth, active count, rejected count)