
A thread dump is the least fashionable diagnostic tool available for a JVM and still one of the most decisive. It costs a safepoint, needs no agent, works on any JDK, and answers the question incidents actually turn on: what is every thread in this process waiting for right now?
The reason it has a reputation for being unhelpful is that it is usually read wrong.
Take it properly
Use jcmd. jstack still works but is the older interface, and jcmd runs the same operation through the standard diagnostic command channel:
for i in 1 2 3 4 5; do
jcmd $(pgrep -f my-app.jar) Thread.print > dump-$i.txt
sleep 5
done
Five dumps, five seconds apart. This matters more than anything else in this article. A single dump tells you where threads were at one instant, which is nearly useless for distinguishing a thread parked in its normal idle state from one that has been stuck for four minutes. Five dumps tell you which stacks did not move — and a stack that is byte-identical across thirty seconds is the finding.
If the process is unresponsive enough that jcmd cannot attach, kill -3 sends SIGQUIT and the JVM writes a dump to its standard output. That path costs nothing and requires no working attach mechanism.
What the states mean, precisely
Each thread’s header line carries a state, and two of the five are routinely misread.
RUNNABLE means the thread is not blocked on a Java-level monitor or parked. It does not mean the thread is consuming CPU. A thread blocked in a socket read sits in RUNNABLE for the entire duration, because from the JVM’s point of view it is executing a native method and the kernel wait is invisible. This single fact accounts for most “the application is CPU-bound” diagnoses that turn out to be network waits. If you need to know which threads are actually burning CPU, take top -H -p <pid>, convert the native thread ids to hexadecimal, and match them against the nid= field in the dump — on Linux, where that field is hexadecimal. The dump also carries a per-thread cpu= figure, which answers the same question without leaving the file.
BLOCKED means the thread is waiting to enter a synchronized block that another thread holds. This is the unambiguous one, and it comes with the evidence attached:
"http-nio-8080-exec-42" #187 [19003] daemon prio=5 os_prio=0 cpu=41.02ms elapsed=612.44s
tid=0x00007f8a08c21000 nid=0x4a3b waiting for monitor entry [0x00007f89f4bfe000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.Registry.lookup(Registry.java:88)
- waiting to lock <0x00000006c2a41f38> (a com.example.Registry)
at com.example.Handler.handle(Handler.java:41)
The address in waiting to lock is the key. Search the same dump for - locked <0x00000006c2a41f38> and you have the thread that is holding it, and its stack tells you what it is doing instead of releasing it.
WAITING and TIMED_WAITING mean the thread called Object.wait(), LockSupport.park(), Thread.sleep() or an equivalent. A thread pool worker with no work is WAITING on its queue, which is the healthy idle state and not a finding. The trap here is the opposite of the RUNNABLE trap: dozens of WAITING threads look alarming and usually mean the system is quiet.
The three shapes worth recognising
Deadlock. The JVM detects monitor cycles itself and appends a section titled Found one Java-level deadlock with the participating threads and the locks each holds and wants. If it is there, the diagnosis is complete; go read the two stacks. Note that this detector covers monitors and Lock implementations, but not a lock ordering problem expressed through semaphores or database row locks — those you will have to see yourself.
Pool exhaustion. Every thread in a pool sharing the same frame, several levels down, in BLOCKED or in a native call. The pool is not the problem; whatever they are all waiting on is. Count them: if http-nio-8080-exec-* appears two hundred times and the pool maximum is two hundred, requests are queuing outside the dump entirely.
Convoy behind one slow resource. Many threads RUNNABLE inside a driver’s socket read, all against the same endpoint. This looks like load and is actually one dependency responding slowly, with the thread pool acting as an accidental queue.
Naming is a decision you make in advance
The single highest-leverage change you can make to your future thread dumps is naming threads properly today. pool-3-thread-17 tells an incident responder nothing. payment-reconcile-17 tells them which subsystem is stuck before they read a single frame.
ThreadFactory factory = Thread.ofPlatform()
.name("payment-reconcile-", 0)
.daemon(true)
.factory();
If you use a framework’s executor abstraction, set the prefix there. It costs one line and it is the difference between reading a dump and decoding one.
Virtual threads change the format
A process running a million virtual threads cannot produce a useful plain-text dump, and Thread.print deliberately does not include them. JDK 21 added a format built for this:
jcmd <pid> Thread.dump_to_file -format=json threads.json
The JSON output groups virtual threads by the structured concurrency scope that created them, which turns “a million stacks” into a tree that mirrors the shape of the work. It also shows which carrier each mounted virtual thread is running on — useful when the question is whether carriers are saturated or whether something is pinned.
For the platform threads that still matter — carriers, the common pool, framework internals — Thread.print remains the right tool, and the reading habits above are unchanged.
The discipline
Take several dumps. Compare them before interpreting any one of them. Trust BLOCKED and its lock addresses. Distrust RUNNABLE until you have checked it against per-thread CPU. Look for what is identical across dumps, not for what looks alarming in one.
Done that way, a thread dump is not a wall of text. It is the runtime answering a direct question about every thread it has, and it answers faster than most of the tooling built to avoid reading it.
Frequently asked
- How many thread dumps should I take?
- Three to five, roughly five to ten seconds apart. One dump cannot distinguish a thread that is stuck from a thread that happened to be there; the comparison across dumps is what carries the information.
- Does taking a thread dump pause the application?
- Yes. It requires a safepoint, so every thread stops for the duration. For a normal heap and thread count this is milliseconds, which is cheap enough to do during an incident but not something to schedule every second.


