A deadlocked program does not crash. It does not log anything, it does not throw, and no watchdog fires. Two threads simply stop, each holding the lock the other one needs, and the request they were serving never returns. From the outside it looks exactly like slowness.
That is the hardest part of the bug: telling a deadlock apart from a program that is merely busy. This article builds a deadlock that reproduces on demand, proves it is a deadlock with a real thread dump, then fixes it four different ways — one for each of the four conditions a deadlock needs. It closes with livelock and starvation, the two failures that look like deadlock in a monitoring dashboard and look like nothing at all in a thread dump.
![]()
Every program below was compiled and run on OpenJDK 21.0.6 (arm64), and every thread dump is a real capture from a real hung JVM. Concurrent output is not deterministic: line ordering, attempt counts and thread names change between runs, so anything variable is labelled as such and you may need to run an example more than once to see what is described.
What deadlock is, and the four conditions behind it
A deadlock is a set of threads in which every thread is waiting for a resource held by another thread in the same set. Because none of them can proceed, none of them will ever release what it holds, and the set is stuck permanently. No timeout expires, because synchronized has no timeout.
Deadlock is not an accident of scheduling. It requires four conditions to hold at the same time, a result usually credited to Edward Coffman's 1971 paper. Break any single one and a deadlock becomes impossible, which is why the four are the right structure for a prevention strategy rather than trivia.
| Condition | What it means | How you break it |
|---|---|---|
| Mutual exclusion | A resource can be held by one thread at a time | Immutable data, thread confinement, or a concurrent collection that needs no exclusive lock |
| Hold and wait | A thread holding one lock asks for another | Acquire everything in one step, or hold nothing while you acquire |
| No preemption | A lock cannot be taken away from its holder | tryLock with a timeout, so a thread gives up its own lock voluntarily |
| Circular wait | The wait-for edges form a cycle | A global order that every thread acquires locks in |
synchronized hands you the first three by construction. A monitor is exclusive, a nested synchronized block holds and waits, and nothing can strip a monitor from its owner. That leaves circular wait as the only condition you can attack while staying with synchronized — which is exactly why "always take locks in the same order" is the standard advice. ReentrantLock is what buys you a second option, because tryLock lets a thread back out of an acquisition it cannot complete.
Building a deadlock you can reproduce
A deadlock needs both threads to be inside the window between their first and second acquisition at the same moment. Left to chance that window is a few nanoseconds wide and the bug shows up once a month in production. Put a sleep in the middle and it happens on every run.
public class DeadlockDemo {
static final Object LOCK_A = new Object();
static final Object LOCK_B = new Object();
static void log(String msg) {
System.out.println(Thread.currentThread().getName() + ": " + msg);
}
static void pause(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (LOCK_A) {
log("holds A, wants B");
pause(300); // widens the window so the deadlock is reliable
synchronized (LOCK_B) {
log("holds A and B");
}
}
}, "transfer-1");
Thread t2 = new Thread(() -> {
synchronized (LOCK_B) {
log("holds B, wants A");
pause(300);
synchronized (LOCK_A) {
log("holds B and A");
}
}
}, "transfer-2");
t1.start();
t2.start();
// watchdog: never leave a wedged JVM behind
Thread watchdog = new Thread(() -> {
pause(20_000);
System.out.println("watchdog: still stuck after 20s, killing the JVM");
Runtime.getRuntime().halt(2);
}, "watchdog");
watchdog.setDaemon(true);
watchdog.start();
}
}transfer-1: holds A, wants B
transfer-2: holds B, wants AThen nothing, until the watchdog fires twenty seconds later. Neither holds A and B nor holds B and A is ever printed. The order of those two lines swaps between runs — on one of my runs transfer-2 printed first — but the outcome does not.
Always give a demonstration like this a watchdog. Runtime.halt is deliberate here rather than System.exit: exit runs shutdown hooks, and a shutdown hook that touches either lock would hang too.

All four conditions are present. The monitors are exclusive, each thread holds one while asking for the other, neither monitor can be revoked, and the two wait-for edges close into a cycle.
How do you prove a hang is really a deadlock?
You do not guess, you take a thread dump. Find the process id with jps or jcmd -l, then ask the JVM what its threads are doing.
jps -l # lists running JVMs and their main classes
jstack 27395 # full thread dump on stdoutThe HotSpot dump ends with a deadlock analysis. This is a verbatim capture from the hung DeadlockDemo above:
Found one Java-level deadlock:
=============================
"transfer-1":
waiting to lock monitor 0x0000000a5d0fd0a0 (object 0x0000000310614370, a java.lang.Object),
which is held by "transfer-2"
"transfer-2":
waiting to lock monitor 0x0000000a5d0fd180 (object 0x0000000310614360, a java.lang.Object),
which is held by "transfer-1"
Java stack information for the threads listed above:
===================================================
"transfer-1":
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:24)
- waiting to lock <0x0000000310614370> (a java.lang.Object)
- locked <0x0000000310614360> (a java.lang.Object)
at DeadlockDemo$$Lambda/0x00000070010009f8.run(Unknown Source)
at java.lang.Thread.runWith(java.base@21.0.6/Thread.java:1596)
at java.lang.Thread.run(java.base@21.0.6/Thread.java:1583)
"transfer-2":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:34)
- waiting to lock <0x0000000310614360> (a java.lang.Object)
- locked <0x0000000310614370> (a java.lang.Object)
at DeadlockDemo$$Lambda/0x0000007001000c08.run(Unknown Source)
at java.lang.Thread.runWith(java.base@21.0.6/Thread.java:1596)
at java.lang.Thread.run(java.base@21.0.6/Thread.java:1583)
Found 1 deadlock.Read it by matching the object addresses. transfer-1 has locked 0x...360 and is waiting to lock 0x...370; transfer-2 has exactly the reverse. The two lines cross, and the line numbers point at the inner synchronized in each lambda. The addresses are heap addresses and differ on every run, but their pairing does not.
The same threads appear earlier in the dump with their state, which is the other half of the evidence:
"transfer-1" #20 [27395] prio=5 os_prio=31 cpu=3.93ms elapsed=1.94s tid=0x0000000a5d15ea00 nid=27395 waiting for monitor entry [0x0000000171aaa000]
java.lang.Thread.State: BLOCKED (on object monitor)
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:24)
- waiting to lock <0x0000000310614370> (a java.lang.Object)
- locked <0x0000000310614360> (a java.lang.Object)BLOCKED (on object monitor) with cpu=3.93ms against elapsed=1.94s says the thread has burned almost no CPU since it started. A slow thread looks nothing like this; a slow thread is RUNNABLE and its cpu value climbs with elapsed. That single contrast is usually enough to classify a hang before you read anything else.
jcmd prints the identical dump and is the tool Oracle now points at, so use whichever is on the box:
jcmd 27395 Thread.printTwo limits are worth knowing. The detector understands monitors and java.util.concurrent ownable synchronizers, so a ReentrantLock cycle is reported too — with waiting for ownable synchronizer 0x... (a java.util.concurrent.locks.ReentrantLock$NonfairSync), which is held by "rl-2" instead of waiting to lock monitor. What it cannot see is a cycle built from anything else: a semaphore, a latch, a queue, or a task waiting on another task. Those hangs are real deadlocks in every practical sense and the dump will report Found 1 deadlock for none of them.
Finding the deadlock from inside the JVM
ThreadMXBean exposes the same analysis programmatically, which is how you turn a deadlock from a support ticket into an alert. findDeadlockedThreads() returns null when there is nothing wrong, and an array of thread ids when there is.
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
static void startMonitor() {
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
Thread monitor = new Thread(() -> {
while (true) {
pause(1000);
long[] ids = bean.findDeadlockedThreads();
if (ids == null) {
System.out.println("monitor: no deadlock");
continue;
}
ThreadInfo[] infos = bean.getThreadInfo(ids, true, true);
System.out.println("monitor: DEADLOCK, " + ids.length + " threads");
for (ThreadInfo info : infos) {
System.out.println(" \"" + info.getThreadName() + "\" is " + info.getThreadState()
+ " on " + info.getLockInfo()
+ " owned by \"" + info.getLockOwnerName() + "\"");
}
Runtime.getRuntime().halt(1);
}
}, "deadlock-monitor");
monitor.setDaemon(true);
monitor.start();
}Bolted onto the same two threads, it reports the cycle on its first pass:
monitor: DEADLOCK, 2 threads
"transfer-1" is BLOCKED on java.lang.Object@6cd66c18 owned by "transfer-2"
"transfer-2" is BLOCKED on java.lang.Object@181dfeef owned by "transfer-1"Four consecutive runs all reported the deadlock; the @ identity hashes differ every time. Note findDeadlockedThreads() versus findMonitorDeadlockedThreads() — the second one ignores ReentrantLock and sees monitors only, so prefer the first unless you have a reason not to. A detector like this belongs on a scheduled executor in a long-running service, logging and alerting rather than calling halt.
Preventing deadlock by breaking one of the four conditions
The two-lock toy is easy to spot. Real code hides the same shape behind a method that locks whatever it is handed:
// Naive: each call locks the accounts in the order it happens to receive them.
static void transfer(Account from, Account to, long amount) {
synchronized (from) {
synchronized (to) {
if (from.balance >= amount) { from.balance -= amount; to.balance += amount; }
}
}
}Nothing here mentions two locks in opposite order — and yet transfer(a, b) on one thread and transfer(b, a) on another produce exactly that. With eight threads moving money between four accounts and a ThreadMXBean monitor watching, three runs deadlocked in well under a second each:
DEADLOCK after 3 threads got stuck
"worker-0" waits for "worker-5"
"worker-5" waits for "worker-4"
"worker-4" waits for "worker-0"DEADLOCK after 2 threads got stuck
"worker-0" waits for "worker-2"
"worker-2" waits for "worker-0"The cycle length and the thread names vary run to run — the second capture is a two-thread cycle, the first a three-thread one. That is the general case: a deadlock cycle can be any length, and a three-way cycle is much harder to see in review than a pair.

Global lock ordering, the fix that actually scales
Give every lock a position in one total order and make every thread acquire in that order. Then a cycle cannot exist, because a cycle needs at least one thread acquiring downwards while another acquires upwards. This breaks circular wait and leaves the other three conditions untouched.
The order has to be stable and available at the call site. When the locks are domain objects with a natural key — an account number, a primary key, a user id — use that. When they are not, System.identityHashCode gives you an order over arbitrary objects:
/** Used only when two distinct objects report the same identity hash code. */
private static final Object TIE_BREAK = new Object();
/** Always takes the two monitors in the same global order, whoever calls it. */
static void transfer(Account from, Account to, long amount) {
int fromHash = System.identityHashCode(from);
int toHash = System.identityHashCode(to);
if (fromHash < toHash) {
synchronized (from) {
synchronized (to) { move(from, to, amount); }
}
} else if (fromHash > toHash) {
synchronized (to) {
synchronized (from) { move(from, to, amount); }
}
} else {
// identity hash collision: one extra lock restores a total order
synchronized (TIE_BREAK) {
synchronized (from) {
synchronized (to) { move(from, to, amount); }
}
}
}
}Same eight threads, same four accounts, twenty thousand transfers each:
all 160000 transfers finished
total before = 4000, total after = 4000It completes every time, and the invariant holds: money is conserved, so the ordering did not cost correctness.
The else branch is not defensive paranoia. System.identityHashCode returns an int, so two distinct objects can share one, and if they do then fromHash < toHash and fromHash > toHash are both false and the two callers fall back to argument order — the original bug. Collisions are easy to produce:
Map<Integer, Object> seen = new HashMap<>();
for (int i = 0; i < 20_000_000; i++) {
Object o = new Object();
int h = System.identityHashCode(o);
Object prev = seen.putIfAbsent(h, o);
if (prev != null) {
System.out.println("collision after " + i + " objects: identityHashCode = " + h);
System.out.println("same hash, different objects? " + (prev != o));
return;
}
}collision after 105842 objects: identityHashCode = 2134400190
same hash, different objects? trueAround a hundred thousand objects, on a JVM that has barely started. Three repeats of that program landed on the same count on this machine, since HotSpot's default identity-hash generator is a pseudo-random sequence per thread; the count will differ on a different platform or JVM, but the conclusion does not. A service holding a million live objects will hit collisions, and the TIE_BREAK lock is what keeps the order total when it does.
Lock timeouts with tryLock and a back-off
The other practical attack is on no preemption. synchronized cannot be interrupted or timed out, but ReentrantLock.tryLock(timeout, unit) returns false instead of waiting forever, which lets a thread release what it already holds and start over. No global order is needed, because no thread ever waits indefinitely while holding something.
/** Takes both locks or neither. No global order needed - it never waits while holding. */
static boolean transfer(Account from, Account to, long amount) throws InterruptedException {
ThreadLocalRandom rnd = ThreadLocalRandom.current();
for (int attempt = 0; attempt < 100; attempt++) {
if (from.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (to.lock.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
}
return true;
} finally { to.lock.unlock(); }
}
} finally { from.lock.unlock(); }
}
// randomised back-off: without the random part every loser retries in lockstep
Thread.sleep(rnd.nextInt(1, 8));
}
return false;
}finished, gave up on 0 transfers
total after = 4000Three things make this correct rather than merely clever. The inner tryLock is inside the outer lock's try, so a failure to get the second lock still releases the first. The retry loop is bounded and the method returns false when it runs out, so the caller has a real failure path instead of an infinite loop. And the back-off is randomised — a fixed back-off makes every loser retry at the same instant, which is the ingredient a livelock needs.
Use tryLock when a global order is impossible: locks handed to you by a framework, a lock ordering that depends on runtime data, or code that has to stay responsive. Use ordering everywhere else, because ordering has no retry path to get wrong.
Shrinking the lock scope and dropping nested locks
The cheapest fix is to stop holding two locks at once. Two rules cover most of it.
Compute outside, publish inside. Anything that does not touch shared state does not belong inside a synchronized block. Moving it out shortens the hold and often removes the second acquisition entirely.
// holds the lock across an I/O call and a second lock
synchronized (cache) {
Report r = reportService.build(id); // slow, and takes its own locks
cache.put(id, r);
}
// holds the lock only for the publish
Report r = reportService.build(id);
synchronized (cache) {
cache.put(id, r);
}Never call unknown code while holding a lock. A listener, a callback, an overridden method, a Comparator, a lambda a caller passed in — any of them can acquire a lock you have never heard of, and now your lock order includes an edge you did not write. Copy the listener list under the lock, release it, then notify. This is the single rule that removes the most surprise deadlocks from a codebase, because it removes the ones no reviewer could have seen.
And prefer the structures that need no locking at all: ConcurrentHashMap and friends, AtomicLong and the other Atomic* classes, and immutable value objects that can be shared without coordination. A lock you never take cannot deadlock.
Thread pool deadlock: a task waiting on a task in the same pool
Not every deadlock is a lock cycle. A fixed-size pool has a fixed number of threads, and if every one of them is blocked waiting for a task that still needs a thread to run, the pool is stuck for good.
ExecutorService pool = Executors.newFixedThreadPool(2);
Callable<String> outer = () -> {
System.out.println(Thread.currentThread().getName() + ": outer task started");
Future<String> inner = pool.submit(() -> "inner done");
return "outer got: " + inner.get(); // blocks a pool thread on a pool task
};
Future<String> f1 = pool.submit(outer);
Future<String> f2 = pool.submit(outer);pool-1-thread-2: outer task started
pool-1-thread-1: outer task started
TimeoutException: both pool threads are blocked on tasks that need a pool thread to runTwo tasks occupy the two threads, both submit an inner task, both call get(). The inner tasks sit in the queue behind threads that will never return. The order of the first two lines varies between runs.
Now take a thread dump of it, and the crucial detail appears: jstack reports no deadlock at all. The dump contains zero Found one Java-level deadlock sections. What it shows instead is this:
"pool-1-thread-1" #20 [26627] prio=5 os_prio=31 cpu=1.67ms elapsed=2.65s tid=0x00000008bf8faa00 nid=26627 waiting on condition [0x000000016daca000]
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@21.0.6/Native Method)
- parking to wait for <0x00000003107cbd38> (a java.util.concurrent.FutureTask)
at java.util.concurrent.FutureTask.awaitDone(java.base@21.0.6/FutureTask.java:500)
at java.util.concurrent.FutureTask.get(java.base@21.0.6/FutureTask.java:190)Every pool thread WAITING (parking) on a FutureTask, with the queue non-empty, is the signature. There is no lock cycle to find, because no locks are involved — the resource being waited on is a thread, and the deadlock detector does not model thread supply.
The rule is short: a task must never block on the completion of work submitted to the same pool. Either give the inner work its own pool, or compose the stages without blocking. A chain composed with thenCompose avoids it because no stage ever blocks waiting for another, and a virtual-thread-per-task executor sidesteps it because a blocked virtual thread releases its carrier and the executor is not size-limited.
Livelock: running hard, going nowhere
A livelock is the polite version of a deadlock. Nobody blocks. Every thread keeps acquiring, checking, releasing and retrying — and because they all keep making the same courteous decision at the same moment, none of them ever finishes.
The structure is tryLock with an immediate release-and-retry. Thread one takes the lock it can get, fails to get the other, gives its own back so as not to hold up anyone, and tries again. Thread two does the mirror image. If their retries are correlated, they can repeat that forever.
static final long PERIOD = TimeUnit.MILLISECONDS.toNanos(10);
static void spinUntil(long deadline) {
while (System.nanoTime() < deadline) Thread.onSpinWait();
}
static void polite(ReentrantLock first, ReentrantLock second) {
while (!stop) {
long tick = ((System.nanoTime() / PERIOD) + 1) * PERIOD; // same value in both threads
spinUntil(tick);
attempts.incrementAndGet();
first.lock(); // succeeds: nobody else wants this one
try {
spinUntil(tick + PERIOD / 4); // by now the other thread holds its own
boolean got = second.tryLock(); // fails: the other thread is holding it
spinUntil(tick + PERIOD / 2);
if (got) {
try { completed.incrementAndGet(); } finally { second.unlock(); }
return;
}
} finally {
first.unlock(); // "after you" - hand it back and retry
}
}
}One thread runs polite(A, B) and the other polite(B, A). The clock tick is what correlates their retries; in production that correlation comes from a fixed back-off, a shared timer, or a rate limiter releasing everybody at once.
t=1s attempts=200 completed=0 polite-1=RUNNABLE polite-2=RUNNABLE
t=2s attempts=404 completed=0 polite-1=RUNNABLE polite-2=RUNNABLE
t=3s attempts=604 completed=0 polite-1=RUNNABLE polite-2=RUNNABLE
t=4s attempts=806 completed=0 polite-1=RUNNABLE polite-2=RUNNABLE
t=5s attempts=1006 completed=0 polite-1=RUNNABLE polite-2=RUNNABLEFour runs behaved identically: roughly two hundred attempts per second, completed stuck at zero, both threads RUNNABLE throughout. The attempt counts drift by a few between runs.

Now the diagnosis, which is the whole reason livelock is worse than deadlock. A thread dump of the livelocked JVM contains zero Found one Java-level deadlock sections, and the two threads look like this:
"polite-1" #20 [28419] prio=5 os_prio=31 cpu=2638.25ms elapsed=2.64s tid=0x0000000c3b116a00 nid=28419 runnable [0x000000016de2a000]
java.lang.Thread.State: RUNNABLE
at LivelockDemo.spinUntil(LivelockDemo.java:16)
at LivelockDemo.polite(LivelockDemo.java:27)RUNNABLE, and cpu=2638.25ms against elapsed=2.64s — the thread has spent essentially all of its wall-clock life on a CPU. ps reported the process at 197% CPU across the two threads. Those figures vary with load and are only meant to show the shape: everything a monitoring system watches says the service is healthy and working hard, and it has completed nothing. There is no automatic detector for this. You find it by noticing that a counter is not moving while CPU is high, then taking two dumps a few seconds apart and seeing the same threads in the same retry loop.
Fixing a livelock with randomised back-off
A livelock survives on symmetry, so the fix is to break the symmetry. Randomising the retry instant is enough — one line:
// randomised back-off: the two threads no longer retry on the same instant
long tick = ((System.nanoTime() / PERIOD) + 1) * PERIOD + rnd.nextLong(PERIOD);attempts=2 completed=2
both threads finished: trueFive runs, attempts=2 and completed=2 in every one: each thread succeeded on its first try. Since the offsets are random, a run in which one thread collides once and retries is possible and unremarkable — what matters is that a collision no longer repeats, because the next retry lands somewhere else.
Randomised back-off is the general answer to any symmetric retry: exponential back-off with jitter in a client, a random delay before re-acquiring, or simply a per-thread random component in the retry interval. A fixed back-off is what causes the problem, not what solves it.
Starvation, and what fair mode really costs
Starvation is the third failure. One thread never gets the lock, while other threads take it constantly and the program as a whole makes plenty of progress. Deadlock stops everyone, livelock keeps everyone busy and stops everyone, starvation stops exactly one thread and hides in a healthy-looking system.
The cause with a default ReentrantLock is barging: a thread that releases the lock and immediately asks for it again can win, because the queued thread first has to be unparked by the scheduler and that takes longer than a re-acquire. new ReentrantLock(true) switches to fair mode, where an available lock is handed to the longest-waiting thread instead.
static long[] run(boolean fair) throws InterruptedException {
ReentrantLock lock = new ReentrantLock(fair); // true = fair mode
long[] counts = new long[THREADS];
AtomicLong budget = new AtomicLong(TOTAL);
// 32 threads share a budget of 800,000 acquisitions; count what each one got
...
}Four sample runs, 32 threads and 800,000 acquisitions each time:
unfair min=15541 max=34735 max/min=2.2 never acquired=0
fair min=24996 max=25031 max/min=1.0 never acquired=0
unfair min=5115 max=119294 max/min=23.3 never acquired=0
fair min=24992 max=25145 max/min=1.0 never acquired=0
unfair min=18289 max=34564 max/min=1.9 never acquired=0
fair min=24989 max=25190 max/min=1.0 never acquired=0
unfair min=17138 max=37791 max/min=2.2 never acquired=0
fair min=24985 max=25180 max/min=1.0 never acquired=0Two honest observations. Fair mode split the budget almost perfectly every time — an even 25,000 each, within a fraction of a percent. The default lock did not, and the spread it produced was itself unpredictable: about 2x on three runs and 23x on one. And no thread was ever completely starved on this machine. Barging permits starvation, it does not guarantee it, and how bad it gets depends on core count, load and how long the lock is held. That is precisely why the bug is so hard to reproduce: the run where one thread got 5,115 acquisitions and another got 119,294 looks like the run before it from the outside.
Fair mode is still usually the wrong choice, and the JDK documentation says so plainly. Its ReentrantLock javadoc notes that programs using fair locks "may display lower overall throughput (i.e., are slower; often much slower) than those using the default setting, but have smaller variances in times to obtain locks and guarantee lack of starvation." The mechanism is easy to reason about without measuring anything: fair mode forbids the fast path. A thread releasing the lock cannot take it straight back while it is still hot in cache; every hand-off must park one thread and unpark another, which is a trip through the scheduler for every single acquisition.
Two more details before you reach for it. ReentrantLock.tryLock() — the un-timed form — does not honour the fairness setting; the javadoc calls this "barging" explicitly and tells you to use tryLock(0, TimeUnit.SECONDS) if you want fairness respected. And fairness of the lock is not fairness of the scheduler: the same javadoc warns that one of many threads using a fair lock may still obtain it several times in a row. Turn fair mode on when a starved thread would be a correctness or latency-SLA failure, and leave it off otherwise.
Practical rules that keep deadlock out of a codebase
| Rule | Which condition it attacks |
|---|---|
| Acquire locks in one documented global order, and write the order down where the locks are declared | Circular wait |
| Never call unknown code — a listener, callback or lambda — while holding a lock | Circular wait, via edges you did not write |
| Hold one lock at a time; if you need two, question the design first | Hold and wait |
Use tryLock with a timeout and a real failure path when an order is impossible | No preemption |
| Prefer immutable data and message passing over shared mutable state | Mutual exclusion |
Prefer java.util.concurrent collections and Atomic* over hand-rolled locking | Mutual exclusion |
| Never block a pool thread on work submitted to the same pool | Thread supply, which no detector models |
| Randomise every retry back-off | Livelock |
Two operational habits are worth as much as the rules. Name your threads, because pool-1-thread-3 in a dump tells you nothing while payment-writer-3 tells you everything. And run a findDeadlockedThreads() check on a schedule in any long-lived service, so a deadlock becomes an alert with a stack trace attached instead of a report that the site is slow.
FAQ
What is the difference between deadlock and livelock in Java?
In a deadlock the threads are BLOCKED and doing nothing: their state in a thread dump is BLOCKED (on object monitor), their CPU time stops climbing, and HotSpot prints Found one Java-level deadlock. In a livelock the threads are RUNNABLE and burning CPU: they keep acquiring and releasing locks, but each retry is undone by the other thread so no unit of work completes. A dump of a livelock shows no deadlock at all, which makes it the harder of the two to find. Both produce the same symptom from outside — a request that never returns.
How do I find a deadlock in a running Java application?
Take a thread dump with jstack <pid> or jcmd <pid> Thread.print and read the end of it: HotSpot analyses the wait-for graph itself and prints a Found one Java-level deadlock section naming the threads, the monitors and which thread holds what. Programmatically, ManagementFactory.getThreadMXBean().findDeadlockedThreads() returns the same information from inside the JVM and null when nothing is stuck, which is what you want on a schedule in production. If the dump reports nothing but threads are still stuck, look for WAITING (parking) frames — a thread-pool or Future deadlock never appears in the deadlock section.
Does using ReentrantLock instead of synchronized prevent deadlock?
Not by itself. Two ReentrantLock objects taken in opposite order deadlock exactly like two monitors, and jstack reports it as waiting for ownable synchronizer ... which is held by. What ReentrantLock adds is a way out: tryLock(timeout, unit) returns false instead of waiting forever, and lockInterruptibly() lets another thread break the wait. That gives you a way to attack the no-preemption condition, which synchronized simply does not offer. The prevention still comes from how you use it.
Why does lock ordering by System.identityHashCode need a tie-break lock?
Because System.identityHashCode returns an int and two distinct objects can share a value. When they do, the less than and greater than branches are both false, so without a third branch each caller falls back to the order of its own arguments — recreating the bug the ordering was supposed to remove. A collision showed up after about 105,000 objects on a freshly started JVM in the test above, so this is not a theoretical concern for a service with a large live set. The TIE_BREAK lock is a single static object that every thread takes first in the collision case, which restores a total order.
Can a thread pool deadlock even if my code has no locks?
Yes, and it is one of the most common production hangs. A fixed-size pool has a fixed number of threads; if a task blocks on the result of another task submitted to the same pool, that thread is unavailable to run the task it is waiting for. With two threads and two such tasks, the pool is stuck permanently. No monitor is involved, so jstack prints no deadlock section — the signature is every pool thread in WAITING (parking) on a FutureTask while the queue is non-empty. Use a separate pool for the nested work, or compose stages without blocking a pool thread.
Should I use a fair ReentrantLock to avoid starvation?
Usually not. Fair mode removes the barging fast path, so every hand-off has to park one thread and unpark another instead of letting the releasing thread re-acquire a lock that is still hot in cache; the JDK's own javadoc says fair locks are "often much slower". In the measurement above fair mode split 800,000 acquisitions almost exactly evenly across 32 threads while the default lock produced spreads from about 2x to 23x — but no thread was ever fully starved. Reach for fairness only when a starved thread is a correctness or latency-SLA failure, and remember that the un-timed tryLock() ignores the fairness setting entirely.
Can a deadlock recover on its own, or can I kill just the stuck threads?
No, and no. A deadlock is permanent by definition: every thread in the cycle is waiting for a resource that will only be released by another thread in the cycle. synchronized has no timeout, and Thread.interrupt() does not break a thread that is blocked entering a monitor — it only sets the flag, which the thread will observe once it acquires the lock it will never acquire. Thread.stop has thrown UnsupportedOperationException since Java 20 — on OpenJDK 21.0.6 it compiles with a removal warning and throws at run time — and it would have corrupted the guarded state anyway. Recovery means restarting the process; the fix belongs in the design.
Conclusion
Deadlock is the one concurrency bug with a complete theory behind it. Four conditions have to hold together, so four families of fix exist, and every technique in this article is one of them applied to a real program: a global order over identityHashCode to break circular wait, tryLock with a bounded retry to break no preemption, a shorter critical section to break hold and wait, and immutable data or a concurrent collection to remove mutual exclusion entirely. Reach for the order first — it has no retry path to get wrong.
The diagnosis matters as much as the prevention. A BLOCKED thread whose CPU time has stopped climbing while elapsed grows is a deadlock, and HotSpot will name it for you at the bottom of a thread dump. A RUNNABLE thread burning a full core while a counter stays at zero is a livelock, and nothing will name it for you. A thread that is simply never scheduled while the system stays busy is starvation, which new ReentrantLock(true) fixes at a real and usually unnecessary cost. Learn to tell those three apart in a dump and most of a bad on-call night is already over.
That closes Part 4 of this course. Part 5 turns to advanced Java I/O, starting with buffered streams and object serialization.