Threads, Executors & Thread Pools Interview Questions
KOTLIN › JVM Concurrency
A colleague configures a ThreadPoolExecutor with core 2, max 50 and a LinkedBlockingQueue, expecting it to scale under load. What do you tell them?
What a strong answer covers: That they have built a two-thread pool with an unbounded queue attached, and the maximum of 50 will never be reached. The submission algorithm goes: create a thread if below core size, otherwise try to enqueue, and only create threads beyond the core size if the queue refuses the task. A LinkedBlockingQueue with no capacity argument is unbounded, so it never refuses anything, so the growth step is unreachable. Under sustained load they get two threads working and a queue growing until the heap is exhausted, which fails as an OutOfMemoryError far from the actual cause rather than as an obvious saturation. The fix is to bound the queue, which makes the maximum meaningful and forces a decision about what happens when it fills. That decision is the rejection policy, and I would push for CallerRunsPolicy in most Android cases because running the task on the submitting thread slows the producer down, which is a natural brake, rather than throwing or silently dropping work. I would also ask whether they need a custom pool at all. If the goal is a concurrency ceiling for a subsystem, Dispatchers.IO.limitedParallelism(n) does it without owning any threads or needing a shutdown, and if it is genuinely CPU work then Dispatchers.Default already caps at the core count for exactly the reason they were reaching for a maximum.
Trace the line from Future to coroutines. Why did each step happen?
What a strong answer covers: Future arrived with the executor framework and solved submitting work to a pool and getting a result back. Its flaw is that get blocks: there is no completion callback, so if you want to do something when the value arrives you have to occupy a thread waiting for it. Composing two async calls therefore burns a pooled thread doing nothing, which is precisely what the pool existed to prevent. Callbacks were the first workaround, and they compose badly: nesting two or three produces the shape everyone calls callback hell, error handling has no natural place because you cannot try/catch across a callback boundary, and cancellation has to be threaded through by hand. CompletableFuture fixed the composition properly, giving you thenApply and thenCompose, which are map and flatMap, plus exceptionally and allOf. It is genuinely non-blocking and genuinely composable. What it did not fix is readability: a thenCompose chain is still callbacks with better syntax, the code reads inside out, error handling splits across three different methods, and cancellation remains manual so cancelling the returned future does not reliably stop the work feeding it. Coroutines closed that last gap. Suspension gives you CompletableFuture non-blocking behaviour with sequential syntax, ordinary try/catch, and structured cancellation that propagates to children automatically. The one-sentence version I would give: each step kept the non-blocking property and improved how the code reads, and coroutines are the point where async code finally looks like the blocking code it replaced.
How do the coroutine dispatchers relate to what you know about thread pools, and when would you still create a pool yourself?
What a strong answer covers: The dispatchers are thread pools with the sizing decisions already made, using exactly the standard formulas. Dispatchers.Default is capped at the core count because CPU-bound work cannot exceed the cores available and more threads only add context switching. Dispatchers.IO is capped at 64 because IO threads spend their time parked consuming no CPU, so the core-count limit would throttle throughput for nothing. They share one underlying pool, with IO permitted to grow it past the Default limit. That is the same reasoning you would apply by hand, so the honest answer to when to create a pool yourself is: rarely. If the goal is a concurrency ceiling, limitedParallelism(n) gives it without allocating anything, because it borrows from the parent pool and refuses to dispatch more than n at once, so there is nothing to close and no idle threads. If the goal is serialising access to shared state, limitedParallelism(1) gives thread confinement without owning a thread. The genuine cases left are when you need specific, stable, application-owned threads: a native library that insists on being called from one particular thread, a legacy ExecutorService you are wrapping with asCoroutineDispatcher, or a thread that needs a Looper, which is HandlerThread rather than an executor. In all of those you own the lifecycle, so the dispatcher has to be closed, and forgetting is a thread leak that lasts the life of the process.