Command Palette

Search for a command to run...

[Advanced Java] Deadlock and Livelock in Java: Diagnosing and Preventing Them

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.

Two threads and two locks wired into a closed cycle: each thread holds what the other is waiting for

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.

ConditionWhat it meansHow you break it
Mutual exclusionA resource can be held by one thread at a timeImmutable data, thread confinement, or a concurrent collection that needs no exclusive lock
Hold and waitA thread holding one lock asks for anotherAcquire everything in one step, or hold nothing while you acquire
No preemptionA lock cannot be taken away from its holdertryLock with a timeout, so a thread gives up its own lock voluntarily
Circular waitThe wait-for edges form a cycleA 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.

Java
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();
    }
}
Text
transfer-1: holds A, wants B
transfer-2: holds B, wants A

Then 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.

A trace of both threads acquiring in opposite order, and the wait-for graph whose edges close into a cycle

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.

Bash
jps -l                # lists running JVMs and their main classes
jstack 27395          # full thread dump on stdout

The HotSpot dump ends with a deadlock analysis. This is a verbatim capture from the hung DeadlockDemo above:

Text
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:

Text
"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:

Bash
jcmd 27395 Thread.print

Two 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.

Java
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:

Text
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:

Java
// 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:

Text
DEADLOCK after 3 threads got stuck
  "worker-0" waits for "worker-5"
  "worker-5" waits for "worker-4"
  "worker-4" waits for "worker-0"
Text
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.

The same two calls with and without a global lock order, and the rule that produces it

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:

Java
/** 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:

Text
all 160000 transfers finished
total before = 4000, total after = 4000

It 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:

Java
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;
    }
}
Text
collision after 105842 objects: identityHashCode = 2134400190
same hash, different objects? true

Around 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.

Java
/** 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;
}
Text
finished, gave up on 0 transfers
total after = 4000

Three 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.

Java
// 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.

Java
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);
Text
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 run

Two 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:

Text
"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.

Java
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.

Text
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=RUNNABLE

Four 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.

A livelock retry loop that never terminates, next to a starvation trace where the queued thread is never woken

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:

Text
"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:

Java
// randomised back-off: the two threads no longer retry on the same instant
long tick = ((System.nanoTime() / PERIOD) + 1) * PERIOD + rnd.nextLong(PERIOD);
Text
attempts=2  completed=2
both threads finished: true

Five 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.

Java
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:

Text
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=0

Two 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

RuleWhich condition it attacks
Acquire locks in one documented global order, and write the order down where the locks are declaredCircular wait
Never call unknown code — a listener, callback or lambda — while holding a lockCircular wait, via edges you did not write
Hold one lock at a time; if you need two, question the design firstHold and wait
Use tryLock with a timeout and a real failure path when an order is impossibleNo preemption
Prefer immutable data and message passing over shared mutable stateMutual exclusion
Prefer java.util.concurrent collections and Atomic* over hand-rolled lockingMutual exclusion
Never block a pool thread on work submitted to the same poolThread supply, which no detector models
Randomise every retry back-offLivelock

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.

Related Posts

[Advanced Java] SOLID Principles in Java: Five Rules and When to Break Them

The five SOLID principles in Java on OpenJDK 21, each with a before and after that compiles and runs: a class split by its reasons to change, a growing switch replaced by an interface, a subclass that breaks its caller with no warning, an UnsupportedOperationException the compiler could have prevented, a class that cannot run without a file, and where each principle stops paying for itself.

[Java Basics] Methods in Java: Declaring and Calling Them

How to declare and call a method in Java: the parts of a declaration, static versus instance methods and the non-static method cannot be referenced from a static context error, the return statement, the call stack and StackOverflowError, reading a stack trace, Javadoc, and the real compiler errors beginners hit.

[Advanced Java] Layered Architecture in Java: Controller, Service and Repository

Layered architecture in Java without a framework: one class that parses input, applies the rules and writes to a database, refactored into Controller, Service and Repository on OpenJDK 21 — the repository interface owned by the service package, the dependency direction proved by javac, wiring by hand in main, entity versus DTO with the mapper between them, a JUnit 5 suite that runs with no database, and the four ways layering goes wrong.

[Advanced Java] Comparable vs Comparator in Java: Natural Ordering and Custom Sorting

Comparable and Comparator on OpenJDK 21: the full compareTo contract, the Comparator factory and combinator API, why reversed flips every key composed so far, the int subtraction overflow bug, the TimSort IllegalArgumentException that only fires on large inputs, null keys, and sorting stability measured in comparator invocations.