ExecutorService and its methods, lambdas and concurrency, the Java concurrency model, thread-management challenges, synchronized vs concurrent collections, Runnable vs Callable, interruption done right, ThreadLocal use cases and leaks, submit vs execute, rejection policies, ConcurrentHashMap internals (Java 8+), and thread dumps.
Published September 25, 2026
In production Java, you rarely create threads by hand. You configure executors, and you debug with thread dumps. Interviewers at this level ask how you'd size a pool, what happens when it's full, and how to stop tasks cleanly.
ExecutorService, and what methods does it provide?Short answer: ExecutorService decouples task submission from thread management. You submit Runnable/Callable tasks, and it runs them on a managed pool of reusable threads, handles queuing, and controls the lifecycle. Its main methods:
execute, submit, invokeAll, invokeAny.shutdown (no new tasks; finish the queued ones), shutdownNow (interrupt running tasks and return the queued ones), awaitTermination, isShutdown, isTerminated, and close() since Java 19.try (ExecutorService pool = Executors.newFixedThreadPool(8)) { // Java 19+: close() waits for the tasks
List<Future<Report>> futures = pool.invokeAll(reportTasks, 30, TimeUnit.SECONDS);
for (Future<Report> f : futures) publish(f.get());
}
Key points to cover:
ThreadPoolExecutor explicitly, with a bounded queue, named threads and a rejection policy. Executors.newFixedThreadPool uses an unbounded queue, which can grow until you run out of memory.Learn it in depth → ExecutorService
ExecutorService for? (Follow-up: how do you size and shut down a pool?)Short answer:
newVirtualThreadPerTaskExecutor()), and stop worrying about pool size.shutdown(), then awaitTermination(timeout). If it times out, call shutdownNow(), and handle InterruptedException properly. In Spring, prefer a ThreadPoolTaskExecutor bean: Spring manages its lifecycle, and it supports graceful shutdown.Short answer: Lambdas made passing tasks as values cheap and readable, and that enabled the functional concurrency APIs:
executor.submit(() -> …) in place of anonymous Runnable classes;CompletableFuture pipelines (supplyAsync(...).thenApply(...).thenCompose(...));ConcurrentHashMap.compute/merge, with atomic update functions.CompletableFuture<OrderView> view = CompletableFuture
.supplyAsync(() -> orders.find(id), ioPool)
.thenCombine(CompletableFuture.supplyAsync(() -> payments.status(id), ioPool), OrderView::new)
.orTimeout(2, TimeUnit.SECONDS);
Learn it in depth → CompletableFuture
Short answer: Java uses shared-memory multithreading. Threads run concurrently in one heap, communicate through shared objects, and coordinate with locks, volatile and atomics, under the rules of the Java Memory Model (happens-before). On top of that, java.util.concurrent offers executors, futures, concurrent collections, synchronizers and fork/join. Java 21 added virtual threads: cheap, JVM-scheduled threads for the thread-per-request style.
Key points to cover:
Short answer:
OutOfMemoryError, and saturated pools add latency.Key points to cover:
TaskDecorator).Short answer: A synchronized collection (Collections.synchronizedList/Map, Vector, Hashtable) wraps every method in one lock. It's safe, but all access is serialised, and iteration must be locked manually. A concurrent collection (ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue, BlockingQueue) is designed for concurrency. It uses fine-grained locking or lock-free algorithms, provides atomic compound operations (putIfAbsent, computeIfAbsent), and has weakly consistent iterators, which never throw ConcurrentModificationException.
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
synchronized (syncList) { // REQUIRED when iterating a synchronized wrapper
for (String s : syncList) process(s);
}
Short answer: Java threads are mapped one-to-one to OS threads (platform threads), which the OS schedules. Since Java 21, virtual threads are scheduled by the JVM onto a small pool of carrier threads. The language provides the monitor-based synchronized/wait/notify, and the JMM defines visibility. java.util.concurrent supplies the high-level tools. Frameworks (servlet containers, Spring's @Async, Kafka listeners) run your code on their managed pools.
Runnable and Callable?Short answer: Runnable.run() returns nothing, and can't throw checked exceptions. Callable<V>.call() returns a value, and can throw checked exceptions. Submit a Callable to an executor to get a Future<V>. Any exception it throws comes back wrapped in an ExecutionException from get().
Future<BigDecimal> price = pool.submit(() -> pricingClient.quote(sku)); // Callable
try {
BigDecimal p = price.get(500, TimeUnit.MILLISECONDS);
} catch (ExecutionException e) {
throw new PricingUnavailableException(e.getCause());
} catch (TimeoutException e) {
price.cancel(true); // interrupt the slow task
}
Short answer: Interruption is cooperative: thread.interrupt() only sets a flag, and wakes up blocking calls (sleep, wait, join, BlockingQueue.take), which then throw InterruptedException. A well-behaved task either stops, or propagates the interruption. If it catches InterruptedException without rethrowing, it must restore the flag.
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
Job job = queue.take(); // blocks; throws if interrupted
handle(job);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag so callers and the pool see it
return; // then exit cleanly
}
}
}
Common trap: swallowing InterruptedException (catch (InterruptedException e) {}). shutdownNow() and Future.cancel(true) can then never stop the task.
ThreadLocal?Short answer: Storing per-thread context without passing it through every method:
SecurityContextHolder, transaction synchronisation and logging's MDC all use ThreadLocal.SimpleDateFormat case).public final class TenantContext {
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
public static void set(String t) { TENANT.set(t); }
public static String get() { return TENANT.get(); }
public static void clear() { TENANT.remove(); } // MUST be called, e.g. in a filter's finally block
}
Common trap: forgetting remove() in thread pools. The next request on that pooled thread inherits the previous tenant (a data leak), and the values keep class loaders alive after redeploys (a memory leak).
Key points to cover:
ThreadLocals don't flow to executor threads automatically. Use decorators to copy the context.ScopedValue (final in Java 25) is the lighter, immutable alternative.submit() and execute()?Short answer: execute(Runnable) (from Executor) runs a task and returns nothing. An exception thrown by the task goes to the thread's uncaught exception handler, and gets printed. submit(...) accepts a Runnable or a Callable, and returns a Future. Any exception is captured inside the Future, and surfaces only when you call get().
Common trap: submit-ing fire-and-forget tasks and never calling get(), so failures vanish silently. Either use execute, or handle the Future, or attach callbacks with CompletableFuture.
RejectedExecutionHandler, and how can you customise it?Short answer: A ThreadPoolExecutor rejects a task when it's shut down, or when all threads are busy and the bounded queue is full. The handler decides what happens next. The built-in policies:
AbortPolicy (the default): throws RejectedExecutionException.CallerRunsPolicy: runs the task on the submitting thread, which gives natural back-pressure.DiscardPolicy: silently drops the task.DiscardOldestPolicy: drops the oldest queued task.You can also implement your own, for example to log, record a metric, or send to a fallback queue.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 32, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), // bounded!
Thread.ofPlatform().name("report-", 0).factory(),
(task, executor) -> {
rejectedCounter.increment();
throw new RejectedExecutionException("report pool saturated"); // fail fast; the caller returns 503
});
Key points to cover:
ThreadPoolExecutor grows beyond corePoolSize only when the queue is full. With an unbounded queue, maximumPoolSize never matters.ConcurrentHashMap work internally?Short answer (Java 8+): It's a table of bins, like HashMap. Reads are lock-free, using volatile reads of the table and the nodes. Writes use a CAS to insert into an empty bin, and otherwise lock only that bin's first node (synchronized), so writers to different bins don't block each other. Long collision chains are turned into tree bins. Resizing is cooperative: threads that encounter a resize help move bins. Size is tracked with LongAdder-style counter cells, to avoid contention.
Common trap: describing segments (16 ReentrantLock segments) as the current design. That was Java 7. Java 8 replaced it with CAS plus per-bin locking.
Key points to cover:
null keys and values are forbidden (ambiguous under concurrency).compute, merge, putIfAbsent), not across separate calls.size() is an estimate under concurrent modification. mappingCount() returns a long.Learn it in depth → HashMap Concurrency Variants
Short answer:
jcmd <pid> Thread.print, the recommended way (jcmd <pid> Thread.dump_to_file -format=json file also includes virtual threads).jstack <pid>.SIGQUIT (kill -3 <pid>, or Ctrl+\ in the console, Ctrl+Break on Windows), which prints to stdout./threaddump endpoint in Spring Boot.ThreadMXBean.dumpAllThreads.Key points to cover:
BLOCKED threads, what they're "waiting to lock", and the JVM's "Found one Java-level deadlock" section.Short answer: Run kubectl exec <pod> -- jcmd 1 Thread.print > dump.txt (the Java process is usually PID 1 in its container), or call the secured Actuator threaddump endpoint. If the image has no JDK tools, use kill -3 1, and read the pod logs. Keep a JDK-enabled debug image, or attach an ephemeral debug container (kubectl debug), for incidents.
Key points to cover:
Q: What's the difference between CountDownLatch and CyclicBarrier?
A: A latch lets threads wait until a count reaches zero, and it's one-shot (for example, waiting for N services to start). A barrier makes a fixed number of threads wait for each other, and it's reusable, with an optional barrier action (for example, parallel phases of a computation).
Q: What does invokeAny do?
A: It runs the tasks, and returns the result of the first one to complete successfully, cancelling the rest. That's useful for querying redundant replicas.
Q: How does ForkJoinPool differ from ThreadPoolExecutor?
A: ForkJoinPool uses work stealing (per-worker deques, idle workers stealing from busy ones), which suits recursive divide-and-conquer tasks. It backs parallel streams and CompletableFuture's default async execution.
Q: What is LongAdder, and when is it better than AtomicLong?
A: It spreads updates across several cells, and sums them when you read. That's much faster under high write contention (metrics counters). Use AtomicLong when you need atomic compare-and-set on a single value.