> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Java Deadlock: Detect It, Read the Dump, Prevent It
- URL: https://www.ggorantala.dev/java-deadlock/
- Published: 2026-10-07T12:14:00.000Z
- Updated: 2026-10-30T13:44:27.000Z
- Description: Found one Java-level deadlock in a thread dump? How to read it, what ThreadMXBean misses (virtual threads, pool starvation), and the fixes that hold.
- Author: Gopi Gorantala
- Tags: deadlock, java-locks, java-threads, java-concurrency

CPU at 2%. Requests timing out. The health check still answers, because it runs on its own thread, so the load balancer thinks everything's fine. Heap is flat, GC is bored, and every worker thread is sitting exactly where it was ten minutes ago.

That's what a deadlock in Java looks like from the outside: nothing. No exception, no error log, no spike. The service just stops doing the one thing you needed. And if you take a thread dump you might get this, which is the nice case:

```log
Found one Java-level deadlock:
=============================
"mover-1":
  waiting to lock monitor 0x00007f4ef8050190 (object 0x00000000a8518aa0, a java.lang.Object),
  which is held by "mover-2"

"mover-2":
  waiting to lock monitor 0x00007f4ef40015b0 (object 0x00000000a8518a90, a java.lang.Object),
  which is held by "mover-1"

```

`jcmd <pid> Thread.print` on OpenJDK 21.0.12.1, straight from the lab below. Two threads, two locks, each holding the one the other wants. JDK did the work for you and named the culprits.

The not-nice cases are the reason I wrote this. I'll show you three hangs that look the same on a dashboard and only one of them prints that block. One shows up in `ThreadMXBean` but not in the `jstack` text. Another shows up in neither.

## The short version: which part is stuck, and who's responsible

A deadlock is a cycle: thread A holds lock 1 and waits for lock 2, thread B holds lock 2 and waits for lock 1\. Nobody can move, because the only thing that would release a lock is the thread that's waiting. For it to happen you need four things at once (the Coffman conditions): locks that only one thread can hold, threads that hold one while asking for another, locks nobody can take away, and a circular wait. Break any one and it can't happen. In practice you break the last one, the circle.

The responsible component is always your lock acquisition order, not the JVM. The JVM can't fix it, it can only tell you, and only for some kinds of locks and some kinds of threads.

One more distinction that cost me a Tuesday: a *deadlock* is a cycle of lock owners. A pool that's full of tasks waiting on other tasks queued behind them is a *resource* deadlock, and nobody holds a lock at all. Same symptom. Different dump. Different tooling answer.

## Reproduce it, then break it three more ways

Everything below is one file, `Lab.java`, run as `java Lab.java <mode>`. My machine: 2 vCPU x86\_64 Linux, OpenJDK 21.0.12.1 (Ubuntu build). I did not run this on JDK 25 or on ARM, so treat the version-dependent claims accordingly.

The core is a toy order-book transfer: move one order from book A to book B, locking source first, then destination. Two threads move in opposite directions.

```java
static void moveSync(Book a, Book b, boolean widen) throws InterruptedException {
    synchronized (a) {
        if (widen) Thread.sleep(50);      // widens the window; a demo lever, not production behaviour
        synchronized (b) { a.orders--; b.orders++; }
    }
}

```

The `ReentrantLock` version does the same with `a.rl.lock()` then `b.rl.lock()`, and two threads start together behind a `CountDownLatch` gate. After half a second the lab asks `ThreadMXBean` what it thinks:

```java
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
mx.findMonitorDeadlockedThreads();  // only synchronized monitors
mx.findDeadlockedThreads();         // monitors + ownable synchronizers (ReentrantLock etc.)

```

Here's what I got:

```log
$ java Lab.java sync
t1=BLOCKED t2=BLOCKED
findMonitorDeadlockedThreads=[mover-1, mover-2]  findDeadlockedThreads=[mover-1, mover-2]

$ java Lab.java rl
t1=WAITING t2=WAITING
findMonitorDeadlockedThreads=null  findDeadlockedThreads=[mover-1, mover-2]

$ java Lab.java rlv        (same ReentrantLock code, virtual threads)
t1=WAITING t2=WAITING
findMonitorDeadlockedThreads=null  findDeadlockedThreads=null

$ java Lab.java pool       (fixed pool of 2, each task submits a child to the same pool and get()s it)
futures done? false false
findMonitorDeadlockedThreads=null  findDeadlockedThreads=null
queued=2 active=2

```

Four hangs, and only the first two are reported. The second needs the *right* method: `findMonitorDeadlockedThreads()` is the older one and it only understands `synchronized`. If your detection check in CI or in a health endpoint calls that, a `ReentrantLock` deadlock sails straight past it. The Javadoc says so, but who rereads the Javadoc of a method they copied.

The third row is the surprise. Same bug, same lock, but the threads are virtual, and `findDeadlockedThreads()` returns `null`. I'd expected a miss on the text `jstack`, but the management bean? I reran it twice before I believed it. The `ThreadMXBean` deadlock detection is built around platform threads, and virtual threads aren't in its view.

How often does it show up *without* my 50 ms sleep? That matters, because it's why people don't believe in their own deadlocks:

```log
single transfer each, no widening:  deadlocked in 0 of 1000 trials
100,000 transfers each way, 20 runs: deadlocked in 20 of 20

```

Zero out of a thousand for the "one transfer" shape, which is what every unit test does. Twenty out of twenty when the threads actually run for a while. The window is a few nanoseconds wide and the bug is certain, as long as you give it enough tries. A test that passes tells you nothing.

## What the dump is telling you

Back to the dump. The line that matters is `waiting to lock <0x...> (a ...)` next to `locked <0x...>` in each thread's stack. Match the addresses: A has locked `...a90` and wants `...aa0`, B has locked `...aa0` and wants `...a90`. That's the cycle, and you can see it even when the JVM doesn't print the "Found one" block.

For `synchronized` the thread state is `BLOCKED (on object monitor)`. For `ReentrantLock` it's `WAITING (parking)`, with a line `parking to wait for <0x...> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)`. The `Lab` output earlier shows the same thing via the bean: `t1 waiting on ...ReentrantLock$NonfairSync@3c01cfa1 owned by mover-2`. The owner is recorded because `ReentrantLock` is an "ownable synchronizer", which is exactly what `findDeadlockedThreads()` and `jstack` use to follow the chain. A plain `Semaphore` or a `CountDownLatch` has no owner, so a cycle through one of those is invisible to every tool here.

Two or three dumps, a few seconds apart, is the habit worth having. If the same threads sit at the same lines with the same addresses, it's stuck, not slow. If the addresses change, it's contention, and you're looking at a different article.

### Virtual threads: the dump you have to ask for differently

I put two virtual threads on two `ReentrantLock`s in a program and took the classic text dump:

```log
$ jcmd <pid> Thread.print | grep -c vmover
0

```

Zero. The text dump didn't list them. The JSON form, `jcmd <pid> Thread.dump_to_file -format=json file.json` (JDK 21), does:

```log
<root> vmover-1 ['java.base/java.lang.VirtualThread.park(VirtualThread.java:596)', 'java.base/java.lang.System$2.parkVirtualThread(...)', ..., 'java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)']
<root> vmover-2 [same]

```

Both parked forever, no "deadlock found" anywhere. You get the frames, and you can read the cycle out of the stacks by hand if you look at what each one is parked on. But nothing flags it for you. If you've moved a service to virtual threads, "the JVM will tell me about deadlocks" stopped being true, and I'd bet nobody updated the runbook. I'm calling that out because I didn't, for the better part of a week.

## Where this bites you in production

**The ordering you can't see.** `transfer(from, to)` locks `from` then `to`. Two callers pass the arguments in opposite order and you've built the lab. No class in your code mentions two locks in a row; the cycle only exists at runtime, depending on the data. This is the dynamic ordering deadlock and it's the most common one I see.

**The alien call.** You hold your lock and call a listener, a callback, an overridden method, a logger appender. That code takes *its* lock, and somewhere it calls back into you. This is the "open call" problem: calling out to code you don't control while holding a lock.

**The pool that waits on itself.** The `pool` row above. Two worker threads, two tasks, each blocked on `Future.get()` for a child task that is sitting in the queue behind them. Nobody holds a monitor. `ThreadMXBean` says everything's fine; both workers say `WAITING`. Raising the pool size from 2 to 3 "fixes" it right up until traffic doubles. It's a design problem, not a sizing one.

**The `synchronized` that looks like it should be enough.** `Collections.synchronizedMap` plus client-side locking on two maps in opposite orders. Same cycle, different spelling.

What *isn't* a deadlock, but gets reported as one: a thread `BLOCKED` on a monitor held by a thread that's busy running (contention), a thread parked on a `Condition` nobody signals (lost wakeup), and a socket read with no timeout. The discriminator is the cycle. No cycle, no deadlock. Follow `locked`/`waiting to lock` until you either get back to where you started or hit a thread that's actually running.

## The fix, in the order I'd try them

One sentence of *why* each time, because the order matters.

**1\. Impose a global lock order.** A cycle needs two threads to disagree about the order. If everyone locks in the same order, the circle can't close. For objects, pick something stable like an ID:

```java
static void moveOrdered(Book a, Book b) {
    Book first = a.id < b.id ? a : b, second = first == a ? b : a;
    synchronized (first) { synchronized (second) { a.orders--; b.orders++; } }
}

```

I ran this with a million transfers each way across two threads: finished, no hang, orders still 100/100\. (Equal IDs need a tiebreak lock; I haven't shown that here. `System.identityHashCode` can collide, so don't lean on it without one.)

**2\. Hold fewer locks, for less time.** Take what you need, copy it out, release, then call anything unfamiliar. Cheap, and it also fixes the alien-call variety.

**3\. `tryLock` with a timeout, when you can't impose an order.** If you can't get the second lock, release the first and try again. This breaks "hold and wait":

```java
if (a.rl.tryLock()) {
    try { if (b.rl.tryLock()) { try { /* work */ } finally { b.rl.unlock(); } } }
    finally { a.rl.unlock(); }
}

```

The lab with 100,000 transfers each way finished with 200,000 completed and 35,299 retries. That cost is the part that gets glossed over: you've traded a hang for spinning, and two threads that keep backing off in lockstep become a livelock. Add a randomised pause between attempts. In my run I used `Thread.onSpinWait()` and got away with it; I wouldn't count on that on a busier box.

**4\. For the pool variety: don't wait inside the pool for work in the same pool.** Use a different executor for child tasks, or compose with `CompletableFuture` so no thread blocks on `get()`. Pool bigger isn't a fix.

And here are the "fixes" that aren't. Adding `volatile`: irrelevant, it's not a visibility problem. A `Thread.sleep` in tests: you'll just move the odds around. Wrapping everything in one big lock: yes, that works, you have just built a single-threaded program.

## Catching it before prod does

For `synchronized` and `ReentrantLock` on platform threads: schedule a check that calls `findDeadlockedThreads()` (not the monitor-only one) and logs the stacks of what it finds. It's cheap, and it's the difference between "the service was hung for an hour" and a page within a minute.

For virtual threads, the bean won't help you. Alert on what you can see: in-flight request count that never drops, an executor's completed-task counter that stops moving while active stays above zero, request latency with a hard timeout. And know the JSON dump command before you need it.

A stress test with a start gate is the test that catches the order bug, like the 100,000-iteration run above. It found the deadlock in 20 of 20 runs where the "one call each" test found it in zero of 1000\. Make it a CI test with a timeout and a `findDeadlockedThreads()` assertion.

A version note. Everything above ran on JDK 21\. JDK 24 changed how `synchronized` interacts with virtual threads (JEP 491), and I'd expect the dump of a `synchronized` deadlock between virtual threads to look different there, but I haven't run it, so don't take that as verified. Retest your detection code when you move LTS to LTS.

I'll admit the part that took longest wasn't the deadlock. It was working out that three different kinds of hang report differently, and that the tool I trusted most reported the least. This was a long day with a stopwatch and a pile of dumps. I'm writing it down so the next one is shorter.

## Takeaways

- A deadlock is a lock cycle. Match the `locked` and `waiting to lock` addresses and you can find it even when the tool stays quiet.
- `findMonitorDeadlockedThreads()` misses `ReentrantLock`. Use `findDeadlockedThreads()`.
- On virtual threads neither method reports anything (JDK 21, my run), and the text dump doesn't list the threads. Use `Thread.dump_to_file -format=json`.
- A pool starving on its own children has no lock cycle and no "Found one" block. More threads delay it; they don't fix it.
- One call each passed 1000 of 1000\. A hundred thousand each deadlocked 20 of 20\. Test with volume.

## Quick answers

**How do I detect a deadlock in a running Java process?**  
Run `jcmd <pid> Thread.print` (or `jstack <pid>`) and look for `Found one Java-level deadlock`. Programmatically, call `ThreadMXBean.findDeadlockedThreads()`. It covers `synchronized` and ownable synchronizers like `ReentrantLock`.

**Does ThreadMXBean find deadlocks between virtual threads?**  
Not in my JDK 21.0.12 test: two virtual threads deadlocked on two `ReentrantLock`s and `findDeadlockedThreads()` returned `null`. The JSON thread dump shows them parked.

**Is a thread pool where every thread is waiting a deadlock?**  
It hangs like one, but there's no lock cycle, and the JVM won't report it. It's pool starvation. See the [thread starvation article](https://ggorantala.dev/java-thread-starvation/?ref=ggorantala.dev) and the [ThreadPoolExecutor admission rules](https://ggorantala.dev/java-threadpoolexecutor/?ref=ggorantala.dev).

**Does `tryLock` prevent deadlock?**  
It breaks the hold-and-wait condition, so no hang. You pay in retries, and lockstep retries can turn into a [livelock](https://ggorantala.dev/java-livelock/?ref=ggorantala.dev).

## Full lab

Save as `Lab.java`, run `java Lab.java sync|rl|rlv|freq|pool|try|ordered`. (`Hang.java` for the dump section is the same idea with `Thread.sleep(120000)` at the end so you can run `jcmd` against it.) Cleanup: `jps`, then `kill` any leftover JVM.

```java
import java.lang.management.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;

public class Lab {
    static final ThreadMXBean MX = ManagementFactory.getThreadMXBean();

    static class Book {
        final String name; final int id; int orders = 100;
        final ReentrantLock rl = new ReentrantLock();
        Book(String n, int id) { name = n; this.id = id; }
    }

    // --- monitor version: lock source, then dest
    static void moveSync(Book a, Book b, boolean widen) throws InterruptedException {
        synchronized (a) {
            if (widen) Thread.sleep(50);
            synchronized (b) { a.orders--; b.orders++; }
        }
    }
    static void moveSyncOrdered(Book a, Book b) {
        Book first = a.id < b.id ? a : b, second = first == a ? b : a;
        synchronized (first) { synchronized (second) { a.orders--; b.orders++; } }
    }
    static void moveRl(Book a, Book b, boolean widen) throws InterruptedException {
        a.rl.lock();
        try {
            if (widen) Thread.sleep(50);
            b.rl.lock();
            try { a.orders--; b.orders++; } finally { b.rl.unlock(); }
        } finally { a.rl.unlock(); }
    }

    static String names(long[] ids) {
        if (ids == null) return "null";
        return Arrays.toString(MX.getThreadInfo(ids).length > 0 ? Arrays.stream(MX.getThreadInfo(ids)).map(ThreadInfo::getThreadName).toArray() : new Object[0]);
    }

    static void report(String label) {
        System.out.println(label + ": findMonitorDeadlockedThreads=" + names(MX.findMonitorDeadlockedThreads())
            + "  findDeadlockedThreads=" + names(MX.findDeadlockedThreads()));
    }

    interface Mover { void go(Book a, Book b) throws Exception; }

    static void twoThreads(String label, Mover m, boolean virtual) throws Exception {
        Book x = new Book("NYSE", 1), y = new Book("LSE", 2);
        CountDownLatch gate = new CountDownLatch(1);
        Thread.Builder tb = virtual ? Thread.ofVirtual() : Thread.ofPlatform().daemon(true);
        Thread t1 = tb.name("mover-1").unstarted(() -> { try { gate.await(); m.go(x, y); } catch (Exception e) {} });
        Thread t2 = tb.name("mover-2").unstarted(() -> { try { gate.await(); m.go(y, x); } catch (Exception e) {} });
        t1.start(); t2.start(); gate.countDown();
        Thread.sleep(500);
        System.out.println("-- " + label + (virtual ? " (virtual threads)" : ""));
        System.out.println("t1=" + t1.getState() + " t2=" + t2.getState());
        report(label);
        if (!virtual) { ThreadInfo[] ti = MX.getThreadInfo(new long[]{t1.threadId()}, true, true);
            ThreadInfo i = ti[0]; System.out.println("t1 waiting on " + i.getLockName() + " owned by " + i.getLockOwnerName()); }
    }

    // frequency without widening
    static void freq(int trials) throws Exception {
        int dead = 0;
        for (int i = 0; i < trials; i++) {
            Book x = new Book("A", 1), y = new Book("B", 2);
            CountDownLatch gate = new CountDownLatch(1);
            Thread t1 = new Thread(() -> { try { gate.await(); for (int k=0;k<1;k++) moveSync(x, y, false); } catch (Exception e) {} });
            Thread t2 = new Thread(() -> { try { gate.await(); for (int k=0;k<1;k++) moveSync(y, x, false); } catch (Exception e) {} });
            t1.setDaemon(true); t2.setDaemon(true);
            t1.start(); t2.start(); gate.countDown();
            t1.join(200); t2.join(200);
            if (t1.isAlive() || t2.isAlive()) dead++;
        }
        System.out.println("no-widen single transfer each: deadlocked in " + dead + " of " + trials);
        // loop of many transfers each
        dead = 0;
        for (int i = 0; i < 20; i++) {
            Book x = new Book("A", 1), y = new Book("B", 2);
            CountDownLatch gate = new CountDownLatch(1);
            Thread t1 = new Thread(() -> { try { gate.await(); for (int k=0;k<100000;k++) moveSync(x, y, false); } catch (Exception e) {} });
            Thread t2 = new Thread(() -> { try { gate.await(); for (int k=0;k<100000;k++) moveSync(y, x, false); } catch (Exception e) {} });
            t1.setDaemon(true); t2.setDaemon(true);
            t1.start(); t2.start(); gate.countDown();
            t1.join(1500); t2.join(1500);
            if (t1.isAlive() || t2.isAlive()) dead++;
        }
        System.out.println("100k transfers each way, 20 runs: deadlocked in " + dead + " of 20");
    }

    static void poolStarve() throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2, r -> { Thread t = new Thread(r); t.setDaemon(true); return t; });
        List<Future<Integer>> fs = new ArrayList<>();
        for (int i = 0; i < 2; i++) {
            fs.add(pool.submit(() -> {
                Future<Integer> child = pool.submit(() -> 42);
                return child.get(); // waits on a task queued behind us in the same pool
            }));
        }
        Thread.sleep(500);
        System.out.println("-- pool starvation");
        System.out.println("futures done? " + fs.get(0).isDone() + " " + fs.get(1).isDone());
        report("pool");
        for (Thread t : Thread.getAllStackTraces().keySet()) if (t.getName().startsWith("Thread-")) System.out.println(t.getName()+" "+t.getState());
        System.out.println(MX.getThreadInfo(MX.getAllThreadIds()).length + " threads");
        ((ThreadPoolExecutor) pool).getQueue();
        System.out.println("queued=" + ((ThreadPoolExecutor) pool).getQueue().size() + " active=" + ((ThreadPoolExecutor) pool).getActiveCount());
        pool.shutdownNow();
    }

    static void tryLockFix() throws Exception {
        int[] retries = {0}; 
        Book x = new Book("A", 1), y = new Book("B", 2);
        CountDownLatch gate = new CountDownLatch(1);
        java.util.concurrent.atomic.AtomicInteger done = new java.util.concurrent.atomic.AtomicInteger(), retry = new java.util.concurrent.atomic.AtomicInteger();
        Runnable r1 = () -> loopTry(x, y, gate, done, retry);
        Runnable r2 = () -> loopTry(y, x, gate, done, retry);
        Thread t1 = new Thread(r1), t2 = new Thread(r2); t1.setDaemon(true); t2.setDaemon(true);
        t1.start(); t2.start(); gate.countDown(); t1.join(5000); t2.join(5000);
        System.out.println("-- tryLock fix: completed=" + done.get() + " retries=" + retry.get() + " alive=" + (t1.isAlive()||t2.isAlive()));
    }
    static void loopTry(Book a, Book b, CountDownLatch gate, java.util.concurrent.atomic.AtomicInteger done, java.util.concurrent.atomic.AtomicInteger retry) {
        try { gate.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
        for (int k = 0; k < 100000; k++) {
            while (true) {
                if (a.rl.tryLock()) {
                    try { if (b.rl.tryLock()) { try { a.orders--; b.orders++; done.incrementAndGet(); break; } finally { b.rl.unlock(); } } }
                    finally { a.rl.unlock(); }
                }
                retry.incrementAndGet(); Thread.onSpinWait();
            }
        }
    }
    static void orderedStress() throws Exception {
        Book x = new Book("A", 1), y = new Book("B", 2);
        CountDownLatch gate = new CountDownLatch(1);
        Thread t1 = new Thread(() -> { try { gate.await(); for (int k=0;k<1_000_000;k++) moveSyncOrdered(x, y); } catch (Exception e) {} });
        Thread t2 = new Thread(() -> { try { gate.await(); for (int k=0;k<1_000_000;k++) moveSyncOrdered(y, x); } catch (Exception e) {} });
        t1.setDaemon(true); t2.setDaemon(true);
        t1.start(); t2.start(); gate.countDown(); t1.join(20000); t2.join(20000);
        System.out.println("-- ordered locks 1M each way: alive=" + (t1.isAlive()||t2.isAlive()) + " orders " + x.orders + "/" + y.orders);
    }

    public static void main(String[] a) throws Exception {
        String mode = a.length > 0 ? a[0] : "sync";
        switch (mode) {
            case "sync" -> twoThreads("synchronized", (p, q) -> moveSync(p, q, true), false);
            case "rl" -> twoThreads("ReentrantLock", (p, q) -> moveRl(p, q, true), false);
            case "rlv" -> twoThreads("ReentrantLock", (p, q) -> moveRl(p, q, true), true);
            case "syncv" -> twoThreads("synchronized", (p, q) -> moveSync(p, q, true), false);
            case "freq" -> freq(1000);
            case "pool" -> poolStarve();
            case "try" -> tryLockFix();
            case "ordered" -> orderedStress();
        }
        System.exit(0);
    }
}

```