Deadlock is the concurrency version of two cars meeting on a narrow bridge and both refusing to reverse — interviewers love it because it tests whether you can keep a system moving, not just make it correct.
Question: What is deadlock, and how do you prevent it in Java?
Answer: Deadlock happens when two or more threads each hold a lock and wait forever for another lock, so none of them can continue. In Java this can happen with either synchronized blocks or explicit locks such as ReentrantLock. Prevention means designing the locking so the cycle cannot form, usually by using one global lock order, keeping locked sections short, or using tryLock with a timeout so a thread can back off instead of waiting forever.
Interview-Ready Answer: I’d define deadlock as a situation where threads are stuck forever because each one is holding a lock and waiting for another lock held by someone else. In Java, it usually appears when multiple locks are acquired in different orders across different code paths. I prevent it by enforcing one global lock order, minimizing the time spent inside critical sections, and, when appropriate, using ReentrantLock.tryLock() with a timeout so a thread can fail fast and retry instead of blocking forever. If I need to debug production, I can confirm the deadlock with a thread dump or ThreadMXBean, but the real fix is to break circular wait in the design.
Deadlock is a liveness problem: the code is alive, threads exist, but useful work stops. In Java, a thread can block on an intrinsic monitor from synchronized or on an explicit lock from java.util.concurrent.locks. The important point is that deadlock is not about bad data; it is about a cycle of waiting.
X.Y.Y and must wait.X and must wait.That is why deadlock is so nasty: the program does not crash, it just stops making progress.
Classic deadlock needs all four of these conditions at the same time:
In practice, the easiest condition to break is circular wait. You do that by making every thread acquire multiple locks in the same order everywhere in the codebase.
ReentrantLock.tryLock(), a thread can give up, release what it already holds, and retry later instead of waiting forever.| Strategy | Best for | Trade-off |
|---|---|---|
| Global lock order | Multiple locks | Needs one rule everywhere |
tryLock + timeout | Request code | Retries and possible starvation |
| Single coarse lock | Small shared state | Lower concurrency |
| No shared mutable state | New designs | More refactoring |
As a rough rule, ordered locking is the cleanest fix when you truly need nested locks. Timeout-based locking is useful when you would rather fail fast than freeze a request. A timeout of tens to hundreds of milliseconds is common in interactive services, but the real value should match your SLA and retry policy.
Ordered locking is usually O(1) extra work per lock if the order key already exists. If you must sort k locks before acquiring them, the extra cost becomes O(k log k). Timeout locking adds O(1) acquisition overhead, but the real cost can be retries, backoff delays, and extra latency. Memory overhead is usually O(1) per lock.
Important gotchas: a fair lock (new ReentrantLock(true)) can reduce starvation, but it does not prevent deadlock. Reentrancy also does not save you from deadlock across different locks; it only means the same thread can re-enter the same lock safely. And if two resources compare equal for ordering, you need a tie-breaker so the order is still deterministic.
If you suspect a production hang, deadlock detection tools help: a thread dump, jstack, or ThreadMXBean.findDeadlockedThreads(). That tells you that you are deadlocked; prevention is still the real fix.
Imagine an e-commerce checkout service that updates an Order record and an Inventory record for every purchase. One code path locks Order first and then Inventory; another path, written later by a different team, locks Inventory first and then Order. Under peak traffic, two requests hit those paths at the same time and each thread waits for the other lock forever.
What does the incident look like? Customers see the spinner never finish, the queue of pending requests grows, and the JVM thread dump shows many threads in BLOCKED or waiting on monitors. CPU may actually be low, which confuses people at first, because the app is not busy doing work — it is stuck waiting. The fix is to enforce one lock order everywhere, and if the second update is optional, use a timeout and retry instead of freezing the whole request.
A small misunderstanding of lock order can turn into a large outage: orders pile up, payment webhooks retry, and support sees “my card was charged but the page hung.” Deadlock prevention is not just a theory question; it is a production safety skill.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class DeadlockPreventionDemo {
static class Account {
final int id;
final String name;
final ReentrantLock lock = new ReentrantLock();
int balance;
Account(int id, String name, int balance) {
this.id = id;
this.name = name;
this.balance = balance;
}
@Override
public String toString() {
return name + "=" + balance;
}
}
// The timeout is the safety valve: if contention starts to look like a deadlock,
// the thread backs out instead of waiting forever.
static boolean transferWithTimeout(Account from, Account to, int amount, long timeoutMs) throws InterruptedException {
if (from == to) {
System.out.println(Thread.currentThread().getName() + ": self-transfer skipped for " + from.name);
return true;
}
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);
if (!from.lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) {
System.out.println(Thread.currentThread().getName() + ": could not lock " + from.name);
return false;
}
try {
long remainingNanos = deadline - System.nanoTime();
if (remainingNanos <= 0 || !to.lock.tryLock(remainingNanos, TimeUnit.NANOSECONDS)) {
System.out.println(Thread.currentThread().getName() + ": timed out waiting for " + to.name + "; backing off.");
return false;
}
try {
if (from.balance < amount) {
System.out.println(Thread.currentThread().getName() + ": insufficient funds in " + from.name);
return false;
}
from.balance -= amount;
to.balance += amount;
return true;
} finally {
to.lock.unlock();
}
} finally {
from.lock.unlock();
}
}
// Fixed ordering is the stronger guarantee: every thread acquires the same pair
// in the same sequence, so circular wait cannot form.
static boolean transferByOrderedLock(Account from, Account to, int amount) {
if (from == to) {
System.out.println(Thread.currentThread().getName() + ": self-transfer skipped for " + from.name);
return true;
}
Account first = from.id < to.id ? from : to;
Account second = first == from ? to : from;
first.lock.lock();
try {
second.lock.lock();
try {
if (from.balance < amount) {
System.out.println(Thread.currentThread().getName() + ": insufficient funds in " + from.name);
return false;
}
from.balance -= amount;
to.balance += amount;
// Artificial pause to make contention visible in the demo.
sleepQuietly(50);
return true;
} finally {
second.lock.unlock();
}
} finally {
first.lock.unlock();
}
}
public static void main(String[] args) throws Exception {
Account a = new Account(1, "A", 1000);
Account b = new Account(2, "B", 1000);
// Force one lock to be held so the timeout path is exercised deterministically.
CountDownLatch bLocked = new CountDownLatch(1);
Thread blocker = new Thread(() -> {
b.lock.lock();
try {
bLocked.countDown();
sleepQuietly(300);
} finally {
b.lock.unlock();
}
}, "blocker");
blocker.start();
bLocked.await();
boolean firstAttempt = transferWithTimeout(a, b, 100, 100);
System.out.println("Timeout transfer success? " + firstAttempt);
blocker.join();
boolean secondAttempt = transferWithTimeout(a, b, 100, 200);
System.out.println("Timeout transfer after release success? " + secondAttempt);
System.out.println("Balances now: " + a + ", " + b);
Thread t1 = new Thread(() -> {
try {
boolean ok = transferByOrderedLock(a, b, 150);
System.out.println(Thread.currentThread().getName() + " ordered transfer success? " + ok);
} catch (Exception e) {
e.printStackTrace();
}
}, "T1");
Thread t2 = new Thread(() -> {
try {
boolean ok = transferByOrderedLock(b, a, 70);
System.out.println(Thread.currentThread().getName() + " ordered transfer success? " + ok);
} catch (Exception e) {
e.printStackTrace();
}
}, "T2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final balances: " + a + ", " + b);
}
private static void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}synchronized prevent deadlock? No. It only guarantees mutual exclusion for one monitor; you can still deadlock if you take multiple monitors in inconsistent orders.jstack, or ThreadMXBean.findDeadlockedThreads(). That helps you confirm the problem, but it is detection, not prevention.tryLock over a normal lock? Use it when waiting forever is worse than failing fast, such as request handlers, batch jobs with retries, or operations that can safely back off.synchronized, so I’m safe" — correction: synchronized makes each individual lock safe, but multiple locks can still deadlock.Memory Hook: Think of a four-way stop where every car has already entered the intersection and nobody is allowed to reverse. Deadlock prevention means deciding the right order before you enter the intersection.
synchronized and ReentrantLock.tryLock with timeout and retry/backoff.Account objects in opposite order, then fix them with a shared ordering rule.tryLock timeout and observe how it reduces collisions.