Senior Java Concurrency Interview Questions – Top 20 with Answers

Senior Java interviews often test whether you can reason from symptoms to concurrency mechanisms. Low CPU with rising latency, stalled requests, rejected work, and incomplete asynchronous pipelines require more than knowing class names: you need to understand queueing, scheduling, visibility, ownership, and failure propagation.

This article frames those production scenarios around ThreadPoolExecutor, Java locks, CompletableFuture, and operational failure patterns. It focuses on safe diagnosis, design trade-offs, and the details that determine whether a mitigation reduces load or merely hides the underlying problem.

All 20 answers below are free. Want the full set, every answer with a worked example?

Core Java Interview Q&A with working code (200 Qs: OOP internals, concurrency, JVM, streams, JDK 8 to 25)

Get the guide

Section 1: Thread Pools

1. A request queue grows while CPU stays low. How can blocked tasks exhaust workers?

A low CPU reading does not mean the pool has capacity: if most worker threads are blocked, all workers can be occupied while doing no CPU work. New requests then wait in the executor queue, often behind tasks blocked on I/O, a lock, a database connection, or another future.

Common ways workers become unavailable include:

  • Synchronous HTTP, JDBC, filesystem, or messaging calls with long or missing timeouts.
  • Waiting for a database connection or another bounded resource.
  • Lock contention, monitor waits, or deadlock.
  • Calling Future.get() or CompletableFuture.join() from a pool worker and waiting for work that needs the same pool.
  • Oversized work units that hold a worker while performing several downstream operations.

The executor counts a thread as active until its submitted task returns. It does not distinguish useful CPU work from waiting. Thus a pool with 100 workers can have 100 active tasks and zero CPU utilisation if every task is waiting for a downstream dependency. More incoming work only increases queueing and latency; it does not create capacity.

Diagnose the bottleneck in production:

  1. Compare active, pool, and queue sizes with task wait and execution times. A full active count plus a growing queue is a strong signal.
  2. Capture several thread dumps. Look for WAITING, BLOCKED, socket reads, JDBC calls, pool acquisition, and repeated stack traces.
  3. Use JFR or executor instrumentation to separate queue delay, run time, and downstream wait time. Check database, HTTP-client, and connection-pool metrics at the same time.
  4. Fix the constrained resource: set realistic connect/read/borrow timeouts, reduce lock scope, avoid blocking joins, and isolate blocking workloads in separately sized, bounded pools.
ThreadPoolExecutor ioPool = new ThreadPoolExecutor(
    20, 80,
    30, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(500),
    new ThreadPoolExecutor.CallerRunsPolicy());

A larger pool can improve throughput for genuinely blocking work, but it can also overload the database or remote service. Size it from measured wait time and downstream capacity, then use bounded queues and admission control rather than treating threads as an unlimited cure.

Interview takeaway: I would explain the low CPU as blocked worker occupancy, then prove the dependency with thread dumps and metrics before changing pool size.

2. How can an unbounded LinkedBlockingQueue hide saturation in a ThreadPoolExecutor?

An unbounded LinkedBlockingQueue can make a ThreadPoolExecutor look healthy while latency and memory deteriorate. Because submissions keep entering the queue, the executor normally never needs to create threads above corePoolSize, so saturation is hidden as queue growth.

The queue is not mathematically infinite: the no-argument constructor uses a capacity of Integer.MAX_VALUE. In practice that is large enough to defer failure until request latency, object retention, or heap usage becomes the incident. Queued Runnable instances retain their arguments and often request-related data, so a backlog can become a memory problem as well as a latency problem.

Typical production signals are:

  • queue.size() rising continuously while active threads remain near corePoolSize.
  • poolSize staying at or near core size even though work is arriving faster than it completes.
  • Increasing queue age, request timeouts, and heap occupancy or longer garbage-collection pauses.
  • Little or no RejectedExecutionException, because the queue accepts almost everything.
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    16, 64,
    30, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),
    namedThreadFactory,
    new ThreadPoolExecutor.AbortPolicy());

A bounded queue makes overload visible: once core threads and the queue are full, the executor can grow toward maximumPoolSize; after that it invokes the rejection policy. The correct capacity depends on an explicit latency and memory budget, not a generic queue size. Monitor queue depth and age, active count, completed count, rejection count, task duration, and downstream saturation.

A safer change is to:

  1. Measure normal burst size and service time, then choose core, maximum, and queue limits together.
  2. Define what rejection means: fail the request, apply backpressure, or hand work to durable messaging.
  3. Load-test the limits, including shutdown and downstream failure, and alert before the queue reaches capacity.
  4. Keep task payloads small and avoid placing unbounded retry loops inside the executor.

A bounded queue may reject or increase caller latency during a burst, but that is controlled failure. An unbounded queue often converts a short overload into a long recovery period or an out-of-memory failure.

Interview takeaway: An unbounded queue does not provide free buffering; it hides saturation by trading an explicit rejection for unbounded latency and memory retention.

3. Why can raising maxPoolSize fail to add threads when ThreadPoolExecutor uses a queue?

ThreadPoolExecutor does not grow toward maximumPoolSize merely because tasks are waiting. Its submission order is: create a thread up to corePoolSize, then queue the task, and only when the queue refuses the task create threads up to maximumPoolSize; after that, reject it.

execute(task)
  if workers < corePoolSize: create worker
  else if workQueue.offer(task): enqueue
  else if workers < maximumPoolSize: create worker
  else: reject

Therefore, with an unbounded LinkedBlockingQueue, offer almost always succeeds and the maximumPoolSize branch is never reached. With a bounded queue, a large queue can still absorb the burst for a long time, delaying growth and increasing latency. Also, changing maximumPoolSize does not move already queued tasks to new workers; new workers appear only as later submissions encounter a full queue, or as implementation housekeeping responds to available work.

When checking an apparent configuration failure, inspect:

  • The actual queue implementation and capacity, not just the configured property name.
  • corePoolSize, maximumPoolSize, current pool size, active count, queue size, and rejection count.
  • Whether a framework created a different executor than the one being tuned.
  • Whether the workload is blocked downstream; more threads may only increase database or remote-service contention.
  • Whether the executor is shutting down or rejecting because of lifecycle state.

To make maximum size meaningful:

  1. Use a bounded queue whose capacity reflects an acceptable waiting budget.
  2. Set core and maximum sizes based on measured task service time and downstream limits.
  3. Instrument queue age and task duration, then test a burst with the real rejection policy.
  4. Separate unrelated workloads so a slow dependency cannot occupy every worker.

A larger maximum is not automatically a fix. It can improve overlap for blocking tasks, but it can also create more concurrent database queries, sockets, lock contention, and context switching. In some systems, a smaller pool with fast rejection gives better end-to-end behavior than a large pool with an overloaded dependency.

Interview takeaway: The key rule is that maximumPoolSize matters only after the work queue is full, so queue choice and capacity must be tuned with the pool limits.

4. A pool rejects work after a traffic spike. How should rejected tasks be handled safely?

A rejected task is an overload or lifecycle signal, not an error to hide. The caller must choose a deliberate outcome: apply backpressure, fail fast, persist the work durably, or retry under a bounded policy; silently dropping the task is safe only when loss is explicitly acceptable.

execute can throw RejectedExecutionException when the executor is saturated or shutting down. The built-in policies have materially different semantics: AbortPolicy throws, CallerRunsPolicy runs inline, DiscardPolicy drops, and DiscardOldestPolicy removes an older queued task before retrying. The last two are dangerous for most business work, especially when ordering or delivery matters.

try {
    executor.execute(() -> process(command));
} catch (RejectedExecutionException ex) {
    if (executor.isShutdown()) {
        return failure("service stopping");
    }
    metrics.rejected();
    return failure("busy; retry later");
}

Design the overload path explicitly:

  1. Return a bounded, observable failure to the caller when the operation is request-driven and can be retried safely.
  2. For asynchronous business work, write the command to a durable broker or database-backed outbox before acknowledging it; do not assume an in-memory queue is durable.
  3. If retrying, use bounded attempts, backoff with jitter, a deadline, and an idempotency key. Otherwise rejection can create a retry storm or duplicate effects.
  4. If using a custom RejectedExecutionHandler, keep it fast and thread-safe. Do not block indefinitely inside the handler or recursively resubmit without a limit.
  5. During shutdown, stop intake first, decide whether to drain or cancel queued work, and expose the outcome.

Useful production signals include:

  • Rejection count and rate by executor and operation.
  • Queue depth and oldest-task age.
  • Shutdown-related versus capacity-related rejections.
  • Retry count, duplicate-operation rate, and durable backlog.

The right policy depends on the contract. Request handling usually benefits from bounded failure and upstream backpressure; critical asynchronous commands usually need durable handoff. A rejection handler cannot create capacity or guarantee delivery by itself.

Interview takeaway: I treat rejection as a designed overload boundary, with explicit durability, retry, idempotency, and shutdown semantics rather than silent loss.

5. How does CallerRunsPolicy alter latency and transaction behavior under load?

CallerRunsPolicy executes the rejected task synchronously in the thread that called execute. That provides backpressure: the caller spends time doing the work instead of submitting more work, but request latency rises and the caller’s execution context can change the task’s transaction and resource behavior.

Under load, pool workers and the queue fill, so servlet, message-listener, scheduler, or application threads begin running tasks inline. Those threads stop accepting or processing their normal work while doing so. This can reduce submission pressure, but it may spread the overload to request latency, consume server request threads, and create long tail latency. If the caller is itself a pool worker, the task runs inline there too, which avoids adding another queued task but can increase nesting and occupancy.

Transaction behavior needs particular care. Thread-bound context is not automatically propagated to an executor thread. In Spring, for example, a transaction managed by @Transactional is associated with the current thread; a normal executor worker does not inherit it. If CallerRunsPolicy runs the task inline, code may observe the caller’s thread-bound transaction and resources and participate in that transaction, depending on the called methods and transaction configuration. That is accidental coupling, not a reliable asynchronous contract.

executor.execute(() -> updateDatabase());
// Usually another thread; transaction context is not inherited.
// Under CallerRunsPolicy, this may run inside the caller's transaction.

Watch for these consequences:

  • Request latency and timeout rates rising only when the executor is saturated.
  • Database locks held longer because inline work runs inside an outer transaction.
  • Unexpected rollback coupling: a failure in inline work can affect the caller’s transaction if it participates.
  • Thread-local security, tracing, or request state being present inline but absent on pool workers.

Use it safely by:

  1. Keeping submitted work short and making its transaction boundary explicit.
  2. Never depending on the policy for transaction or context propagation; pass required data explicitly.
  3. Measuring caller time, queue age, and rejection count, then load-testing timeout and rollback behavior.
  4. Using fail-fast rejection or durable handoff when inline execution would block request threads or violate isolation.

Interview takeaway: CallerRunsPolicy is useful backpressure, but I would treat its inline execution as a latency and transaction-boundary change, not merely a rejection strategy.

Section 2: Locks and Deadlocks

6. Requests freeze with low CPU. How can a lock held during JDBC I/O create this?

A lock held across JDBC I/O turns a slow external dependency into a queue inside the JVM. One thread blocks while the database is slow, and every request needing the same lock waits, so throughput collapses even though CPU usage remains low.

The lock protects the whole method rather than only the in-memory state. While holding it, the thread may wait for a connection from the pool, network round trips, database locks, server-side execution, or result-row processing. Waiting threads consume request threads, and enough blocked requests can exhaust the servlet or application executor. At that point the database may also appear quiet because requests cannot reach it.

Typical production signals include:

  • A thread dump shows many threads in BLOCKED on the same monitor or parked in ReentrantLock.lock.
  • One lock owner is inside a JDBC driver call, DataSource.getConnection, or result processing.
  • Request latency rises while CPU stays low; pool metrics show waiting application threads or exhausted request and JDBC pools.
  • The database may show a single long-running statement or a transaction holding database locks.
lock.lock();
try {
    Snapshot before = state.snapshot();
    // Do not hold the lock during this call.
    Result result = repository.load(before.id());

    lock.lock();
    try {
        state.applyIfCurrent(before.version(), result);
    } finally {
        lock.unlock();
    }
} finally {
    // unlock only if this scope still owns the lock
}

The real fix is to capture an immutable snapshot, release the lock, perform JDBC work, then reacquire briefly and verify that the state is still compatible. In practice, structure the scopes separately rather than using the illustrative pattern above, or use immutable state and compare-and-update logic. This may cause retries or stale-result rejection, but it avoids serializing I/O. A database transaction can still be required for database consistency; it should not be confused with a JVM lock.

Diagnose it by:

  1. Capture several jstack -l dumps and identify the lock owner and waiters.
  2. Compare timestamps with JDBC pool, database activity, and request-thread metrics.
  3. Move external calls outside the critical section, then load-test the longer database path and retry behavior.

Interview takeaway: I would keep the critical section limited to fast in-memory state changes and treat JDBC latency as an external failure, not something protected by a JVM lock.

7. Why can locking on a mutable or interned object break unrelated code?

A lock is safe only when its identity is private, stable, and owned by one synchronization protocol. Locking on a mutable or interned object violates that assumption because other code can obtain the same monitor, or the application can replace the object and silently create a different lock.

Mutating fields inside an object does not itself change that object's monitor identity. The danger is exposing the object as the lock, using a replaceable field such as synchronized (lockField), or synchronizing on an object whose ownership is unclear. If lockField is reassigned, old and new operations synchronize on different monitors. If callers can access the object, they can hold its monitor for unrelated work and block your code.

Especially risky choices are:

  • synchronized (this), because external callers can synchronize on the same instance.
  • A public lock object or a getter that returns the lock.
  • Interned strings such as "USER_LOCK"; every use of that interned value in the JVM can share the monitor.
  • Boxed values or other shared canonical objects, where identity is not an application-owned resource.
private final Object stateLock = new Object();

void update(State next) {
    synchronized (stateLock) {
        state = next;
    }
}

Use a private final lock per independent state domain. A private ReentrantLock is another option when timed acquisition, interruption, or diagnostics are useful. If locking must coordinate across processes, a JVM monitor is the wrong mechanism; use database constraints, transactions, or a distributed coordination design. The trade-off is that a private lock cannot coordinate with arbitrary external code, so the protected protocol must be explicit and consistently applied.

When investigating unexpected blocking:

  1. Find the monitor identity and owner in jstack -l.
  2. Search for every synchronization site using that object, including library and callback code.
  3. Replace shared or replaceable lock expressions with a private, stable lock and document its protected state.

Interview takeaway: I would synchronize on a private, final lock object, never on an object whose identity or monitor can be controlled by unrelated code.

8. How does ReentrantReadWriteLock create writer starvation, and how can fairness change it?

A non-fair ReentrantReadWriteLock can starve a writer because new readers may acquire the read lock while a writer is already waiting. A fair lock uses queue order more strictly, so waiting writers are given a chance, but fairness adds coordination overhead and can reduce read throughput.

With the default non-fair policy, readers are often allowed to barge when the lock is available for shared acquisition. If reads arrive continuously, each small group can keep the read side occupied and the exclusive writer may wait indefinitely in practice. This is not a database-style guarantee that a writer must eventually run. A reader can also be delayed behind a queued writer, depending on the lock's state and policy.

Look for:

  • A growing writer queue or write-lock acquisition latency while read throughput remains high.
  • Thread dumps showing writers waiting in ReentrantReadWriteLock$WriteLock.lock and many readers in read-lock acquisition or inside the protected operation.
  • Long read critical sections, which make both starvation and ordinary contention worse.
ReentrantReadWriteLock lock =
    new ReentrantReadWriteLock(true); // fair mode

try {
    lock.writeLock().lock();
    try {
        rebuildIndex();
    } finally {
        lock.writeLock().unlock();
    }
}

Constructing the lock with true enables fair mode; it cannot be switched later. Fair mode generally prevents barging by readers when writers are queued and gives waiting threads a more predictable order, but it is not free: context switching and queue discipline can lower peak throughput, especially for tiny read sections. First shorten read sections, remove I/O from them, and consider immutable snapshots or copy-on-write. If writes are rare but must meet a latency bound, fair mode is a reasonable trade-off. Measure both writer wait time and total throughput rather than assuming fairness is faster.

A practical diagnosis is:

  1. Record read and write acquisition latency separately, not just method duration.
  2. Take repeated thread dumps and identify whether readers continuously hold or reacquire the lock.
  3. Test shorter critical sections and fair mode under a representative reader/writer mix.

Interview takeaway: I would choose fair mode when bounded writer waiting matters, accepting some throughput loss, and still minimize the work done under either lock.

9. A deadlock appears only under load. How would you prove the cycle with jstack?

I would prove a deadlock by mapping each blocked thread to the resource it waits for and the thread that owns that resource. A cycle such as A waits for B's monitor while B waits for A's monitor is proof; high latency or many blocked threads alone is not.

Take a live dump with jstack -l PID. The -l option includes extra lock information, including ownable synchronizers where available. The JVM often prints a Found one Java-level deadlock section listing the involved threads, the lock each waits for, and its owner. The individual stack traces then show the application call sites that acquired the first lock and requested the second.

jstack -l 24817 > /tmp/app.threads
# Take two more dumps several seconds apart
jstack -l 24817 > /tmp/app.threads.2
jcmd 24817 Thread.print -l > /tmp/app.threads.3

In the dump, connect these facts:

  • BLOCKED on monitor M, owned by thread B.
  • Thread B is BLOCKED on monitor N, owned by thread A.
  • The stack frames show the lock acquisition order at both sites.
  • For ReentrantLock, inspect ownable synchronizer information and the lock-related stack frames, not only monitor sections.

Repeat the dumps because a transient wait can look suspicious, and correlate thread names with request IDs, logs, and lock metrics. Also check whether the apparent freeze is actually JDBC pool exhaustion, a long database lock, or a thread parked on a future; those have no circular ownership cycle. In code, preserve a consistent global lock order, avoid calling external or callback code while holding a lock, and use timed tryLock where failure can be handled. A timeout detects contention and can break a wait, but it does not replace fixing the ordering bug.

To demonstrate the cycle in an incident report:

  1. Capture several jstack -l or jcmd Thread.print -l outputs.
  2. Write each edge as thread -> resource -> owner thread.
  3. Show the closed loop and map each edge to source code before changing lock order.

Interview takeaway: A deadlock is proven by a closed ownership-and-wait graph, not merely by seeing many threads in BLOCKED state.

10. Why can StampedLock optimistic reads return unsafe data without validate()?

A StampedLock optimistic read is only a validation protocol, not a read lock. The reader may observe a writer changing related fields while it reads them, so using the values before calling validate() can produce an inconsistent or unsafe snapshot.

tryOptimisticRead() returns a stamp without excluding writers. The reader copies the fields, then calls validate(stamp). If an exclusive write occurred after the stamp was issued, validation returns false and the copied result must be discarded or reread under a real read lock. Reading several fields is important: each individual read might look plausible while the combination represents no state that ever existed. A writer can update coordinates, a version, or a collection reference between those reads.

long stamp = lock.tryOptimisticRead();
Point p = point;       // copy all required state
long version = version;
if (!lock.validate(stamp)) {
    stamp = lock.readLock();
    try {
        p = point;
        version = this.version;
    } finally {
        lock.unlockRead(stamp);
    }
}
return new Snapshot(p, version);

The validation must happen after the reads and before the result is trusted. If validation fails, retry under readLock() or restart the optimistic attempt. The fields should still be accessed according to the lock's protocol; optimistic mode does not make arbitrary unsynchronized mutation safe, and mutable objects returned from the snapshot can remain vulnerable after the lock is released. Prefer immutable values or defensive copies.

Use optimistic reads when:

  • Reads are frequent and usually uncontended.
  • The read operation is short and can be retried cheaply.
  • The copied state is immutable or fully validated before publication.

StampedLock is not reentrant, so code that already holds a write lock must not accidentally reacquire it through a nested call. Its performance benefit comes with more complicated correctness rules than ReentrantReadWriteLock; a regular read lock is often the better choice when retries are expensive.

Interview takeaway: An optimistic read is valid only after copying the required state and successfully validating the stamp; otherwise I fall back to a real read lock.

Preparing for a full interview loop? Full interview kit (600 Qs: Java, Spring Boot, database, DevOps, cloud, system design, microservices)

Section 3: CompletableFuture

11. A CompletableFuture chain never finishes. How can join() inside its pool deadlock it?

join() can deadlock a chain when pool threads block waiting for continuations that are queued to that same exhausted pool. The classic cycle is: every worker waits for a future, while no worker remains available to run the stage that completes that future.

The mechanism is usually one of these:

  • A task submitted to a bounded ThreadPoolExecutor calls join() on a future whose next stage uses that executor.
  • A fixed pool is full of parent tasks, so their dependent stages sit in the executor queue indefinitely.
  • A dependency cycle exists: stage A waits for B, while B waits directly or indirectly for A.
  • ForkJoinPool can sometimes compensate for blocking or help execute tasks during join(), but that is not a correctness guarantee. Code must not rely on it, especially when the executor is a regular bounded pool.
CompletableFuture<Result> result = CompletableFuture
    .supplyAsync(this::load, executor)
    .thenApplyAsync(this::transform, executor);

// Dangerous when called by a task already running on executor:
Result value = result.join();

Diagnose and fix it as follows:

  1. Take thread dumps and look for many pool workers parked in CompletableFuture.join, get, or a latch wait. Inspect the executor's active count and queue, then map each blocked task to the future it needs.
  2. Prefer composition: return the future and use thenCompose or thenCombine instead of waiting inside a worker.
  3. If blocking is unavoidable, isolate it on a pool sized for that blocking workload, add queue limits and timeouts, and keep CPU stages on a separate executor.
  4. Increase pool size only as a controlled mitigation. It can hide the dependency problem and may simply move saturation to a database, HTTP client, or downstream service.

Also inspect timeout and cancellation paths: a timed-out caller may leave the original work occupying the same pool, making the apparent deadlock recur under load.

Interview takeaway: I avoid blocking inside executor tasks; I compose futures, or isolate unavoidable blocking behind a separately bounded executor.

12. Why can thenApplyAsync overload the common ForkJoinPool with blocking work?

thenApplyAsync without an executor normally uses the CompletableFuture default asynchronous execution facility, which is the common ForkJoinPool. If the function performs blocking I/O, many such stages can consume the pool's workers and prevent unrelated asynchronous work from progressing.

Why this overloads a shared pool:

  • The common pool is intended primarily for CPU-oriented fork/join work, with parallelism commonly related to available processors rather than the number of outstanding requests.
  • A blocked worker does not contribute CPU but still occupies a pool slot while waiting for a socket, database connection, lock, or remote response.
  • The common pool is process-wide. Library code, framework code, and other application features may compete with the same workers.
  • Queued continuations can create a feedback loop: requests remain incomplete, more requests arrive, and each adds more blocked stages.
// The function runs on the default async facility:
CompletableFuture<Data> f = fetch()
    .thenApplyAsync(this::readFromDatabase);

Executor blockingIo = Executors.newFixedThreadPool(32);
CompletableFuture<Data> safer = fetch()
    .thenApplyAsync(this::readFromDatabase, blockingIo);

In production, confirm the diagnosis with thread dumps, JFR executor and socket activity, common-pool metrics, and blocked-thread or queue-depth metrics. Then:

  1. Classify each stage as CPU-bound, blocking, or potentially blocking.
  2. Give blocking stages an explicitly owned executor with a bounded admission policy, and configure the client pool and database pool consistently with it.
  3. Apply backpressure: limit in-flight requests rather than allowing an unbounded future graph.
  4. Keep CPU transformations on a CPU-sized executor. On Java 21, virtual threads can make waiting cheaper for suitable blocking tasks, but they do not remove downstream connection limits or the need for admission control.

Changing every call to thenApplyAsync is not a fix; it can move work to the common pool without changing its blocking behavior. Also remember that an explicit executor makes ownership and monitoring visible.

Interview takeaway: I use the common pool for short CPU work, and inject a separately bounded executor for blocking stages with backpressure.

13. How does exceptionally differ from handle when a fallback needs the result?

exceptionally is an error-only recovery stage: it receives the failure and must return a replacement value. handle receives either the successful result or the failure and always runs, so it is the right choice when a fallback decision needs to inspect the original result.

The distinction matters in several cases:

  • exceptionally does not run after success, so it cannot transform or validate a successful value as part of the same recovery function.
  • handle receives (value, throwable); one is normally non-null, although a successful computation can legitimately produce a null value.
  • If the upstream failed, the throwable may be a CompletionException or another wrapper. Production logging often needs its cause unwrapped.
  • An exception thrown by the recovery function makes the returned future fail again. A fallback should not silently turn every error into a success unless that is the policy.
CompletableFuture<Response> recovered = call()
    .exceptionally(ex -> cachedResponse());

CompletableFuture<Response> inspected = call()
    .handle((response, ex) -> {
        if (ex != null) return fallbackBasedOn(ex);
        return adjustSuccessfulResponse(response);
    });

Choose the operator deliberately:

  1. Use exceptionally when every failure has the same value-only fallback and the successful value should pass through unchanged.
  2. Use handle when success and failure both need normalization, metrics, logging, or a fallback based on the result and exception.
  3. Use whenComplete when you need an observation or side effect but want to preserve the original result or failure rather than replace it.
  4. Test both paths, including a null success and an exception from the fallback itself.

For asynchronous recovery that itself returns a future, use exceptionallyCompose where available, or compose an explicit recovery branch; returning a future from exceptionally merely produces a nested future.

Interview takeaway: I use exceptionally for a simple failure replacement, and handle when the fallback must see both the value and the error.

14. A timeout returns quickly, but CPU keeps rising. How can orTimeout leave work running?

orTimeout changes the CompletableFuture's observable completion to a TimeoutException; it does not stop a supplier that is already running. The caller returns quickly, but the original task can continue consuming CPU, threads, connections, and downstream capacity.

The important separation is:

  • A future represents completion notification, not a guaranteed operating-system task cancellation mechanism.
  • orTimeout operates on and returns the same future, completing it exceptionally if the deadline expires.
  • A running supplyAsync computation has no automatic interruption or cooperative stop just because its future timed out.
  • Even explicit cancel(true) is best effort for CompletableFuture; it does not guarantee that arbitrary underlying work is interrupted.
CompletableFuture<Result> work = CompletableFuture
    .supplyAsync(() -> calculate(input), worker);

work.orTimeout(500, TimeUnit.MILLISECONDS)
    .exceptionally(ex -> fallback());
// calculate(input) may still be running after the timeout.

Diagnose and fix it in layers:

  1. Correlate timeout counts with executor active threads, CPU samples, task duration, connection usage, and thread dumps. Look for timed-out request IDs still executing.
  2. Propagate a deadline or cancellation token into the computation, and check it inside loops and between expensive operations. Stop safely and release resources in finally blocks.
  3. If the work is an interruptible task submitted through an ExecutorService, retain its task handle and attempt cancel(true) on timeout, while ensuring the task honors interruption. Treat this as cooperative, not forceful.
  4. Use separate executors and concurrency limits so expired work cannot starve fresh requests. Align HTTP, database, and application deadlines.

For non-interruptible CPU work, the reliable containment mechanism is bounded admission or process isolation; a timeout can protect the caller, but it cannot reclaim CPU from code that refuses to stop.

Interview takeaway: A timeout is a completion policy, not cancellation, so I propagate deadlines and design the underlying task to stop cooperatively.

15. How should allOf expose the first failure while preserving other task outcomes?

allOf completes only after all supplied futures complete, and it reports that at least one failed without providing a useful collection of every result. Wrap each task with handle, record the first failure explicitly, and let the wrapper preserve either a value or an error for every task.

This design gives separate answers to separate questions:

  • The aggregate can signal that the batch had a failure.
  • An atomic reference can record the first failure observed by the completion callbacks; do not assume the exception exposed by allOf is the chronologically first failure.
  • Each wrapped future completes normally with an outcome, so collecting outcomes after allOf does not throw and discard the successful tasks.
  • Do not cancel remaining tasks if their outcomes are required. Cancel only when fail-fast behavior is intentional and the work supports cooperative cancellation.
record Outcome<T>(T value, Throwable error) {}

AtomicReference<Throwable> first = new AtomicReference<>();
List<CompletableFuture<Outcome<Result>>> wrapped = tasks.stream()
    .map(task -> task.handle((v, ex) -> {
        if (ex != null) first.compareAndSet(null, ex);
        return new Outcome<>(v, ex);
    }))
    .toList();

CompletableFuture.allOf(wrapped.toArray(CompletableFuture[]::new)).join();
List<Outcome<Result>> outcomes = wrapped.stream().map(CompletableFuture::join).toList();

In a production batch, I would then:

  1. Attach task identity, duration, and cancellation state to each outcome so failures remain actionable.
  2. Unwrap CompletionException for logging while retaining the original cause where possible.
  3. Return a batch result containing outcomes and firstFailure, or map it to an API contract that distinguishes partial success from total failure.
  4. Define whether “first” means first callback observed or a deterministic input order. Concurrency cannot guarantee chronological ordering without timestamping or another explicit policy.

The wrapper also avoids calling join on failed original tasks during collection: every wrapper has converted failure into data, while allOf still provides a clear synchronization point.

Interview takeaway: I use allOf as the barrier, but convert each task’s success or failure into an outcome so the first failure and every other result remain available.

Section 4: Production Failures

16. Logs show another user's request ID. How can MDC leak across pooled threads?

MDC is usually backed by a ThreadLocal, and a pooled worker keeps that thread after the request ends. If request cleanup does not remove the MDC values, the next request assigned to that worker can inherit the previous request ID and log it.

try {
    MDC.put("requestId", requestId);
    return chain.doFilter(request, response);
} finally {
    MDC.clear();
}

The common failure modes are:

  • A servlet filter, interceptor, or manually submitted task sets MDC but has no finally cleanup.
  • A task restores a previous context incorrectly, or copies a map into a worker without clearing it afterward.
  • Work moves to another executor through CompletableFuture, so the request thread's MDC is not available there; developers then add propagation but forget cleanup.
  • InheritableThreadLocal is used and appears to work for newly created threads, but it does not reliably propagate changing request context to already-created pool workers.

To diagnose and fix it:

  1. Compare the suspicious request ID with the worker thread name, request access logs, and trace/span IDs. Repeated contamination on one pool thread strongly suggests leaked thread-local state.
  2. Inspect every boundary that sets MDC, including filters, executor decorators, messaging listeners, and scheduled jobs. Require clear() in a finally block, or save and restore the previous map when nesting is intentional.
  3. Wrap submitted tasks so they capture the context at submission and restore the worker's original context in finally. Prefer a tested context-propagation component over ad hoc copying.
  4. Add a test that reuses a small executor: run a task with one ID, then a task that asserts the MDC is empty or has the newly supplied ID.

Clearing MDC prevents leakage, but it does not solve correlation across asynchronous stages by itself. Explicit propagation improves traceability while adding copying overhead and the risk of propagating stale or sensitive context, so propagate only the fields that are needed.

Interview takeaway: Treat MDC as request-scoped thread-local state: explicitly propagate it across executor boundaries and always restore or clear it in finally.

17. A graceful shutdown hangs. How can awaitTermination interact with dependent tasks?

awaitTermination only waits for tasks that the executor is tracking to finish; it does not resolve a dependency deadlock. A shutdown can therefore hang when a running task waits for another task that is queued behind it in the same saturated pool, or when a task keeps submitting or waiting on dependent work.

executor.shutdown();
if (!executor.awaitTermination(timeout, unit)) {
    List<Runnable> abandoned = executor.shutdownNow();
    // Restore interruption if this code is itself interrupted.
}

Typical dependency problems include:

  • A pool has one or a few workers. Each running task calls Future.get() or join() for a child submitted to that same pool, leaving the child unable to start.
  • A task submits follow-up work during shutdown. shutdown() rejects new submissions, so the parent may wait for a future that can never complete; the rejection may be hidden by poor error handling.
  • A task ignores interruption while blocked on I/O, a lock, or an uncooperative library. shutdownNow() requests interruption but cannot forcibly stop arbitrary Java code.
  • The executor being awaited is not the executor doing the dependent work. For example, the application pool may finish while a common-pool or scheduler task remains active.

Use an ordered shutdown procedure:

  1. Stop accepting new requests and stop producers, schedulers, and message consumers before shutting down the worker pool.
  2. Make task dependencies explicit. Avoid blocking joins inside a bounded pool; compose stages asynchronously or reserve separate capacity for blocking operations.
  3. Record active thread stacks, executor queue depth, completed-task counts, and rejected submissions when the timeout expires. A thread dump usually shows the parent waiting and the missing child queued.
  4. Use finite timeouts and propagate cancellation. Make tasks interruptible, close resources during cancellation, and define what partial work means before choosing shutdownNow().

A longer await timeout may mask the symptom but cannot fix a dependency cycle or starvation. Separate executors can isolate blocking or dependent work, at the cost of more threads, queues, and capacity limits that must be tuned and monitored.

Interview takeaway: A graceful shutdown must stop producers and resolve the task dependency graph; awaitTermination cannot make a queued child run or interrupt code that ignores cancellation.

18. Why can volatile visibility fail to make a compound counter update safe?

volatile makes each read and write visible and prevents certain reorderings, but it does not make a read-modify-write operation atomic. Two threads can both read the same value, increment it, and overwrite each other, so a volatile counter can lose updates.

// Not an atomic increment
volatile int count;
count = count + 1;

// Atomic update
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();

The race is an interleaving, not a visibility failure:

  • Thread A reads 10.
  • Thread B reads 10.
  • Both calculate 11 and write 11. The final value is 11 even though two increments occurred.
  • A volatile reference can also expose a newly published object, but it does not make several fields change as one transaction or preserve an invariant across separate variables.
  • The same issue appears with checks such as if (available) { available = false; }; visibility does not prevent two threads from passing the check.

Choose the synchronization primitive from the required semantics:

  1. Use AtomicInteger or AtomicLong for a single numeric atomic update, and use compareAndSet or updateAndGet when the new value depends on the old value.
  2. Use LongAdder for very high-contention statistics where sum() is acceptable as an observation, not as a precisely synchronized value used for a limit or decision.
  3. Use a lock or synchronized block when multiple fields must change together or a larger invariant must hold. Keep the protected region small.
  4. If the counter is only local to a request or task, avoid sharing it; aggregate per-thread or per-task results after the concurrent work completes.

Atomic classes avoid lost updates for their operations, but they do not automatically make a sequence of separate operations atomic. Diagnose this with concurrent stress tests and invariant checks, not with a test that merely observes eventual visibility.

Interview takeaway: Use volatile for visibility of an independent value, but use an atomic operation or lock when correctness depends on an indivisible update.

19. A thread logs to a slow appender while holding a lock. How can this spread latency?

Logging while holding a lock extends the lock's critical section to include formatting, queueing, and possibly slow I/O. If the appender blocks, every thread needing that lock waits, and the resulting lock convoy can turn one slow sink into broad request latency.

State snapshot snapshot;
synchronized (lock) {
    updateState();
    snapshot = state.copy();
}

// No application lock is held here.
logger.info("Updated state: {}", snapshot);

The amplification path often looks like this:

  • One request owns a lock and writes to a synchronous file, console, network, or database-backed appender.
  • Other request threads accumulate on the same monitor or ReentrantLock, consuming pool workers while doing no useful work.
  • If the appender has a bounded asynchronous queue, a full queue may make the producer block or discard events, depending on the appender and its configuration.
  • A logging call can also contend on logger internals or trigger expensive message construction, making the critical section longer even when the destination is healthy.

Investigate and correct it in this order:

  1. Capture thread dumps during the incident. Look for many threads waiting on the same lock and identify whether its owner is inside a logging call or appender write.
  2. Measure appender latency, queue depth, dropped events, disk or network saturation, and log volume. Include lock-wait time in request traces and executor metrics.
  3. Snapshot the minimal state under the lock, release it, then log outside the critical section. Use parameterized logging so expensive formatting can be avoided when the level is disabled.
  4. Use an asynchronous appender only after defining queue-full behavior. It can isolate request threads, but a bounded queue still needs a policy: block, drop, or shed lower-value events.

Do not remove useful logs blindly: moving them outside the lock can make the message observe state after another update, so log an immutable snapshot or an event containing the relevant version. Also keep logs out of lock-order-sensitive error paths where reentrant logging can complicate failures.

Interview takeaway: A lock should protect state, not a slow logging destination; capture the needed facts under the lock and perform logging after releasing it.

20. Errors vanish from logs. How can fire-and-forget CompletableFuture lose exceptions?

A fire-and-forget CompletableFuture stores an exception in its completion state, but nothing reports that state unless a later stage is observed or a terminal handler is attached. If the returned future is discarded, the operation can fail without reaching application logs.

CompletableFuture.runAsync(this::rebuildIndex, executor)
    .whenComplete((ignored, error) -> {
        if (error != null) {
            logger.error("Index rebuild failed", error);
        }
    });

Important details are easy to miss:

  • Exceptions thrown by the supplier normally complete the future exceptionally; they are not automatically printed like an uncaught exception from a directly started thread.
  • An exception can be wrapped in CompletionException when observed through join(), so log the exception and its cause chain rather than only the wrapper message.
  • A failure in one stage skips ordinary dependent stages until an error-handling stage such as handle, whenComplete, or exceptionally deals with it. A handler that itself throws creates another exceptional future.
  • Executor rejection, submission-time failures, cancellation, and failures before a logging handler is attached also need a defined reporting path.

Make ownership of asynchronous work explicit:

  1. For genuinely detached work, attach whenComplete or exceptionally at creation and include operation, tenant, and correlation context in the log. Avoid logging the same failure again at every layer.
  2. For work that affects the request or transaction result, return the future and compose it; let the caller await it or translate the failure into the appropriate response.
  3. Keep a reference or use an application-managed job abstraction when shutdown, retries, cancellation, and status reporting matter. Do not rely on garbage collection to reveal failures.
  4. Test both supplier failures and executor rejection, and verify that shutdown waits for or cancels owned futures rather than abandoning them.

CompletableFuture is a coordination primitive, not an error-reporting system. A terminal observation gives failures an owner, while detached work needs explicit lifecycle, logging, and cancellation policy; otherwise errors may be silent by design.

Interview takeaway: Every fire-and-forget future needs an explicit terminal error handler, while important work should be returned and observed instead of detached.

Key Takeaways

  • Blocked tasks consume pool workers even when the process has plenty of unused CPU capacity.
  • An unbounded work queue can prevent ThreadPoolExecutor from creating threads beyond corePoolSize, allowing latency and memory usage to grow invisibly.
  • Rejected work needs an explicit policy that preserves correctness, backpressure, and observability rather than silently discarding requests.
  • CallerRunsPolicy provides backpressure by moving execution into the submitting thread, which can change request latency and transaction boundaries.
  • Locks held during JDBC or other blocking I/O can turn a small dependency delay into widespread request stalls.
  • A consistent lock-ordering rule and thread-dump evidence are essential for distinguishing deadlock from ordinary contention.
  • CompletableFuture does not make blocking work asynchronous, and blocking inside a constrained executor can prevent dependent stages from running.
  • Timeout stages usually affect the future's result, not necessarily the underlying task, so cancellation and resource ownership must be designed separately.
  • volatile provides visibility and ordering guarantees for a variable, but it does not make read-modify-write operations atomic.
  • Thread-local diagnostic context and asynchronous exceptions must be explicitly managed because pooled threads outlive individual requests.

Frequently Asked Questions

How should I choose a queue and pool size for a production executor?

Start with workload characteristics rather than a universal formula. CPU-bound work generally needs a pool near the available processing capacity, while blocking work needs measured limits and careful protection for the blocking dependency. Prefer a bounded queue when overload must be visible, define a rejection policy, and monitor active threads, queue depth, execution time, rejection count, and downstream saturation.

What is the safest way to investigate a suspected concurrency stall?

Capture several thread dumps separated by a short interval, then compare thread states, lock ownership, and stack traces. Look for cycles involving monitors or java.util.concurrent synchronizers, repeated blocking calls, and a common downstream dependency. Correlate the dumps with pool metrics, request traces, database activity, and garbage-collection data so contention is not confused with a broader pause.

When should asynchronous code use a dedicated executor?

Use a dedicated executor when the work has different blocking behavior, latency requirements, or failure impact from the rest of the application. Database calls, remote calls, and file operations should not casually share an executor with short CPU stages. Bound the executor, size it for the dependency, and make overload and shutdown behavior explicit. Pass the executor to async methods instead of relying on the common pool.

Does a `CompletableFuture` timeout stop the underlying operation?

Usually, no. Methods such as orTimeout and completeOnTimeout arrange what the future reports after a deadline; they do not automatically interrupt or stop arbitrary work already running. The underlying operation needs its own timeout, cancellation support, and cleanup. For JDBC, HTTP, or messaging clients, configure the relevant client-side deadlines and release resources when cancellation is observed.

How can request context be kept safe across asynchronous stages?

Treat context such as MDC values as request-scoped data, not as permanent thread state. Capture only the values needed by a stage, restore them around execution, and clear them in a finally block. Prefer explicit context passing when practical. Also ensure every terminal asynchronous path observes failures, because an unobserved exceptional future can leave both diagnostics and business outcomes incomplete.

Conclusion

Reliable Java concurrency starts with making limits and ownership visible. A pool owns worker capacity, a queue represents waiting work, a lock protects a defined state, and a future represents an outcome—not necessarily a running task that can be stopped. When diagnosing incidents, combine code semantics with thread dumps, executor metrics, downstream timeouts, and request context. The strongest interview answers explain both the immediate mechanism and the production-safe design: bounded work, explicit backpressure, consistent lock ordering, appropriate executors, deliberate cancellation, and complete error observation.

Which question would you find the hardest? Tell me in the comments.

Explore More Java Interview Resources

Core Java Interview Q&A with working code (200 Qs: OOP internals, concurrency, JVM, streams, JDK 8 to 25)
https://topmate.io/logicbrace/2288636

Full interview kit (600 Qs: Java, Spring Boot, database, DevOps, cloud, system design, microservices)
https://topmate.io/logicbrace/2294498

Complete Java Full Stack Developer Kit (900 Qs: Core Java, Spring Boot & Microservices, Cloud/DevOps/System Design, React & Angular)
https://topmate.io/logicbrace/2306382

Senior Java Concurrency Interview Questions – Top 20 with Answers

Post a Comment

0 Comments

Close Menu