Concurrency & Multithreading — Interview Q&A Matrix
Parent stack: Core Java Runtime Technology: Concurrency & Multithreading Levels: Senior · Architect Questions: 4
Each entry contains a Question, the Expected Depth, an Ideal Answer, and Red Flags.
Senior
Q1 — ThreadLocal, thread pools, and leaks
Question: Why can ThreadLocal cause memory leaks in a servlet or thread-pool environment, and how do you use it safely?
Expected Depth:
- Explains that pool threads live for a long time, so values may remain after a request ends.
- Understands that
ThreadLocalMaphas weak keys but strong references to non-null values. - Identifies both risks: retained memory and data leaking between requests.
- Uses
remove()in afinallyblock. - Knows that context does not automatically move to another executor thread.
Ideal Answer:
- A thread pool reuses the same threads for many requests. If code does not clear a
ThreadLocal, the next request on that thread may see old data. The old value may also stay in memory. - Each thread owns a
ThreadLocalMap. Its keys are weak references, but its non-null values are strong references. If a key is collected, its value can remain until the map cleans the stale entry or the thread ends. - Always call
remove()in afinallyblock so cleanup also happens after an exception:
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
try {
CTX.set(context);
handle(request);
} finally {
CTX.remove();
}set(null)releases the old value, but it leaves an entry in the map.remove()is clearer and removes the entry.- In a Spring web application, a filter can manage the context for every request:
@Component
class ContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
try {
CTX.set(UserContext.from(request));
chain.doFilter(request, response);
} finally {
CTX.remove();
}
}
}InheritableThreadLocalcopies a value when a child thread is created. This does not provide reliable per-task context in a thread pool because its threads are reused.- In Java 21,
ScopedValueis a preview API. It provides immutable context for a limited scope and does not need manual cleanup:
private static final ScopedValue<UserContext> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, UserContext.from(request))
.run(() -> handle(request));- When work moves to another executor, pass the context as a method argument or use a framework-supported context propagation feature. Do not assume that
ThreadLocalfollows the task.
Red Flags:
- Does not call
remove()after request processing. - Treats the problem only as a memory leak and misses the risk of data leaking between requests.
- Claims that GC always removes the value because the key is weak.
- Uses
InheritableThreadLocalfor pooled tasks without discussing thread reuse. - Assumes that context automatically follows work submitted to another executor.
Q2 — CompletableFuture composition and error handling
Question: How would you run two independent remote calls concurrently, combine their results, apply a timeout, and handle failures without waiting for each call in sequence?
Expected Depth:
- Uses
thenCombinefor independent calls andthenComposefor dependent calls. - Selects an explicit executor for blocking I/O instead of using the common pool.
- Explains
orTimeoutversuscompleteOnTimeout. - Uses
exceptionally,handle, andwhenCompletefor the correct purposes. - Avoids
get()orjoin()in the middle of the pipeline. - Knows that a timeout on the future does not automatically cancel the underlying operation.
Ideal Answer:
- Start both independent calls before combining them. Manage the executor with try-with-resources and wait only at the application boundary:
try (ExecutorService io = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<Price> price =
CompletableFuture.supplyAsync(this::fetchPrice, io);
CompletableFuture<Stock> stock =
CompletableFuture.supplyAsync(this::fetchStock, io);
Quote quote = price
.thenCombine(stock, Quote::new)
.orTimeout(500, TimeUnit.MILLISECONDS)
.exceptionally(error -> Quote.fallback())
.join();
}- Use
thenComposewhen the next call depends on the first result. UsethenApplyfor a normal value transformation:
CompletableFuture<Account> account = fetchUser(id)
.thenCompose(user -> fetchAccount(user.accountId()))
.thenApply(this::redactPii);- Use
allOffor a group of independent calls. The innerjoin()calls do not wait becauseallOfhas already completed:
List<CompletableFuture<Quote>> futures = symbols.stream()
.map(symbol -> CompletableFuture.supplyAsync(
() -> fetchQuote(symbol), io))
.toList();
CompletableFuture<List<Quote>> all = CompletableFuture
.allOf(futures.toArray(CompletableFuture[]::new))
.thenApply(ignored -> futures.stream()
.map(CompletableFuture::join)
.toList());orTimeoutcompletes the stage with aTimeoutException.completeOnTimeoutreturns a fallback value instead:
CompletableFuture<Quote> resilient = price
.thenCombine(stock, Quote::new)
.completeOnTimeout(Quote.stale(), 300, TimeUnit.MILLISECONDS)
.handle((quote, error) ->
error == null ? quote : Quote.fallback());- These timeout methods do not reliably stop an HTTP or database operation. Configure a timeout in the client as well.
- For platform threads, use a dedicated bounded executor for blocking work. For virtual threads, limit access to scarce resources with a semaphore, connection pool, or rate limiter.
Red Flags:
- Calls
get()orjoin()after each request and therefore runs the requests in sequence. - Has no client timeout or future timeout.
- Uses
whenCompleteas if it replaced a failed result. - Runs blocking I/O on the common
ForkJoinPoolwithout considering starvation. - Confuses
thenApplywiththenComposeand creates nested futures. - Assumes that
orTimeoutstops the remote operation.
Architect
Q3 — Virtual Threads (Project Loom): when and when not
Question: Your team wants to replace all thread pools with virtual threads to "make everything faster." How do you assess this proposal? Where do virtual threads help, and what are the risks?
Expected Depth:
- Explains how virtual threads run on platform carrier threads.
- Knows that they improve the scalability of blocking I/O, not CPU speed.
- Understands pinning in Java 21, especially around
synchronizedblocks and native code. - Creates one virtual thread per task instead of pooling virtual threads.
- Uses explicit limits for database connections and other scarce resources.
- Includes load testing and observability in the migration plan.
Ideal Answer:
- A virtual thread mounts on a platform carrier thread while it runs. For supported blocking operations, it can unmount while it waits. This lets a small number of carrier threads support many concurrent tasks.
- Virtual threads are useful for blocking HTTP, database, and file calls. They do not make CPU-heavy calculations faster because CPU parallelism is still limited by the number of cores.
- Create one virtual thread per task. Do not create a fixed pool of virtual threads:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
requests.forEach(request ->
executor.submit(() -> handle(request)));
} // close() waits for submitted tasks- In Java 21, a virtual thread can pin its carrier while it blocks inside
synchronizedcode or native code. Avoid long blocking operations while holding a monitor:
synchronized (this) {
updateLocalState();
}
callRemoteService(); // blocking work is outside the monitor- If a lock must cover the blocking operation,
ReentrantLockavoids monitor pinning in Java 21. However, holding any lock during a remote call can still reduce throughput and should be reviewed carefully. - Cheap threads do not make downstream resources unlimited. Protect a database or remote API with a connection pool, semaphore, or rate limiter:
private final Semaphore dbPermits = new Semaphore(20);
void handle(Request request) throws InterruptedException {
dbPermits.acquire();
try {
repository.save(request);
} finally {
dbPermits.release();
}
}- Large
ThreadLocalvalues can use too much memory when an application creates many virtual threads. Prefer method arguments orScopedValuewhere suitable. In Java 21,ScopedValueis a preview API. - During migration, test with realistic downstream limits and use JFR. Java 21 can also report pinned threads:
-Djdk.tracePinnedThreads=full- Start with I/O-bound request handlers. Keep CPU-heavy tasks on a bounded executor and compare throughput, latency, memory use, and downstream load before a wider rollout.
Red Flags:
- Claims that virtual threads make CPU-heavy code faster.
- Creates a fixed pool of virtual threads.
- Ignores pinning in Java 21 or does not measure it.
- Has no limit around a small connection pool or rate-limited API.
- Stores large objects in
ThreadLocalfor every virtual thread. - Proposes a full migration without load tests or monitoring.
Q4 — Choosing concurrency tools at scale
Question: Teams use synchronized, atomic classes, ConcurrentHashMap, StampedLock, and queues in different ways. What guidance would you provide for choosing the correct concurrency tool?
Expected Depth:
- Starts with the workload: thread count, read/write ratio, contention, and required invariants.
- Compares blocking locks with atomic operations and understands that lock-free code is not always faster.
- Selects the correct tool for a counter, map, multi-field invariant, or producer/consumer pipeline.
- Describes a deadlock prevention method, such as a fixed lock order.
- Prefers immutable data, ownership, or message passing when possible.
- Understands the goal of structured concurrency and notes that it is a preview API in Java 21.
Ideal Answer:
- First reduce shared mutable state. Prefer immutable values, local state, single ownership, or message passing.
- Use
AtomicLongfor one independent value. UseupdateAndGetfor an atomic read-modify-write operation:
private final AtomicLong balance = new AtomicLong();
balance.updateAndGet(current -> current + delta);- Use
ConcurrentHashMap.mergeorcomputefor an atomic update of one key. A separategetfollowed byputis not atomic:
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.merge(key, 1, Integer::sum);StampedLockcan help when reads are much more common than writes. An optimistic read must be validated before its values are used:
private final StampedLock lock = new StampedLock();
private double x;
private double y;
double distance() {
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.hypot(currentX, currentY);
}- Use one lock for an invariant that covers several fields. If several locks are required, acquire them in a fixed global order:
Account first = a.id() < b.id() ? a : b;
Account second = a.id() < b.id() ? b : a;
first.lock.lock();
try {
second.lock.lock();
try {
transfer(a, b, amount);
} finally {
second.lock.unlock();
}
} finally {
first.lock.unlock();
}- Use a bounded
BlockingQueuefor a producer/consumer pipeline. A full queue slows producers instead of allowing unlimited memory growth:
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1_000);
queue.put(task); // waits when the queue is full
Task next = queue.take(); // waits when the queue is empty- Structured concurrency keeps related task lifetimes inside one scope.
ShutdownOnFailurecancels sibling tasks after a failure. In Java 21, this API is a preview feature:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> loadUser(id));
var permissions = scope.fork(() -> loadPermissions(id));
scope.join().throwIfFailed();
return new Profile(user.get(), permissions.get());
}- Confirm the choice with profiling and load tests. Measure real contention, queue length, wait time, throughput, and latency.
Red Flags:
- Uses
synchronizedfor every case without discussing contention. - Uses
getfollowed byputonConcurrentHashMapand assumes both calls form one atomic operation. - Acquires several locks in different orders.
- Uses an optimistic
StampedLockread without callingvalidate(). - Treats
StampedLockas reentrant or forgets to release a lock infinally. - Uses an unbounded queue without discussing memory limits or back-pressure.
Scoring Rubric
| Level | Pass bar |
|---|---|
| Junior | Explains basic concurrency concepts and uses standard APIs correctly. |
| Senior | Explains trade-offs, finds concurrency risks, and writes correct code. |
| Architect | Defines clear platform guidance and measures behavior under realistic load. |