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 guideA 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:
Future.get() or CompletableFuture.join() from a pool worker and waiting for work that needs the same pool.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:
WAITING, BLOCKED, socket reads, JDBC calls, pool acquisition, and repeated stack traces.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.
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.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:
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.
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:
corePoolSize, maximumPoolSize, current pool size, active count, queue size, and rejection count.To make maximum size meaningful:
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.
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:
RejectedExecutionHandler, keep it fast and thread-safe. Do not block indefinitely inside the handler or recursively resubmit without a limit.Useful production signals include:
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.
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:
Use it safely by:
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.
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:
BLOCKED on the same monitor or parked in ReentrantLock.lock.DataSource.getConnection, or result processing.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:
jstack -l dumps and identify the lock owner and waiters.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.
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."USER_LOCK"; every use of that interned value in the JVM can share the monitor.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:
jstack -l.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.
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:
ReentrantReadWriteLock$WriteLock.lock and many readers in read-lock acquisition or inside the protected operation.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:
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.
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.BLOCKED on monitor N, owned by thread A.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:
jstack -l or jcmd Thread.print -l outputs.thread -> resource -> owner thread.Interview takeaway: A deadlock is proven by a closed ownership-and-wait graph, not merely by seeing many threads in BLOCKED state.
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:
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)
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:
ThreadPoolExecutor calls join() on a future whose next stage uses that executor.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:
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.thenCompose or thenCombine instead of waiting inside a worker.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.
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 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:
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.
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.CompletionException or another wrapper. Production logging often needs its cause unwrapped.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:
exceptionally when every failure has the same value-only fallback and the successful value should pass through unchanged.handle when success and failure both need normalization, metrics, logging, or a fallback based on the result and exception.whenComplete when you need an observation or side effect but want to preserve the original result or failure rather than replace it.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.
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:
orTimeout operates on and returns the same future, completing it exceptionally if the deadline expires.supplyAsync computation has no automatic interruption or cooperative stop just because its future timed out.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:
finally blocks.ExecutorService, retain its task handle and attempt cancel(true) on timeout, while ensuring the task honors interruption. Treat this as cooperative, not forceful.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.
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:
allOf is the chronologically first failure.allOf does not throw and discard the successful tasks.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:
CompletionException for logging while retaining the original cause where possible.outcomes and firstFailure, or map it to an API contract that distinguishes partial success from total failure.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.
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:
finally cleanup.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:
clear() in a finally block, or save and restore the previous map when nesting is intentional.finally. Prefer a tested context-propagation component over ad hoc copying.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.
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:
Future.get() or join() for a child submitted to that same pool, leaving the child unable to start.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.shutdownNow() requests interruption but cannot forcibly stop arbitrary Java code.Use an ordered shutdown procedure:
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.
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:
if (available) { available = false; }; visibility does not prevent two threads from passing the check.Choose the synchronization primitive from the required semantics:
AtomicInteger or AtomicLong for a single numeric atomic update, and use compareAndSet or updateAndGet when the new value depends on the old value.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.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.
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:
ReentrantLock, consuming pool workers while doing no useful work.Investigate and correct it in this order:
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.
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:
CompletionException when observed through join(), so log the exception and its cause chain rather than only the wrapper message.handle, whenComplete, or exceptionally deals with it. A handler that itself throws creates another exceptional future.Make ownership of asynchronous work explicit:
whenComplete or exceptionally at creation and include operation, tenant, and correlation context in the log. Avoid logging the same failure again at every layer.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.
ThreadPoolExecutor from creating threads beyond corePoolSize, allowing latency and memory usage to grow invisibly.CallerRunsPolicy provides backpressure by moving execution into the submitting thread, which can change request latency and transaction boundaries.CompletableFuture does not make blocking work asynchronous, and blocking inside a constrained executor can prevent dependent stages from running.volatile provides visibility and ordering guarantees for a variable, but it does not make read-modify-write operations atomic.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.
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.
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.
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.
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.
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.
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

0 Comments