Look for the Found one Java-level deadlock: block the JVM appends to a thread dump. It names each thread in the cycle, the lock it is waiting to lock, and which thread already holds it — trace those "owned by" arrows and confirm they form a loop.
Generating the dump
jcmd <pid> Thread.print # or: jstack -l <pid> (-l shows ReentrantLock owners)
Reading the deadlock section
After the per-thread stacks, the JVM prints an explicit analysis. For the two-lock example (T1 takes A→B, T2 takes B→A):
Found one Java-level deadlock:
=============================
"T1":
waiting to lock monitor 0x00007f... (object 0x000000071, a java.lang.Object),
which is held by "T2"
"T2":
waiting to lock monitor 0x00007f... (object 0x000000070, a java.lang.Object),
which is held by "T1"
Java stack information for the threads listed above:
===================================================
"T1":
at TwoLockDeadlock.lambda$main$0(TwoLockDeadlock.java:11)
- waiting to lock <0x000000071> (a java.lang.Object) // LOCK_B
- locked <0x000000070> (a java.lang.Object) // LOCK_A
"T2":
at TwoLockDeadlock.lambda$main$1(TwoLockDeadlock.java:21)
- waiting to lock <0x000000071...> // wants LOCK_A 0x...70
- locked <0x000000071> (a java.lang.Object) // LOCK_B
How to read it:
- The summary block is the cycle. "T1 ... held by T2" and "T2 ... held by T1" close the loop — that is the proof of deadlock.
locked <addr>= a monitor this thread currently owns.waiting to lock <addr>= a monitor it is blocked trying to acquire.- Match the hex addresses. The address T1 is
waiting to lockequals the address T2 haslocked, and vice versa. The matching identities are what prove the cycle, not the names. - Each thread's state shows as
BLOCKED (on object monitor). ForReentrantLock, you instead seeparking to wait for ... AbstractQueuedSynchronizer, and you needjstack -lto see the owner in the "Locked ownable synchronizers" list.