A deadlock is one of those bugs that only shows up in production. Two pieces of code, each working perfectly in unit tests, hit a circular impasse when they run at the same time: thread A holds lock 1 and wants lock 2; thread B holds lock 2 and wants lock 1. Neither lets go, neither makes progress, and the rest of the system slowly degrades until someone restarts it. That is how it went in the classic bank incident in 2012, in the payment gateway that ate a Friday night in 2019, and probably how it went the last time you watched an endpoint stop responding for no apparent reason.
The good news: the JVM already knows how to identify a deadlock on its own. The even better news: you can detect it in automated tests before the code ships. This article shows you how.
What a deadlock is
Formally, a deadlock is a set of processes where each one waits for a resource held by another process in the same set. In Java, the "resources" are typically object monitors (synchronized) or ReentrantLock instances, but they can also be semaphores, pool connections, or even idle threads of an ExecutorService.
Unlike a slow thread, in a deadlock none of the threads involved makes progress. The only intervention possible without restarting the JVM is to kill one of the threads — which usually means corrupting state and grabbing a quick dump before something worse happens.
The four Coffman conditions
Edward Coffman proved in 1971 that a deadlock requires four conditions simultaneously. Breaking any one of them eliminates the possibility of the impasse.
- Mutual exclusion. The resource can be held by only one thread at a time. (Almost every lock in Java has this.)
- Hold and wait. A thread holds one resource and waits for another. (This happens when you open a lock inside another lock.)
- No preemption. The system does not forcibly take the resource away from the thread. (The JVM does not preempt monitors.)
- Circular wait. There is a cycle of threads where each one waits for the next one's resource. (This is the easiest condition to break.)
In Java code, the standard strategy is to attack the fourth one: global lock ordering (whenever you need more than one lock, acquire them in a fixed order). We will get to that further on.
How to detect it (code and dump)
From the API: ThreadMXBean
The JVM exposes a runtime detector. In integration tests, you can check for a deadlock automatically:
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
public class DeadlockDetector {
public static long[] detectar() {
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] ids = bean.findDeadlockedThreads();
return ids == null ? new long[0] : ids;
}
}In a serious product, it is worth running this every 60 seconds as a guardrail and exporting a Micrometer metric; if the array is ever non-empty, fire a P1 alert immediately.
From the dump
The HotSpot JVM writes the Found one Java-level deadlock section at the end of the thread dump when it detects the impasse — you do not need to reconstruct anything, the JVM itself has already done the topological analysis of the locks.
Example: a classic two-thread deadlock
The most instructive example — and still the one that causes the most real incidents — is two threads acquiring two locks in opposite orders:
public class Transferencia {
private final Object contaA = new Object();
private final Object contaB = new Object();
public void aParaB() {
synchronized (contaA) {
// ... trabalho com A
synchronized (contaB) {
// ... atualiza saldo
}
}
}
public void bParaA() {
synchronized (contaB) {
// ... trabalho com B
synchronized (contaA) { // <-- ordem invertida
// ... atualiza saldo
}
}
}
}When aParaB and bParaA run concurrently, most of the time one finishes before the other starts and nothing happens. Under load, eventually both will run simultaneously: the first grabs contaA, the second grabs contaB, and both sit there waiting for the lock the other holds. The unit tests stay green. The bug only shows up in production, under load, intermittently — the worst kind.
Reading the Found one Java-level deadlock section
In a dump taken in that scenario, you find something like this at the end:
Found one Java-level deadlock: ============================= "Thread-1": waiting to lock monitor 0x00007fb44c004ce8 (object 0x00000007a3c41e80, a java.lang.Object), which is held by "Thread-0" "Thread-0": waiting to lock monitor 0x00007fb44c004d88 (object 0x00000007a3c41e90, a java.lang.Object), which is held by "Thread-1" Java stack information for the threads listed above: =================================================== "Thread-1": at com.exemplo.Transferencia.bParaA(Transferencia.java:17) - waiting to lock <0x00000007a3c41e80> (a java.lang.Object) - locked <0x00000007a3c41e90> (a java.lang.Object) "Thread-0": at com.exemplo.Transferencia.aParaB(Transferencia.java:8) - waiting to lock <0x00000007a3c41e90> (a java.lang.Object) - locked <0x00000007a3c41e80> (a java.lang.Object) Found 1 deadlock.
The read is linear. The "Found one Java-level deadlock" block is confirmation that the JVM, not you, identified the problem — so there is no false positive here. The hexadecimal addresses are unique identifiers for the objects: 0x...e80 and 0x...e90 are the two instances held in swapped order. The stack shows exactly which line each thread hit. In 30 seconds you have the complete diagnosis.
How to avoid deadlocks in practice
- Global lock ordering. Establish a total order among locks (e.g., by the object's identifier). Every thread that needs two locks acquires them in the same order. Breaks the circular-wait condition.
- tryLock with a timeout. Instead of nested
synchronized, useReentrantLock.tryLock(timeout, unit)and back off if you cannot acquire it within N seconds. Breaks the "no preemption" condition. - Avoid nested synchronization. If you are holding a lock inside another lock, stop and look for a way to refactor. Moving work out of the inner block is the simplest change and the one that eliminates the most deadlocks.
- Prefer concurrent structures.
ConcurrentHashMap,CopyOnWriteArrayList, andAtomicReferenceremove the need forsynchronizedin many cases. - Keep synchronization to the bare minimum. Work inside a synchronized block should be almost atomic — read/update a field, not call IO or an external service.
- Test pessimistically. In integration tests, run
ThreadMXBean.findDeadlockedThreads()after each concurrent scenario.
False deadlocks and livelocks
Not every freeze is a deadlock. Three close relatives to tell apart:
- Livelock — threads keep running, but in circles, with no useful progress (e.g., two retry algorithms that always collide).
- Starvation — one thread never gets the lock because others always grab it first (a fairness problem).
- Stuck pool exhaustion — every thread in the pool is busy waiting for an external response. If you restart the external service, it frees up; so it is not a real deadlock.
The practical difference: if findDeadlockedThreads() returns empty but the system is stopped, you have one of the three situations above — not a classic deadlock.
How ThreadMine detects them automatically
When it receives a dump, ThreadMine runs two orthogonal checks. First it reads the Found one Java-level deadlock section emitted by the JVM itself — if it exists, it shows the cycle, the threads involved, and the monitor addresses directly, with a CRITICAL classification. Second, it reconstructs the lock dependency graph from the stacks (who holds X and who waits for X) and looks for cycles on its own — useful for dumps from older JVM versions or from implementations that do not write that section automatically.
Each thread involved in the cycle becomes a card with a severity and the relevant stack, and the lock graph appears in the dependency visualization (with cycle detection). Bring a dump and ThreadMine tells you in seconds whether what you are looking at is a deadlock or something else that looks like one.
Conclusion
Deadlocks are preventable with a few rules: never hold two locks in the same routine without a global order, prefer tryLock with a timeout, and keep the synchronized block as small as possible. When one does show up, the JVM handles the tedious part of the analysis — you just need to know how to read it. And when the incident queue grows, automate the reading.
Want to see all this applied to your own dump?
Upload a dump and get a health score, detected problems and fixes in seconds. No signup for the first dump.