Taking one
The problem. A service stops answering but uses no CPU. Nothing is in the logs. A thread dump - a snapshot of what every thread in the JVM is doing right now - is how you see where it is stuck.
What you need to know already: threads (20.1), kill -3 / SIGQUIT (3.6), jcmd PID Thread.print (20.3), grep and sort | uniq -c (7.1, 7.4).
A stack trace is the list of method calls a thread is inside, innermost first (a method = a function belonging to a class). A lock (or monitor) is a guard only one thread can hold at a time; others that want it wait.
sudo -u appuser jcmd PID Thread.print > dump1.txt preferred
sudo -u appuser jcmd PID Thread.print -l > dump1.txt with java.util.concurrent locks
sudo -u appuser jstack -l PID > dump1.txt the older tool, same content, no "PID:" header
sudo kill -3 PID to the JVM's stdout (the journal)
curl -H 'Accept: text/plain' localhost:8080/actuator/threaddump over HTTP (next chapter)
Taking a dump pauses the JVM at a safepoint for a few milliseconds. Safe in production.
The header
1210:
2026-09-23 10:15:08
Full thread dump OpenJDK 64-Bit Server VM (21.0.8+9-Ubuntu-0ubuntu1~26.04 mixed mode, sharing):
Threads class SMR info:
_java_thread_list=0x0000ffff5c002e10, length=46, elements={
0x0000ffff943d7320, 0x0000ffff94043210, 0x0000ffff943f6af0, 0x0000ffff94023930,
...
}
length=46 is the number of Java threads. The SMR block is internal bookkeeping - skip it.
One thread
"http-nio-8080-exec-7" #52 [1297] daemon prio=5 os_prio=0 cpu=95.10ms elapsed=3590.11s tid=0x0000ffff9410e970 nid=1297 runnable [0x0000ffff7b4b8000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.Net.poll([email protected]/Native Method)
at sun.nio.ch.NioSocketImpl.park([email protected]/NioSocketImpl.java:191)
at sun.nio.ch.NioSocketImpl.implRead([email protected]/NioSocketImpl.java:309)
...
at org.springframework.web.client.RestTemplate.postForObject(RestTemplate.java:519)
at lab.orders.payments.PaymentsClient.authorize(PaymentsClient.java:48)
at lab.orders.checkout.CheckoutService.checkout(CheckoutService.java:71)
...
at java.lang.Thread.run([email protected]/Thread.java:1583)
Locked ownable synchronizers:
- <0x00000000f1a2b3c8> (a org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker)
Field by field:
"http-nio-8080-exec-7"- the name. Names are the most useful thing in a dump:http-nio-8080-exec-*are Tomcat request threads for port 8080,HikariPool-1:housekeeperis the DB pool's timer,pool-N-thread-Mis an anonymousExecutors.newFixedThreadPool- someone forgot to name it.#52- Java thread id.[1297]andnid=1297- the OS thread id, the same numbertop -Handps -Lshow. (Older JDKs printed nid in hex.)daemon- does not keep the JVM alive.prioJava priority,os_priothe OS one.cpu=95.10ms- CPU this thread has consumed since it started. Compare across three dumps: a stuck thread's cpu does not move.elapsed=3590.11s- time since the thread started.runnable/waiting on condition/waiting for monitor entry/in Object.wait()- what the thread was doing, at the OS level.java.lang.Thread.State- the Java state.- The stack, most recent frame first. Read from the top to see what it is doing right now, from the bottom to see how it got there.
Locked ownable synchronizers- only with-l:java.util.concurrentlocks held by this thread.
The four states that matter
RUNNABLE executing, or blocked in native code - including a socket read
BLOCKED (on object monitor) waiting to enter a synchronized block another thread holds
WAITING (parking) LockSupport.park with no timeout: a pool, a queue, a future.get()
WAITING (on object monitor) Object.wait() with no timeout
TIMED_WAITING (parking) park with a timeout: pool borrow with timeout, poll(timeout)
TIMED_WAITING (sleeping) Thread.sleep()
The trap in the list: a thread waiting for bytes on a socket is RUNNABLE. From the JVM's point of view it is inside a native call (Net.poll, socketRead0 on older JDKs) and could return any moment. So "all threads RUNNABLE" does not mean "all threads busy on CPU": check the top frames. The cpu= field across two dumps settles it.
RUNNABLE + top frame Net.poll / socketRead / EPoll.wait waiting on the network
RUNNABLE + top frame in your code, cpu= growing actually computing
The lock lines
- parking to wait for <0x00000000e94b9b80> (a java.util.concurrent.SynchronousQueue$Transferer)
- waiting to lock <0x00000000f5a1b2c8> (a lab.payments.settlement.Ledger)
- locked <0x00000000f5a1c4e0> (a lab.payments.settlement.AccountBook)
- waiting on <0x...> (a java.lang.Object)
The hex number is the object's identity. Same address in many threads = they all wait for the same thing. Search the dump for who locked it.
Threads you can ignore
Every JVM has them, and they look scary if you do not know them:
"Reference Handler", "Finalizer", "Signal Dispatcher", "Service Thread",
"Monitor Deflation Thread", "C1/C2 CompilerThread0", "Common-Cleaner",
"Notification Thread", "Attach Listener" (appears once you attach!),
"DestroyJavaVM", and the non-Java ones at the end:
"VM Thread", "GC Thread#0", "G1 Main Marker", "G1 Conc#0", "G1 Refine#0", "VM Periodic Task Thread"
And the dump ends with JNI global refs: 24, weak refs: 0 - and, when there is one, the deadlock report.
Triage greps
grep -c 'java.lang.Thread.State' dump1.txt how many Java threads
grep 'java.lang.Thread.State' dump1.txt | sort | uniq -c | sort -rn
threads per state
grep -A1 'java.lang.Thread.State' dump1.txt | grep -v -- '--' | sort | uniq -c | sort -rn | head
state + top frame: the clusters
grep '^"' dump1.txt | sed -E 's/"([^"]*[^0-9-])[0-9-]*".*/\1/' | sort | uniq -c | sort -rn
threads per pool name
grep -B2 -A20 '"http-nio-8080-exec-7"' dump1.txt one thread in full
The next lesson turns those counts into diagnoses.