
How to Diagnose Deadlocks in Java Thread Dumps Without Guesswork
Few production incidents are as quietly frustrating as a deadlock. Nothing crashes, the CPU stays low, the logs stop mid-sentence, and a handful of requests simply never return. You take a thread dump, stare at several hundred lines of stack traces, and start guessing.
You don't have to guess. A dump already contains the two threads involved, the two objects they are fighting over, and the exact lines of code where the cycle closes. You only need to know which lines matter.
What a Deadlock Actually Looks Like
The state you care about is BLOCKED (on object monitor), usually paired with a line reading waiting to lock. A blocked thread is not sleeping and not waiting for a notification — it is queued for a monitor that somebody else is holding, and it cannot proceed until that monitor is released.
Contrast that with WAITING and TIMED_WAITING, which are normal. A thread parked in a queue, sitting in Object.wait(), or sleeping in a scheduled task might be perfectly healthy. A single WAITING thread is rarely the story; a pair of BLOCKED threads pointing at each other always is.
Deadlocks also tend to look boring from the outside: low CPU, no exceptions, no heap growth. If your monitoring shows a healthy process that has stopped serving requests, that profile fits.
Capture a Dump You Can Trust
Any of these will work, and the first is the one to reach for on a modern JDK:
- jcmd <pid> Thread.print -l — the current standard. The -l flag adds java.util.concurrent lock information that is otherwise invisible.
- jstack -l <pid> — the classic equivalent, still useful on older JDKs.
- kill -3 <pid> — sends SIGQUIT on Linux and macOS, printing the dump to the process's stdout. Handy when you cannot attach a tool, awkward in containers where stdout is buried.
- JVisualVM, JMC or your IDE — fine for local reproduction, less so for production.
Take three dumps, five to ten seconds apart, and note the JDK version. A genuine deadlock looks identical in all three. Threads that are merely slow will have moved. Run the command as the same user that owns the process, otherwise you may get an empty or partial dump and waste a diagnostic window chasing nothing.
Reading Lock Ownership
Inside each thread stanza, two kinds of line describe monitors:
- - waiting to lock <0x…> (a com.example.Account) — the monitor this thread wants and cannot get.
- - locked <0x…> (a com.example.Account) — the monitor this thread currently holds.
The hex value is the object's identity. Match it character for character; two accounts of the same class have different addresses, and that is precisely what lets you name the culprit. The first waiting to lock line at the top of a BLOCKED frame is the immediate blocker; locked lines further down show what the thread was holding when it got stuck.
Two related lines mean something different. - waiting on <0x…> and - parking to wait for <0x…> belong to java.util.concurrent locks, and they only appear with -l. A thread sitting in Object.wait() is waiting for a notification, which is a different bug class entirely — usually a missing notify() or a lost wake-up.
Walking the Cycle by Hand
If the dump does not announce a deadlock, build the cycle yourself. A worked pair, stripped down:
"transfer-a" ... java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.Account.transfer(Account.java:42)
- waiting to lock <0x000000076ab12345> (a com.example.Account)
- locked <0x000000076ab67890> (a com.example.Account)
"transfer-b" ... java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.Account.transfer(Account.java:42)
- waiting to lock <0x000000076ab67890> (a com.example.Account)
- locked <0x000000076ab12345> (a com.example.Account)
The procedure that gets you there is always the same:
- Grep the dump for waiting to lock to list every blocked thread.
- For each one, note the hex of the monitor it wants and the method on the top frame.
- Search the same dump for that hex in a locked line to find the current owner.
- If that owner is itself blocked on a monitor held by your first thread, you have found the cycle.
- Read both stacks downwards to see the order in which each thread acquired its locks. In this example, transfer-a takes 67890 then wants 12345, and transfer-b does the reverse.
Both threads are usually inside your own code, often in the same method with arguments swapped. That is the moment the guessing stops.
When the JVM Spots It For You
Both jcmd Thread.print and jstack include a Found one Java-level deadlock section followed by Found 1 deadlock when monitors form a clean cycle. Read it, then verify it against the stacks above; it is a shortcut, not a substitute for understanding the lock order.
The reverse matters more. A missing deadlock header does not mean there is no deadlock. Detection covers monitor cycles and, with -l, ownable synchronisers. It knows nothing about thread pool starvation, database locks, remote calls or locks held across processes.
Deadlocks the JVM Won't Report
Some of the worst offenders never appear in that header. All the worker threads in a bounded pool parked while waiting for tasks they themselves must execute is a deadlock by any practical definition, yet the dump shows only a sea of WAITING threads and an idle queue consumer. Two services each waiting on an HTTP call from the other behaves the same way. So does a database deadlock, which lives in the database's own logs, not your dump.
Look for the shape rather than the label: every thread that could make progress is waiting on something only another waiting thread can provide.
Turning the Dump into a Fix
The standard cure is a consistent lock order: every method acquires the same monitors in the same sequence, ideally enforced in one place rather than trusted across a codebase. Narrowing the scope of what you hold helps too, particularly by never calling out to listeners, callbacks or external systems while a monitor is held.
Where ordering is hard to guarantee, ReentrantLock.tryLock with a timeout turns a permanent hang into a recoverable failure you can log and retry. Beyond the immediate fix, take dumps on a schedule when the app is healthy so you know what normal looks like, and alert on BLOCKED thread counts creeping up. Then reproduce the bug in a test with two threads and a latch — a deadlock you cannot trigger on demand will come back.
Photo: Jakub Zerdzicki / Pexels
Designing Backpressure into Event-Driven Systems
Queues, rate limits and load shedding are the three levers that stop a busy producer overwhelming a slower...
Debugging Race Conditions with ThreadSanitizer: A Practical Walkthrough
ThreadSanitizer catches data races that only appear under load. Here is how to build with it, read its...




