What causes a deadlock and how do you prevent it?
Two threads each holding a lock the other needs, in a cycle nobody can break. The practical cause is almost always inconsistent lock ordering, and the practical fix is a global order every thread follows. When ordering is impossible, tryLock with a timeout turns a permanent hang into a recoverable failure.
The Answer
Say this in the room. 45 seconds.
- A deadlock is a cycle in the waits-for graph: thread 1 holds A and wants B, thread 2 holds B and wants A. Neither can proceed and neither will give up.
- Textbook answer is the four Coffman conditions — mutual exclusion, hold-and-wait, no pre-emption, circular wait. Only the last one is practical to attack.
- In real code the cause is almost always inconsistent lock ordering, and it is usually invisible in review because the two orderings live in different files.
- Prevention: a global lock order. Sort by any stable key — account id, primary key, name — and have every thread take locks in that order. A cycle then cannot form.
- When ordering is impossible, use
ReentrantLock.tryLockwith a timeout, so the thread backs out instead of waiting forever. - Detection is free:
jstack,jcmd Thread.print, orThreadMXBean.findDeadlockedThreads()from inside. It names the threads and the locks. - A deadlocked
synchronizedthread cannot be rescued — you cannot interrupt it, time it out, or unstick it. The process has to restart.
Understand It
A real deadlock, and the JVM finding it
Two threads, two accounts, opposite lock order. The latch is there so this happens every run rather than most runs — the underlying bug needs no latch, it just needs unlucky timing.
// Two threads, two accounts, opposite lock order. The latch removes the luck:
// both threads hold one lock before either reaches for the second.
var a = new Account("account-A");
var b = new Account("account-B");
var bothHoldOne = new CountDownLatch(2);
daemon("transfer-A-to-B", () -> {
synchronized (a) { bothHoldOne.countDown(); await(bothHoldOne); synchronized (b) { } }
});
daemon("transfer-B-to-A", () -> {
synchronized (b) { bothHoldOne.countDown(); await(bothHoldOne); synchronized (a) { } }
});
// Ask the JVM. This is the same detector jstack and jcmd use.
var names = Map.of(lockString(a), a.id, lockString(b), b.id);
for (String line : deadlockReport(names)) System.out.println(" " + line); transfer-A-to-B is waiting for account-B, held by transfer-B-to-A
transfer-B-to-A is waiting for account-A, held by transfer-A-to-BThat output came from ThreadMXBean.findDeadlockedThreads(), which is the same detector behind jstack — it looks for a cycle in the graph of "this thread is waiting for a lock that thread owns". A cycle is a proof, not a guess, which is why a deadlock is one of the few production hangs you can diagnose with certainty in about thirty seconds.
Note what the code does not contain: any bug you would catch in review. Each thread locks two accounts and transfers between them. The two orderings are only wrong relative to each other, and in a real codebase they live in different classes written by different people.
The latch is the honest part of this example. Without it the deadlock still happens, just not reliably — which is exactly why this class of bug survives testing and appears under production load, when the interleaving finally lines up.
The fix is an order, not more locking
If every thread takes locks in the same order, a cycle cannot form. Any total order works as long as everyone agrees on it.
// The same two transfers, run twice. Only the lock ORDER changes.
// Fresh accounts each time: the deadlocked threads from the first run hold
// their locks forever, so reusing them would prove nothing about the second.
System.out.println(" each thread locks its own account first : "
+ transferBothWays(new Account("account-A"), new Account("account-B"), false));
System.out.println(" both threads lock the lower id first : "
+ transferBothWays(new Account("account-A"), new Account("account-B"), true)); each thread locks its own account first : still stuck after 2s
both threads lock the lower id first : both transfers completedThe whole change is three lines: compare the two ids, and swap so the lower one is locked first. The code still holds both locks, still holds them at once, and still does the same work — the fourth Coffman condition, circular wait, is simply no longer possible.
This is the fix worth knowing by heart, because it is the one that scales. It needs no timeouts, no retries, no detection, and no coordination between the threads. It only needs a key that is stable and total — an account number, a primary key, a file path. Anything derived from object identity is neither, which is the trap in the Reference section below.
When you cannot impose an order
Sometimes the locks are not yours to order — a third-party library, or a lock acquired inside a callback. ReentrantLock.tryLock with a timeout converts an unbounded wait into a decision.
// Same opposite-order transfers, but with a lock that can give up.
var accountA = newLock();
var accountB = newLock();
var bothHoldOne = new CountDownLatch(2);
var done = new CountDownLatch(2);
var report = new CopyOnWriteArrayList<String>();
daemon("transfer-1", () -> {
accountA.lock();
try {
bothHoldOne.countDown();
await(bothHoldOne);
if (tryLockFor(accountB, 200)) {
try { report.add("transfer-1 took both and completed"); } finally { accountB.unlock(); }
} else {
report.add("transfer-1 gave up on account-B after 200ms and released account-A");
}
} finally { accountA.unlock(); done.countDown(); }
});
daemon("transfer-2", () -> {
accountB.lock();
try {
bothHoldOne.countDown();
await(bothHoldOne);
if (tryLockFor(accountA, 3_000)) {
try { report.add("transfer-2 took both and completed"); } finally { accountA.unlock(); }
} else {
report.add("transfer-2 gave up on account-A");
}
} finally { accountB.unlock(); done.countDown(); }
});
System.out.println(" both finished within 5s? " + done.await(5, TimeUnit.SECONDS));
report.stream().sorted().forEach(line -> System.out.println(" " + line)); both finished within 5s? true
transfer-1 gave up on account-B after 200ms and released account-A
transfer-2 took both and completedThe cycle formed and then broke, because one participant was willing to stop waiting. Releasing account-A on the way out is the part that matters — a tryLock that times out and then keeps holding its first lock has achieved nothing.
This is strictly worse than ordering and worth being honest about. It converts a hang into a failure you now have to handle: transfer-1 did not complete, and something has to retry it, report it, or queue it. Retrying immediately in a tight loop is how a deadlock becomes a livelock, so retries want backoff and jitter. Use ordering where you can, and tryLock where you cannot.
You cannot rescue a deadlocked thread
The reason a deadlock means a restart, and the strongest argument for ReentrantLock in code that can hang.
// A lock that will never be released, and two threads trying to take it.
var monitor = new Object();
var holderHasIt = new CountDownLatch(1);
daemon("holder", () -> { synchronized (monitor) { holderHasIt.countDown(); blockForever(); } });
holderHasIt.await();
// 1. Blocked on synchronized. Interrupting it changes nothing.
var onSynchronized = daemon("waiting-on-synchronized", () -> { synchronized (monitor) { } });
awaitState(onSynchronized, Thread.State.BLOCKED, 2_000);
onSynchronized.interrupt();
Thread.sleep(50);
System.out.println(" synchronized : interrupted, state is now " + onSynchronized.getState()
+ ", interrupt flag " + onSynchronized.isInterrupted());
// 2. Blocked on ReentrantLock.lockInterruptibly. Interrupting it works.
var lock = newLock();
lock.lock(); // main thread holds it and never releases
var outcome = new ArrayBlockingQueue<String>(1);
var onLock = daemon("waiting-on-lockInterruptibly", () -> {
try {
lock.lockInterruptibly();
outcome.offer("acquired");
} catch (InterruptedException e) {
outcome.offer("threw InterruptedException — the thread is free to back out");
}
});
awaitState(onLock, Thread.State.WAITING, 2_000);
onLock.interrupt();
System.out.println(" lockInterruptibly : " + outcome.poll(2, TimeUnit.SECONDS)); synchronized : interrupted, state is now BLOCKED, interrupt flag true
lockInterruptibly : threw InterruptedException — the thread is free to back outRead the first line carefully. The interrupt was delivered — the flag is true — and the thread is still BLOCKED. It has been told to stop and has no way to act on it, because entering a synchronized block is not an interruptible operation. Nothing you can do from outside will free that thread: no interrupt, no timeout, no Thread.stop (removed, and it was never safe). The process restarts.
ReentrantLock is a lock with the same semantics plus three escape hatches — lockInterruptibly, tryLock, and tryLock(timeout). The cost is that you must unlock() in a finally, which synchronized does for you. That trade is worth it exactly where a hang would be unrecoverable, and not worth it for a two-line critical section that cannot deadlock.
Reference
The correct implementation, the configuration, and the migration path. Copy from here.
The four conditions, and which one you actually attack
| Condition | Meaning | Can you remove it? |
|---|---|---|
| Mutual exclusion | only one thread holds the lock | no — that is what a lock is |
| Hold and wait | holds one lock while requesting another | sometimes — acquire everything at once, or nothing |
| No pre-emption | a lock cannot be taken away | partly — tryLock lets a thread pre-empt itself |
| Circular wait | a cycle in the waits-for graph | yes — impose a global order |
All four must hold for a deadlock. Breaking any one is enough, and circular wait is the one you can break without changing what the code does.
Ordering with a stable key
// The pattern. Any total order works; it just has to be the SAME everywhere.
void transfer(Account from, Account to, BigDecimal amount) {
Account first = from.id().compareTo(to.id()) < 0 ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
The key must be stable and total. System.identityHashCode is the classic wrong choice — it is not guaranteed unique, so two distinct objects can compare equal and both threads fall back to their own order. If you must order by identity, the documented pattern uses a third lock as a tie-breaker:
private static final Object TIE_BREAKER = new Object();
void transfer(Account from, Account to, BigDecimal amount) {
int a = System.identityHashCode(from), b = System.identityHashCode(to);
if (a < b) { synchronized (from) { synchronized (to) { move(from, to, amount); } } }
else if (a > b) { synchronized (to) { synchronized (from) { move(from, to, amount); } } }
else {
// Hash collision: neither order is agreed. Serialise through one global lock.
synchronized (TIE_BREAKER) {
synchronized (from) { synchronized (to) { move(from, to, amount); } }
}
}
}
Use a business key if one exists. The tie-breaker is for when nothing else is available.
Finding one in production
# The thread dump. jstack prints "Found one Java-level deadlock:" and names both.
jstack <pid>
jcmd <pid> Thread.print
# Container without a JDK? Send the signal; the dump goes to the JVM's stdout.
kill -3 <pid>
// From inside — useful as a health check that fails loudly instead of hanging.
var mx = ManagementFactory.getThreadMXBean();
long[] stuck = mx.findDeadlockedThreads(); // null when there is none
if (stuck != null) {
for (ThreadInfo t : mx.getThreadInfo(stuck, true, true)) {
log.error("deadlocked: {} waiting on {} held by {}",
t.getThreadName(), t.getLockName(), t.getLockOwnerName());
}
}
findDeadlockedThreads covers monitors and ReentrantLock; findMonitorDeadlockedThreads covers only synchronized, and is almost never the one you want. Neither reports virtual threads.
A deadlock does not raise CPU, so it will not trip a CPU alarm. The symptoms are a thread pool that stops completing work, request latency climbing to timeout, and a heap that stops moving. Alert on task completion rate, not on CPU.
Preferring ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
// The unlock MUST be in a finally. This is the cost of the extra control.
lock.lock();
try { work(); } finally { lock.unlock(); }
// Bounded wait — turns a hang into an error you can handle.
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try { work(); } finally { lock.unlock(); }
} else {
throw new ResourceBusyException("could not acquire within 500ms");
}
// Cancellable — the request was abandoned, so stop waiting for the lock.
try { lock.lockInterruptibly(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
Reach for ReentrantLock when you need a timeout, cancellation, more than one condition variable, or lock acquisition and release in different scopes. Keep synchronized for short critical sections that cannot deadlock — it is harder to get wrong, and the JIT optimises it well.
The same bug in a database
Two transactions updating the same rows in opposite order deadlock for exactly the same reason. The difference is that the database detects the cycle and kills one transaction, so you get an exception rather than a hang:
// PostgreSQL: SQLState 40P01. MySQL/InnoDB: error 1213.
// Retryable — the victim was rolled back, so re-running it is safe.
catch (DeadlockLoserDataAccessException e) { retryWithBackoff(); }
The prevention is identical: touch rows in a consistent order, keep transactions short, and order the ids inside a batch update — ORDER BY id on a bulk update is a deadlock fix, not a cosmetic one.
Scenarios
Real situations, with the decision and the argument.
1. A service stops serving under load. CPU is near zero and the heap is flat.
That combination is close to diagnostic. A hot loop burns CPU, a memory problem moves the heap, and a deadlock does neither — the threads are parked.
Take a thread dump before restarting, because the restart destroys the only evidence. jcmd <pid> Thread.print names both threads and both locks if it is a genuine deadlock, and if it says nothing about a deadlock you have learned something too: probably a lock held across a slow call, or an exhausted connection pool, which look identical from the outside and are fixed differently.
2. It only happens in production, never in test.
Expected, and not a testing failure to feel bad about. The bug needs a specific interleaving, and load is what makes rare interleavings frequent — a one-in-ten-thousand window is invisible at ten requests a second and constant at a thousand.
Do not try to reproduce it by running the tests harder. Read the two code paths in the dump and check the order they take locks; the bug is usually obvious once you have both stack traces side by side. To catch the next one, a targeted test can force the interleaving with a latch, exactly as the examples above do.
3. Someone proposes wrapping the lock acquisition in a timeout and retrying.
Better than hanging, and it is treating the symptom. Ask first whether the locks can be ordered, because ordering removes the possibility rather than recovering from it.
If tryLock is genuinely the answer, two details decide whether it works. The thread must release everything it holds before retrying, or the cycle survives the timeout. And the retry needs backoff with jitter — two threads retrying in lockstep at a fixed interval is a livelock, which looks like a deadlock in monitoring and is harder to see in a thread dump, because the threads are running.
4. The deadlock involves a lock inside a third-party library.
You cannot reorder what you cannot see, so the usual move is to stop holding your own lock while calling into the library. Making the call outside your critical section removes hold-and-wait, which is the second Coffman condition and the other one that is sometimes attackable.
Where that is impossible — a callback the library invokes while holding its lock — tryLock on your side plus a documented ordering rule is the fallback. It is also worth checking the library's own documentation before assuming: many state a lock-ordering contract, and the bug is that your code did not follow it.
5. A team wants a deadlock health check that restarts the pod automatically.
Reasonable, with one caveat worth stating. findDeadlockedThreads in a liveness probe turns an unrecoverable hang into an automatic restart, which is the right outcome given the threads cannot be freed.
The caveat is that a restart discards the evidence. Have the check log the full thread dump before it reports unhealthy, and alert on the restart rather than treating it as routine — otherwise the deadlock becomes a pod that restarts every few hours and nobody investigates, which is a worse outcome than the original outage because it never becomes urgent.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "What is a deadlock?" A cycle in the waits-for graph: each thread holds a lock another needs and none will release. Nothing times out and nothing recovers, so the process stops making progress on those threads permanently.
2. "What conditions are required?" Mutual exclusion, hold-and-wait, no pre-emption, and circular wait — all four at once. Breaking any one prevents it, and circular wait is the only one you can break without changing what the code does.
3. "How do you prevent it?" Impose a global lock order on a stable, total key — account id, primary key, path — and take locks in that order everywhere. A cycle then cannot form. That is the fix that scales, because it needs no coordination between threads.
4. "What if you cannot order them?"
ReentrantLock.tryLock with a timeout, releasing everything already held on the way out, and retrying with backoff and jitter. It converts a hang into a failure you must handle, which is worse than ordering and better than stopping.
5. "How do you detect one in production?"
jstack or jcmd Thread.print — it prints "Found one Java-level deadlock" and names both threads and locks. From inside, ThreadMXBean.findDeadlockedThreads(). Take the dump before restarting; the restart destroys the evidence.
6. "What does a deadlock look like in monitoring?" Flat CPU, flat heap, and work stopping. That is the tell, and it is why CPU alarms miss it — alert on task completion rate and request latency instead.
7. "Can you interrupt a deadlocked thread?"
Not one blocked entering a synchronized block. The interrupt flag gets set and the thread stays blocked, because monitor entry is not interruptible. ReentrantLock.lockInterruptibly can be interrupted, which is the main reason to prefer it where a hang would be unrecoverable.
8. "synchronized or ReentrantLock?"
synchronized for short critical sections — it releases automatically and is hard to get wrong. ReentrantLock when you need a timeout, cancellation, multiple conditions, or acquire and release in different scopes. The cost is remembering finally.
9. "How is a database deadlock different?" Same cycle, different ending: the database detects it and kills one transaction, so you get a retryable exception instead of a hang. Prevention is the same — touch rows in a consistent order and keep transactions short.
10. "What is a livelock?"
Threads that are running and still making no progress — each repeatedly backing off and retrying in a way that keeps colliding. It is what a naive tryLock retry loop degrades into, and it is harder to spot than a deadlock because the threads are active and CPU is not flat.
Code traps
Trap A — predict before you run:
synchronized (from) {
synchronized (to) {
from.debit(amount);
to.credit(amount);
}
}
Answer
Deadlocks the moment two transfers run in opposite directions at once — transfer(a, b) and transfer(b, a). Each thread holds one account and waits for the other.
What makes it hard to catch is that there is nothing wrong with this method by itself. The bug only exists relative to another call with the arguments swapped, and that call is usually somewhere else entirely. Sort the two accounts by a stable id before locking, and the same code becomes safe.
Trap B:
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try { work(); } finally { lock.unlock(); }
}
Answer
The lock handling is correct — and the failure is unhandled. When tryLock returns false, work() silently does not happen and the method returns as though it did. Under contention the system quietly stops doing some of its work.
tryLock does not remove the problem; it moves it into your error handling. Every call needs an explicit else: throw, retry with backoff, queue it, or return a busy response. The other half of the trap is that this only shows up under load, so a code review sees a tidy try/finally and a production incident sees missing transactions.
Trap C:
synchronized (cache) {
var fresh = httpClient.send(request, ofString()); // 30s timeout
cache.put(key, fresh.body());
}
Answer
No deadlock in the strict sense, and the same outcome. The lock is held across a network call, so every other thread wanting cache blocks for as long as the remote service is slow — and if that service is slow because it is calling back into this one, it is a genuine distributed deadlock.
A thread dump shows many threads BLOCKED on one monitor with no cycle, so findDeadlockedThreads returns nothing while the service is completely stalled. Do the I/O outside the lock and take the lock only to store the result. "Never hold a lock across a call you do not control" prevents more outages than any deadlock rule.
Common wrong answers
| Said in interviews | Reality |
|---|---|
"Use synchronized everywhere to be safe." | More locks, more chances of a cycle. |
| "The JVM detects and recovers automatically." | It detects on request. It never recovers. |
| "Interrupt the thread to break it." | Monitor entry is not interruptible. The flag is set and nothing changes. |
"Thread.stop() unblocks it." | Removed, and it never worked safely. |
| "A timeout on the lock prevents deadlock." | It converts one into a failure you must handle. |
| "It shows up as high CPU." | Flat CPU, flat heap, no progress. |
| "Order locks by identity hash code." | Not unique. Collisions need a tie-breaker lock. |
| "Deadlocks only happen with multiple locks." | One lock plus a slow call under it stalls a service the same way. |
| "The tests would catch it." | Needs an interleaving that load makes common and tests do not. |
Check Yourself
Q1. Two methods each lock two accounts, and each is correct on its own. Where is the bug?
Answer
Between them. The bug is that the two orderings disagree — one locks from then to, the other locks to then from — so a cycle forms when both run at once. No single method can be reviewed into correctness, which is why the fix is a global rule: sort the accounts by a stable, total key and lock in that order everywhere.
Q2. Your service is stalled. CPU is near zero and the heap is flat. What do you do first, and why is the order important?
Answer
Take a thread dump — jcmd <pid> Thread.print — before restarting, because a restart is the fix and also destroys the only evidence. Flat CPU with no progress is close to diagnostic for a lock problem, and the dump either names a deadlock cycle explicitly or shows many threads blocked on one monitor, which is a lock held across a slow call and a different fix.
Q3. Why can't you interrupt a thread that is deadlocked on synchronized, and what does that imply for design?
Answer
Entering a monitor is not an interruptible operation: the interrupt flag is set and the thread stays BLOCKED, because there is no point in the JVM's monitor-entry path where it checks. Nothing external can free it, so the process must restart. The implication is that anywhere a hang would be unrecoverable — request handlers, anything holding a scarce resource — ReentrantLock earns its extra ceremony, because lockInterruptibly and tryLock give the thread a way out.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Deadlock two threads and detect it | 10 min |
| Challenge | Five hangs, only three are deadlocks | 25 min |
| Production | The transfer service that stalled at month end | 45 min |
| Interview | Full round replay | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 21LTS
Virtual threads arrive, and a virtual thread that blocks inside a synchronized block pins its carrier platform thread. A lock-ordering bug can then starve the scheduler rather than stalling two threads, and ThreadMXBean does not report virtual threads. ReentrantLock does not pin, which is a reason to prefer it in code that runs on virtual threads.
Before Java 21: Every thread was a platform thread, so a blocked thread cost one OS thread and the JVM's deadlock detector saw all of them.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Deadlock two threads and detect it
One concept, guided. Near-impossible to fail.
- Challenge25 min
Five hangs, only three are deadlocks
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The transfer service that stalled at month end
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — deadlock
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- What is the happens-before relationship?
- How does ConcurrentHashMap achieve thread safety?
- db deadlocks — not written yet
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-30.