Thread vs Runnable vs Callable, sharing one Runnable across threads, Future.get vs CompletableFuture.join, interruption semantics, yield vs sleep, Executor vs ExecutorService vs ThreadPoolExecutor, how newFixedThreadPool works, what happens when tasks exceed threads, corePoolSize/maximumPoolSize/keepAliveTime interplay (and the queue-before-grow surprise), worker lifecycle and pool states, how the executor tracks threads, graceful vs forced shutdown, ForkJoinPool and work stealing, and how parallel streams use the common pool.
Published September 25, 2026
Executor questions check whether you can size and operate thread pools in production:
ThreadPoolExecutor decision algorithm;Thread, Runnable and Callable? Which should you prefer?Short answer:
Thread is the execution mechanism: an OS or virtual thread, with a lifecycle.Runnable is a task returning nothing: void run(), with no checked exceptions.Callable<V> is a task that returns a value and may throw checked exceptions: V call() throws Exception.Prefer tasks (Runnable/Callable) submitted to an executor, rather than subclassing or creating Threads yourself:
Future/CompletableFuture;Raw new Thread(...) is fine for rare, long-lived dedicated threads, or tests. For many concurrent I/O tasks, use Executors.newVirtualThreadPerTaskExecutor() (Java 21).
Learn it in depth → ExecutorService & Thread Pools
Runnable in two threads? Can one Runnable be shared by several threads?Short answer: Yes. new Thread(task).start() twice runs task.run() concurrently, in two threads, on the same Runnable object. Output order is nondeterministic. The implication: any instance fields of that Runnable are shared state. Counters or buffers in it are subject to races, so they must be thread-safe, or the task must be stateless (keeping state in local variables). A shared, stateless task is fine, and common: the same Runnable submitted many times to a pool.
class Counter implements Runnable {
private int count; // shared between threads, so racy
public void run() { for (int i = 0; i < 1000; i++) count++; }
}
Counter c = new Counter();
Thread a = new Thread(c), b = new Thread(c);
a.start(); b.start(); a.join(); b.join();
System.out.println(c.count); // usually < 2000 (lost updates). Use AtomicInteger/LongAdder
Future.get() different from join() on a CompletableFuture?Short answer: Both block until the result is ready. The difference is in exceptions:
get() (from Future) throws checked InterruptedException and ExecutionException (wrapping the cause). It has a timeout overload, get(timeout, unit), and it responds to interruption.join() (on CompletableFuture) throws the unchecked CompletionException (wrapping the cause), or CancellationException, so it's convenient in lambdas and streams. It has no timeout, and it doesn't throw InterruptedException.Key points to cover:
thenApply, thenCompose, orTimeout) over either.main method, or a virtual thread where blocking is cheap.resultNow()/exceptionNow()/state() for completed futures.Thread.interrupt() actually do?Short answer: interrupt() doesn't stop a thread. It sets the target's interrupt status flag, and:
sleep, wait, join, BlockingQueue.take, Lock.lockInterruptibly, interruptible NIO channels), that call throws InterruptedException (or ClosedByInterruptException), and clears the flag;Thread.currentThread().isInterrupted()).Correct handling:
InterruptedException;Thread.currentThread().interrupt()) and exit.Never swallow it. Blocking socket I/O (classic java.io) and synchronized lock acquisition aren't interruptible.
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
Job job = queue.take(); // wakes up with InterruptedException on interrupt
process(job);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore, then exit the loop cleanly
return;
}
}
}
Key points to cover:
Future.cancel(true) and ExecutorService.shutdownNow() work through interruption.Thread.stop() is deprecated, and since Java 20 it throws UnsupportedOperationException.Thread.yield() and Thread.sleep()?Short answer:
sleep(ms) guarantees the thread stops running for at least the given time (subject to timer granularity), moving it to TIMED_WAITING. It's interruptible, and keeps any locks it holds.yield() is a hint to the scheduler that the thread is willing to give up the CPU. It stays RUNNABLE, and may be scheduled again immediately. Its behaviour is platform-dependent, and it's rarely useful in application code. Use it only in spin-wait loops (Thread.onSpinWait(), Java 9, is the better hint for spin loops).Neither is a correct tool for coordination between threads. Use latches, conditions or queues.
Executor, ExecutorService and ThreadPoolExecutor?Short answer:
Executor: the minimal interface, void execute(Runnable). It decouples task submission from execution.ExecutorService: extends it with lifecycle management (shutdown, shutdownNow, awaitTermination, close() since Java 19, which makes it usable with try-with-resources) and results (submit returning a Future, invokeAll, invokeAny). ScheduledExecutorService adds scheduling.ThreadPoolExecutor: the main configurable implementation: core and maximum pool size, keep-alive time, work queue, ThreadFactory, RejectedExecutionHandler, and hooks (beforeExecute, afterExecute). Most of the Executors.* factory methods return one.Executors.newFixedThreadPool(n) work internally? What happens when you submit more tasks than there are threads?Short answer: It creates new ThreadPoolExecutor(n, n, 0L, MILLISECONDS, new LinkedBlockingQueue<>()):
LinkedBlockingQueue.When more tasks arrive than there are threads, the extras wait in the queue, in FIFO order. The danger is that the queue is unbounded: under sustained overload it grows without limit, so latency explodes, and eventually you get an OutOfMemoryError. Nothing is ever rejected. The same applies to newSingleThreadExecutor.
Production practice: build a ThreadPoolExecutor explicitly with a bounded queue, a named ThreadFactory, and a deliberate rejection policy, and expose metrics (Micrometer's ExecutorServiceMetrics).
corePoolSize, maximumPoolSize and keepAliveTime interact in ThreadPoolExecutor?Short answer: On execute(task), the pool follows this algorithm:
RejectedExecutionHandler).keepAliveTime are terminated. The core threads can time out too, with allowCoreThreadTimeOut(true).Common trap: with an unbounded queue, step 3 never happens. maximumPoolSize is ignored, and the pool never grows beyond its core size. People configure core=10, max=100 with a LinkedBlockingQueue, and wonder why only 10 threads ever run. Use a bounded queue, or a SynchronousQueue (a direct hand-off, as newCachedThreadPool does).
ThreadPoolExecutor pool = new ThreadPoolExecutor(
16, 64, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), // bounded: back-pressure
Thread.ofPlatform().name("pricing-", 0).factory(),
new ThreadPoolExecutor.CallerRunsPolicy()); // throttles submitters when saturated
ThreadPoolExecutor? What are the pool's states, and how does it track its threads?Short answer:
keepAliveTime and the pool has more than corePoolSize threads (or core timeout is allowed);execute; with submit, the exception is captured in the Future, and the worker survives).Worker (it wraps the thread, and is itself a small lock), held in a HashSet<Worker> guarded by mainLock. An atomic ctl integer packs the run state (3 bits) and the worker count (29 bits). The monitoring methods read this state: getPoolSize(), getActiveCount() (workers currently holding their lock, meaning running a task), getCompletedTaskCount(), getQueue().size() and getLargestPoolSize(). There's no "dead thread" count, because dead workers are removed and replaced.shutdownNow(): no new tasks, the queue is abandoned, and workers are interrupted.terminated() hook runs.Common trap: the source says the executor "first checks if there are idle threads". While below the core size, it starts a new thread regardless of idle ones. After that, it queues before growing.
Short answer:
void shutdownGracefully(ExecutorService pool) {
pool.shutdown(); // stop accepting; finish queued and running tasks
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
List<Runnable> dropped = pool.shutdownNow(); // interrupt running tasks; return the never-started ones
log.warn("Forced shutdown, {} tasks dropped", dropped.size());
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) log.error("Pool did not terminate");
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
Key points to cover:
shutdownNow() relies on interruption. Tasks that ignore interrupts keep running.ExecutorService implements AutoCloseable: close() means shutdown, then wait. Use it with try-with-resources for scoped pools.ThreadPoolTaskExecutor with setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds(...) handles this during context shutdown.ForkJoinPool differ from ThreadPoolExecutor? When is it the better choice? How does the Fork/Join framework work?Short answer:
RecursiveTask/RecursiveAction) fork subtasks and join their results;join() doesn't simply block: the joining worker helps execute other tasks while it waits, so recursion doesn't exhaust the threads;Prefer ForkJoin for recursive algorithms (parallel merge sort, tree or graph processing, large array reductions), and implicitly through parallel streams. It's also the carrier scheduler for virtual threads. Avoid blocking I/O in it (use ManagedBlocker if you must).
class SumTask extends RecursiveTask<Long> {
private final long[] a; private final int lo, hi;
SumTask(long[] a, int lo, int hi) { this.a = a; this.lo = lo; this.hi = hi; }
protected Long compute() {
if (hi - lo <= 10_000) { long s = 0; for (int i = lo; i < hi; i++) s += a[i]; return s; }
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(a, lo, mid);
left.fork(); // push onto this worker's deque
long right = new SumTask(a, mid, hi).compute(); // work on the other half directly
return right + left.join();
}
}
long total = ForkJoinPool.commonPool().invoke(new SumTask(data, 0, data.length));
Learn it in depth → Fork/Join Framework
ForkJoinPool?Short answer: Each worker thread owns a deque of tasks:
Owners and thieves work at opposite ends, so contention is minimal (only CAS operations on steals). The result is automatic load balancing of irregular recursive work, without a central queue bottleneck.
Short answer:
ForkJoinPool, whose parallelism is availableProcessors() - 1, plus the calling thread, which also participates. So you get about one thread per core in total.-Djava.util.concurrent.ForkJoinPool.common.parallelism=N (JVM-wide), or run the stream inside a custom ForkJoinPool (pool.submit(() -> list.parallelStream()...).get()). That second trick works, but is undocumented behaviour.Spliterator recursively splits the data (trySplit). ArrayList, arrays and IntStream.range split evenly. LinkedList and Stream.iterate split badly.forEachOrdered, limit on ordered streams, findFirst) add coordination costs.Common trap: all parallel streams, and CompletableFuture.*Async calls without an executor, share the same common pool. Blocking I/O inside them starves everything else in the JVM.
Short answer:
ThreadPoolExecutor): a set of Worker threads, and one shared BlockingQueue. Tasks follow core → queue → max → reject. Workers loop on getTask() (take or a timed poll for the keep-alive), run the task, and repeat.Spliterator → fork the subtasks → combine with the reducers.Pick: ThreadPoolExecutor for independent jobs, with bounded queues and back-pressure. ForkJoin for recursive CPU work. Virtual threads for massive numbers of blocking I/O tasks.
Q: How do you size a thread pool?
A: For CPU-bound work, about the number of cores (N or N+1). For blocking I/O, threads ≈ cores × (1 + wait time / compute time) (Goetz's formula), capped by downstream capacity (database pool size, rate limits). Better still, use virtual threads for I/O-bound work, and limit concurrency with semaphores or bulkheads instead of pool sizes.
Q: What does CallerRunsPolicy do, and why is it useful?
A: When the pool is saturated, the submitting thread runs the task itself. That naturally slows the producer down (back-pressure), and nothing is dropped. The caveat is that it can block request threads, or event loops. The other policies are AbortPolicy (the default; it throws), DiscardPolicy and DiscardOldestPolicy.
Q: Why should thread pools have named threads?
A: Thread dumps, logs and profilers become readable (pricing-7 instead of pool-3-thread-7). Use a ThreadFactory (Thread.ofPlatform().name("pricing-", 0).factory(), or Guava's ThreadFactoryBuilder), and set an uncaught-exception handler too.
Q: What is ScheduledThreadPoolExecutor's pitfall with exceptions?
A: If a periodic task (scheduleAtFixedRate) throws, all future runs are silently cancelled, and the exception sits in the unobserved ScheduledFuture. Wrap the task body in try/catch and log inside it.