Order Service: Nested Submit Deadlock, a Non-Volatile Flag and a Starved Common Pool
An order service handles requests with new ThreadPoolExecutor(8, 64, 60, SECONDS, new LinkedBlockingQueue<>()). Each task enriches an order by submitting a second Callable to the same executor and blocking on future.get() with no timeout. Under a load test of 50 concurrent requests the service stops responding within about 8 requests; a thread dump shows all 8 worker threads parked in FutureTask.get() and 42 outer tasks sitting in the queue. A second defect: private boolean running = true is set to false by the shutdown hook, but two worker loops never exit; making the field final does not help. A third: an order counter is declared private volatile int count and incremented with count++; a test running 10 threads × 10,000 increments reports 93,412. A fourth: CompletableFuture.supplyAsync(() -> jdbcCall()) is used with no executor, and under load the whole JVM, including unrelated parallel streams, slows down. The team wants to keep the fixed worker count at 8.
1. Diagnose the stall and give remedies that keep the 8-thread limit.
Read the worked answer
This is thread-starvation deadlock. The inner Callable is submitted to the same executor and goes into the same LinkedBlockingQueue. Because that queue is unbounded, the executor never rejects work, and ThreadPoolExecutor only grows past corePoolSize once the queue is full - so maximumPoolSize = 64 is never reached and the pool stays at 8 threads. Each outer task then occupies a worker while parked in FutureTask.get(), waiting for an inner task that can never get a worker. Eight requests consume all eight threads; the other 42 outer tasks queue forever. The dump is diagnostic: all workers in FutureTask.get(), queue non-empty, poolSize == corePoolSize.
Remedies, best first:
- Don't block. Compose instead:
CompletableFuture.supplyAsync(this::enrich, pool).thenCompose(x -> CompletableFuture.supplyAsync(() -> call(x), pool)).thenApply(...). No thread waits on another. - Separate the pools. A dedicated executor for the inner (I/O-bound) work means the waiting thread and the awaited task are different resources. Note this still deadlocks if both pools can saturate - size the inner pool for blocking I/O.
- Bound the wait:
get(2, TimeUnit.SECONDS)turns a stall into a failed request instead of a frozen service; catchTimeoutExceptionand cancel. - If you must keep one pool, use a bounded queue with
CallerRunsPolicyso the submitting thread runs the inner task itself, orForkJoinPool.managedBlock.
Raising maximumPoolSize alone changes nothing while the queue is unbounded - that is the trap in this configuration.
2. Explain the shutdown flag and the lost counter increments, and say which tool fixes which.
Read the worked answer
The flag (volatile missing): without volatile there is no happens-before edge between the shutdown hook's write and the worker's read. The JIT may hoist the read out of the loop (the loop body does not change the field, so it is a legal optimization), and the worker spins on a cached value forever. Declaring the field volatile establishes visibility, which is why it fixes the hang. final is not a fix: a final field cannot be assigned by the shutdown hook at all, so the loop has no way to end. Better than a flag: use interruption - Thread.currentThread().isInterrupted() in the loop and shutdownNow() - because interrupt() also unblocks sleep, wait and interruptible I/O, whereas a volatile flag can leave a worker sleeping for the remaining delay.
The counter (volatile insufficient): count++ is a read, an add and a write. volatile guarantees visibility and prevents reordering, but not atomicity, so two threads can read the same value and both write the same successor - losing updates (93,412 of 100,000). Fixes: AtomicInteger.incrementAndGet() (CAS, lock-free), LongAdder (striped cells, best under the 10-thread contention here), or synchronized/ReentrantLock. Inside a single thread, volatile is right for the flag and wrong for the counter - that distinction is the whole point.
3. Explain what `CompletableFuture.supplyAsync` does without an executor and how to stop the collateral slowdown.
Read the worked answer
Without an Executor, the *Async methods run on ForkJoinPool.commonPool(), whose parallelism is Runtime.getRuntime().availableProcessors() - 1 (never less than 1) and which is shared process-wide. On an 8-core box that is 7 threads for the entire JVM. A blocking JDBC call holds one of those threads for its full duration; a handful of concurrent orders occupies the pool, and every other common-pool user - parallel streams, other CompletableFuture stages, parallelSort - queues behind them. That is the observed collateral slowdown: one badly placed blocking call degrades unrelated code.
Also note where continuation code runs: thenApply, thenAccept and thenCompose (non-Async variants) execute on whichever thread completed the previous stage - here, a common-pool worker - so a JDBC call inside a plain thenApply after supplyAsync is still on the common pool. Only the *Async variants with an explicit executor move it.
Fix: create a dedicated executor sized for the blocking I/O concurrency you actually want and pass it: supplyAsync(() -> jdbcCall(), ioPool).thenApplyAsync(this::map, ioPool). Keep the 8-thread pool for CPU-bound work and never share it with blocking calls. If blocking inside a ForkJoinPool is unavoidable, wrap it in ForkJoinPool.managedBlock so the pool can compensate - a dedicated executor is still clearer.