Threads & CPU

Reading Java thread dumps: jstack, jcmd Thread.print and what each line means

Threads & CPU · Published 2026-10-21

A tangle of grey network cables hanging in front of patch panels and switches in a rack
A thread dump untangles who is waiting for whom. Photo: Moses Cursor Ssebunya, WordPress Photo Directory, CC0.

When a Java service hangs, slows down or stops answering health checks, the first thing to capture is a thread dump: a snapshot of every thread, its state and its stack. It is cheap, it does not need any agent, and read correctly it often names the culprit outright.

Taking a dump

The JDK offers three equivalent routes:

# recommended: jcmd, with java.util.concurrent lock details
$ jcmd <pid> Thread.print -l > dump-1.txt

# jstack, same content
$ jstack -l <pid> > dump-1.txt

# SIGQUIT: the JVM prints the dump on its own standard output
$ kill -3 <pid>

The JDK troubleshooting guide suggests jcmd rather than jstack for enhanced diagnostics and reduced overhead. The -l option adds the ownable synchronizers (ReentrantLock and friends) held by each thread; without it, only monitors (synchronized) are shown. The jcmd reference also lists -e for extended thread information.

kill -3 does not stop the process. It is useful when the attach mechanism fails, for example when the tool runs as a different user than the JVM. The output goes wherever the JVM's stdout goes, which in a container is the container log.

Always take a series

One dump is a photograph; a problem is a film. Take three to five dumps a few seconds apart. A thread that shows the same stack in every dump is stuck or doing a long operation; a thread that changes is making progress.

$ for i in 1 2 3 4 5; do jcmd <pid> Thread.print -l > dump-$i.txt; sleep 5; done

Anatomy of a thread entry

"http-worker-17" #84 daemon prio=5 os_prio=0 cpu=…ms elapsed=…s tid=0x00007f... nid=0x1f2b waiting for monitor entry  [0x00007f...]
   java.lang.Thread.State: BLOCKED (on object monitor)
        at com.example.cache.Registry.lookup(Registry.java:88)
        - waiting to lock <0x00000006c1a3e2f8> (a java.util.HashMap)
        at com.example.api.OrderHandler.handle(OrderHandler.java:41)
        ...

Reading left to right on the first line:

Thread states

The states are those of Thread.State:

StateMeaningWhat to look for
RUNNABLEExecuting in the JVM, or in native code such as a socket readA RUNNABLE thread in socketRead is waiting for the network, not using CPU
BLOCKEDWaiting to enter a synchronized blockFind the thread that holds the monitor
WAITINGWaiting indefinitely: Object.wait(), LockSupport.park(), Thread.join()Idle pool threads look like this; that is normal
TIMED_WAITINGThe same with a timeout, or Thread.sleepRetry loops and polling
NEW, TERMINATEDNot started, finishedRarely appear in dumps

Lock lines

The lines starting with a dash connect threads to each other:

To find a bottleneck, search the dump for the address in a waiting to lock line and find the single thread whose stack shows locked with the same address. That thread's stack is the code holding everyone else up.

Deadlocks

HotSpot detects Java-level deadlocks itself. When one exists, the end of the dump contains a section starting with Found one Java-level deadlock:, listing the threads involved, the lock each one waits for and which thread owns it, followed by their stacks. Detection covers monitors and, with -l, ownable synchronizers. The same check is available programmatically through ThreadMXBean.findDeadlockedThreads(), which a health check can call.

Classic causes and fixes:

Thread pools that starve themselves

Not every hang is a lock cycle. A task running in a bounded pool that submits a sub-task to the same pool and waits for its result can block forever once all workers are doing the same: every worker waits for a sub-task that has no free worker to run it. The dump shows every pool thread in WAITING inside FutureTask.get and nothing deadlocked in the JVM's sense. Use separate pools for dependent stages, or a non-blocking composition such as CompletableFuture.

Also recall how ThreadPoolExecutor grows: below the core size it creates threads; above it, it queues; only when the queue is full does it create threads up to the maximum; beyond that it rejects. With an unbounded queue the maximum size is never reached, and work piles up in the queue rather than in the dump.

Virtual threads

Thread.print shows platform threads. For applications using virtual threads, JEP 444 introduced a dump format that includes them, written to a file, optionally as JSON:

$ jcmd <pid> Thread.dump_to_file -format=json /var/tmp/threads.json

See virtual threads in production.

Sources