Articles
Java Lock Interview Questions: Reentrancy, Conditions, and Interruptible Acquisition
Prepare for Java lock interviews with reentrancy, condition loops, interruptible acquisition, timeout budgets, and concrete ownership and cancellation tests.
A worker receives a cancellation request but remains stuck waiting for a lock. Another worker wakes after a signal, finds no work, and removes from an empty queue. These failures come from different contracts: how ownership is acquired, and what must be true before an owner proceeds.
For Java lock interview questions, explain the protected invariant first. Then track acquisition, reentrant holds, predicate checks, waiting, cancellation, and release. A lock protects an agreed set of accesses; it does not make arbitrary code inside the same class safe.
The exercises below are original practice examples, not candidate reports or predictions of an employer's interview. API statements reference Java SE 26 documentation; the saved local fixtures identify their actual runtime separately.
What does reentrancy actually preserve?
A reentrant lock allows its current owning thread to acquire it again. Each successful acquisition adds a hold that must be released. Another thread cannot acquire the lock until the owner has released every hold. Reentrancy changes ownership accounting; it does not remove mutual exclusion.
Official API fact: ReentrantLock tracks the owning thread and hold count. An owner's repeated acquisition increments that count; unlock() decrements it, releasing ownership at zero. Unlocking from a nonowner throws IllegalMonitorStateException. ReentrantLock documentation.
Consider an outer method that acquires a lock and calls a helper that also acquires it. The helper can run without blocking itself, but its finally releases only its own acquisition. When the helper returns, the outer method still owns the lock.
For an original diagnostic, acquire twice on the main thread. Ask a separate thread to call tryLock(): it returns false. Release once and repeat: still false. Release again and repeat: now true. The competing thread must release its own successful acquisition before returning. Testing tryLock() from the owner would miss the distinction because reentrant acquisition succeeds.
This example also exposes an exception-path leak. If the helper throws before releasing its hold, the outer method's single unlock leaves ownership active. Pair acquisition and cleanup at the same lexical boundary instead of counting on some distant caller to finish the job.
Where should the try/finally block begin?
The successful acquisition belongs immediately before the protected try. Every path through the acquired region must release ownership, including early returns and exceptions. Treat the lock as a resource with a clearly bounded lifetime.
lock.lockInterruptibly();
try {
updateProtectedState();
} finally {
lock.unlock();
}
If acquisition throws, this finally is never entered. That is correct: the caller did not acquire a hold to release. Putting an unconditional unlock in a finally that also surrounds acquisition can replace the original interruption with an ownership error.
For tryLock(), branch on its returned boolean. A false result is a normal acquisition outcome, not permission to continue reading protected mutable state. Define the alternative explicitly: reject, return a cached immutable snapshot, queue elsewhere, or retry within a budget.
Official synchronization fact: the Lock interface specifies monitor-equivalent memory synchronization effects for successful locking and unlocking. Failed acquisitions do not provide that guarantee. Lock documentation.
My review rule is to trace every conflicting read and write back to the same locking agreement. A synchronized getter using a different monitor does not automatically participate. Neither does a debug path that reads fields directly because it “only logs” them.
Which acquisition methods respond to interruption?
Choose an acquisition contract before implementing cancellation. lock() waits for ownership. lockInterruptibly() permits acquisition to terminate with InterruptedException. The timed tryLock(timeout, unit) supports both a waiting limit and interruption; untimed tryLock() reports immediate success or failure.
Official ReentrantLock behavior: its interruptible acquisition checks interruption on entry and while waiting, clears the interrupt status when throwing, and gives interruption priority over ordinary or reentrant acquisition. Untimed tryLock() can acquire an available fair lock even when other threads are queued. Method contracts.
In an original cancellation fixture, the main thread holds the lock, starts a worker at an acquisition boundary, and interrupts that worker while retaining ownership. The worker using lockInterruptibly() exits its acquisition path through InterruptedException; it never runs the protected update. Use latches and a bounded join rather than assuming a sleep means the worker reached the right point.
When the surrounding API permits it, propagate the exception. If a boundary cannot throw it, restore interruption with Thread.currentThread().interrupt() and return an explicitly cancelled outcome. Continuing business work after swallowing cancellation defeats the caller's request.
An acquired lock does not make later computation interruptible. A CPU loop, another lock, or an external call has its own cancellation behavior. State the complete path from cancellation request to observable termination, including cleanup and any work already committed.
Why must a Condition wait recheck a predicate?
A condition is a waiting mechanism associated with a lock. The predicate is the application statement being awaited, such as “the queue contains an item” or “the queue has space.” The predicate is durable state; a signal is a prompt to reconsider it.
Official contract: condition waiting releases the associated lock and reacquires it before returning. Spurious wakeups are permitted, so the predicate must be tested in a loop. Condition documentation.
Here is an original one-slot mailbox. The distinction between an empty slot and a stored value is explicit; null messages are rejected.
final class Mailbox {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
private Integer value;
void put(int next) throws InterruptedException {
lock.lockInterruptibly();
try {
while (value != null) notFull.await();
value = next;
notEmpty.signal();
} finally {
lock.unlock();
}
}
int take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (value == null) notEmpty.await();
int result = value;
value = null;
notFull.signal();
return result;
} finally {
lock.unlock();
}
}
}
The waiting consumer temporarily releases ownership so a producer can fill the slot. After reacquiring, it checks again before accessing the value. The producer similarly waits for space. Both state transitions and both predicate checks use the same lock.
The sample needs imports from java.util.concurrent.locks. It illustrates the protocol rather than replacing a production queue with shutdown, capacity, and failure semantics.
Does signal hand the lock to the waiter?
No. Signalling and releasing ownership are separate actions. The signalling thread can continue its critical section; a selected waiter must subsequently reacquire the lock before proceeding.
To expose the mistake deterministically, signal an empty mailbox without adding a value. A correct consumer returns to waiting when it checks the still-false predicate. A consumer using if instead of while can proceed into invalid state. This is a deliberate ordinary signal, not a claim that a test manufactured an operating-system spurious wakeup.
A second trace uses two consumers and one item. Even if both wake, only one can remove the item. The other checks an empty slot after reacquiring and waits again. That is why replacing signal() with signalAll() cannot repair a missing predicate loop.
For multiple waiter categories, explain why the selected condition contains eligible waiters. Separate notEmpty and notFull conditions make that reasoning concrete. If one condition mixes unrelated predicates, waking a single ineligible waiter can leave eligible work asleep; signalAll() is a simpler conservative design, with additional wakeup cost.
The ReentrantLock condition implementation requires ownership for waiting and signalling, and restores the previous hold count after waiting. Do not generalize every implementation detail to every possible Condition implementation. Implementation-specific condition contract.
How do you enforce one timeout budget?
Suppose a caller has 200 milliseconds for lock acquisition plus waiting for an item. Spending 200 milliseconds on each stage allows the total operation to exceed the intended budget. Decide whether the requirement limits acquisition alone, each stage, or the whole operation.
For a total budget, compute a monotonic deadline before acquisition. After acquiring, calculate remaining time. Inside the predicate loop, return a timeout outcome when the remaining budget is exhausted; otherwise pass it to awaitNanos and update the remainder after waking.
Official API fact: awaitNanos returns an estimate of remaining wait time and still requires lock reacquisition before return. Condition timed waiting.
A deadline bounds the intended waiting policy, not an exact wall-clock completion guarantee under scheduling delays or lock reacquisition. In a rehearsal, record acquisition time, predicate-wait time, and total elapsed time separately. A timeout response should also define whether any state mutation happened before the deadline was noticed.
What reentrancy and fairness cannot fix
Reentrancy avoids blocking an owner on that same lock. It does not prevent a cycle where thread A holds lock one and requests lock two while thread B holds lock two and requests lock one. Write the two ownership edges before proposing a repair.
My default repair for a multi-object operation is one documented global lock order, or one lock around the tightly coupled invariant. Timed acquisition can provide an escape path only if partial acquisitions are released and retries are bounded. Immediate retries by every worker can recreate contention.
Fairness is a queueing policy, not proof of low latency or a throughput improvement. Measure the relevant workload and tail waiting times. Inspection methods such as queue-length estimates support diagnosis; they should not become correctness predicates.
Two other lock types invite useful follow-ups. Official API facts: ReentrantReadWriteLock allows a writer to acquire a read lock for downgrading, but a reader cannot upgrade by simply acquiring the write lock. StampedLock is not reentrant, and an optimistic read needs validation. Read/write lock, StampedLock.
The practical consequence is to reconsider the state after changing acquisition strategy. Dropping a read lock before obtaining a write lock creates a window in which another writer can invalidate the earlier decision.
The original hold-count, interruptible-acquisition, and unchanged-predicate signal fixtures passed on OpenJDK 26.0.2.1. They use bounded waits and explicit coordination. These executions witness the described outcomes; they do not establish behavior under every thread schedule or benchmark fairness and latency.
Five exercises for lock reasoning
Reusing a queue, counter, or trigger exercise is intentional: here the follow-up concerns ownership, predicate transitions, and cancellation rather than virtual-thread capacity or general visibility.
| Question | Lock-specific follow-up |
|---|---|
| Design a Thread-Safe Bounded Blocking Queue | Trace an unchanged-predicate wakeup and both waiting sets. |
| Design a Thread-Safe Shared Counter | Explain the lock agreement for reads and writes. |
| Fix Thread-Safe Trigger Synchronization | Place the callback relative to ownership release. |
| Thread-Safe Token-Bucket Rate Limiter | Separate time calculation, state mutation, and acquisition budget. |
| Design a Concurrent Key-Value Store with Batch Writes | Choose a global order for a batch touching several keys. |
Start with the bounded queue, and explain one consumer's entire path: waiting for ownership, checking emptiness, awaiting a change, reacquiring, removing, and releasing. Add cancellation at each blocking boundary. That trace makes the lock contract reviewable.