Deadlock: Reproducing It, Detecting It, and Two Ways to Avoid It

Part 9 of 9 in Java Concurrency: Deep Dive

A deadlock happens when two threads each hold a lock the other one needs, and neither will let go first — both wait forever. This post compiles and runs three small Java programs: one that actually deadlocks and detects it programmatically, one that avoids it by acquiring locks in a consistent order, and one that avoids it a different way, by never blocking indefinitely on a second lock at all.

Reproducing a deadlock

DeadlockRepro starts two threads and two plain monitor locks, lockA and lockB. t1 locks A then, after a short sleep, tries to lock B. t2 does the mirror image: locks B then tries to lock A. If both threads get their first lock before either reaches the second synchronized block, each is stuck waiting on a lock the other one is holding.

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;

public class DeadlockRepro {
    static final Object lockA = new Object();
    static final Object lockB = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("t1: holding lockA, waiting for lockB");
                sleep(200);
                synchronized (lockB) {
                    System.out.println("t1: holding both locks");
                }
            }
        }, "t1-A-then-B");

        Thread t2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("t2: holding lockB, waiting for lockA");
                sleep(200);
                synchronized (lockA) {
                    System.out.println("t2: holding both locks");
                }
            }
        }, "t2-B-then-A");

        t1.start();
        t2.start();

        Thread.sleep(1000);

        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        long[] deadlockedIds = bean.findDeadlockedThreads();
        if (deadlockedIds != null) {
            System.out.println("Deadlock detected among " + deadlockedIds.length + " threads:");
            for (ThreadInfo info : bean.getThreadInfo(deadlockedIds)) {
                System.out.println("  " + info.getThreadName() + " is " + info.getThreadState()
                    + " waiting on " + info.getLockName() + ", owned by " + info.getLockOwnerName());
            }
        } else {
            System.out.println("No deadlock detected");
        }
        System.exit(0);
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

The 200ms sleep between grabbing the first lock and reaching for the second widens the window on purpose, so both threads are guaranteed to be holding their first lock before either tries for the second — a real race would usually need many iterations to hit this, this just forces it every time. ThreadMXBean.findDeadlockedThreads() is the JDK’s own cycle detector: it walks the lock-ownership graph the JVM already tracks and returns the IDs of any threads stuck in a cycle, which is the same graph jstack/jcmd Thread.print read from when a thread dump reports a Found one Java-level deadlock block.

Real javac+java output, JDK 21 (Temurin):

t1: holding lockA, waiting for lockB
t2: holding lockB, waiting for lockA
Deadlock detected among 2 threads:
  t1-A-then-B is BLOCKED waiting on java.lang.Object@4dd8dc3, owned by t2-B-then-A
  t2-B-then-A is BLOCKED waiting on java.lang.Object@6f496d9f, owned by t1-A-then-B

Neither thread ever printed “holding both locks” — both are permanently BLOCKED, each waiting on the object the other one owns. t1 never got past its synchronized (lockB) line and t2 never got past its synchronized (lockA) line. This is also why the program calls System.exit(0) instead of letting main return normally: t1 and t2 are non-daemon threads that will never finish, so without an explicit exit the JVM process would just hang forever after printing the detection output.

Fix 1: consistent lock ordering

The deadlock above only happens because the two threads acquire lockA and lockB in opposite order. DeadlockFixed is the same program with one line changed: t2 now locks A before B, same as t1.

public class DeadlockFixed {
    static final Object lockA = new Object();
    static final Object lockB = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("t1: holding lockA, waiting for lockB");
                sleep(200);
                synchronized (lockB) {
                    System.out.println("t1: holding both locks");
                }
            }
        }, "t1-A-then-B");

        Thread t2 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("t2: holding lockA, waiting for lockB");
                sleep(200);
                synchronized (lockB) {
                    System.out.println("t2: holding both locks");
                }
            }
        }, "t2-A-then-B");

        long start = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("both threads finished after " + (System.currentTimeMillis() - start) + "ms, no deadlock");
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

Real javac+java output, JDK 21 (Temurin):

t1: holding lockA, waiting for lockB
t1: holding both locks
t2: holding lockA, waiting for lockB
t2: holding both locks
both threads finished after 402ms, no deadlock

Whichever thread reaches synchronized (lockA) first now holds both locks it needs and finishes its whole critical section before the other thread can even start — the output shows t1 fully completing (“holding both locks”) before t2 prints anything, then running back-to-back rather than interleaved. There’s no cycle possible because both threads walk the lock graph in the same direction. This is the standard fix and it costs nothing at runtime, but it requires every piece of code that ever locks both A and B to agree on the order, which gets harder to guarantee as a codebase grows and locks get acquired from more call sites.

Fix 2: tryLock() instead of blocking indefinitely

DeadlockAvoidedTryLock takes a different approach: instead of enforcing an order, it never blocks forever on the second lock. Both threads use ReentrantLock and tryLock(timeout, unit); if a thread can’t get both locks within the timeout, it releases whatever it’s holding and retries after a random backoff.

import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class DeadlockAvoidedTryLock {
    static final Lock lockA = new ReentrantLock();
    static final Lock lockB = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> acquireBoth("t1", lockA, lockB), "t1-A-then-B");
        Thread t2 = new Thread(() -> acquireBoth("t2", lockB, lockA), "t2-B-then-A");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("both threads finished after " + (System.currentTimeMillis() - start) + "ms, no deadlock");
    }

    static void acquireBoth(String name, Lock first, Lock second) {
        Random random = new Random();
        int attempt = 0;
        while (true) {
            attempt++;
            boolean gotFirst = false, gotSecond = false;
            try {
                gotFirst = first.tryLock(100, TimeUnit.MILLISECONDS);
                if (gotFirst) {
                    Thread.sleep(50);
                    gotSecond = second.tryLock(100, TimeUnit.MILLISECONDS);
                    if (gotSecond) {
                        System.out.println(name + ": acquired both locks on attempt " + attempt);
                        return;
                    }
                }
                System.out.println(name + ": attempt " + attempt + " failed to get both locks, backing off");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            } finally {
                if (gotSecond) second.unlock();
                if (gotFirst) first.unlock();
            }
            try {
                Thread.sleep(random.nextInt(50));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }
}

Note this keeps the same opposite-order acquisition as the original broken version (t1 tries A then B, t2 tries B then A) — the fix here isn’t ordering, it’s that neither tryLock call can block forever.

Real javac+java output, JDK 21 (Temurin):

t1: attempt 1 failed to get both locks, backing off
t2: attempt 1 failed to get both locks, backing off
t1: attempt 2 failed to get both locks, backing off
t2: acquired both locks on attempt 2
t1: acquired both locks on attempt 3
both threads finished after 373ms, no deadlock

Both threads’ first attempts failed — each had grabbed its own first lock during the 50ms simulated-work sleep, then found the second lock already taken, so both tryLock(100ms) calls for the second lock timed out. On failure the finally block releases whatever that thread is holding before backing off, so neither thread parks on a lock forever; t2 succeeded on attempt 2, t1 needed a third attempt once t2 had released everything. Because the retry order is effectively random, running this a second and third time reordered which thread won which attempt, but every run finished — the point isn’t that this specific interleaving is guaranteed, it’s that no attempt can hang, so the pair always eventually converges.

Takeaway

The first run showed a genuine deadlock — ThreadMXBean reported both threads BLOCKED on each other’s lock, and the JVM had to be killed with System.exit(0) because neither would ever finish on its own. Consistent lock ordering removed the cycle entirely and cost nothing at runtime, but only works if every caller that locks both resources agrees on the order. tryLock() with a timeout and backoff didn’t need that agreement — it let both threads keep the opposite acquisition order from the original bug — at the cost of retry logic and occasionally doing the same partial work more than once before it commits.