Debugging Concurrency — Reading What the JVM Is Actually Doing¶
Writing concurrent code is hard. Debugging it is harder because most tools designed for single-threaded reasoning stop working: a debugger’s breakpoint changes the timing, println reorders itself, and “it worked when I stepped through it” is a warning sign, not a diagnosis.
This file is about the tools and habits that actually work — thread dumps, deadlock reports, race-detection frameworks, and controlled chaos. When you finish it, you should be able to look at a 2000-line jstack output at 3 AM and pinpoint what’s wrong within a few minutes.
1. Thread Dumps — Your First Weapon¶
A thread dump is a snapshot of every live thread’s stack. It tells you what each thread is doing right now.
Getting a dump¶
Command |
Use case |
|---|---|
|
Simple, prints to stdout. Available in JDK. |
|
Same info, slightly richer. Preferred modern way. |
|
JSON format, machine-readable, includes virtual thread hierarchy. Java 21+. |
|
Sends |
Grab one every 5 seconds for 30 seconds |
Compare across dumps to catch what’s actually stuck vs momentarily busy. |
Reading a dump¶
Example entry:
"http-nio-8080-exec-42" #94 daemon prio=5 os_prio=0 cpu=1234.56ms
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.Service.transfer(Service.java:47)
- waiting to lock <0x00000007a0e12340> (a com.example.Account)
- locked <0x00000007a0e12350> (a com.example.Account)
at com.example.Handler.handle(Handler.java:22)
Key signals:
BLOCKED (on object monitor)— waiting to acquire asynchronizedlock. If many threads BLOCKED on the same monitor address, that’s contention.WAITING (parking)on aLockSupport— waiting insideReentrantLock, a queue, or aCompletableFuture. The next frames tell you which.TIMED_WAITING (sleeping)—Thread.sleep,wait(timeout). Usually benign unless it’s a hot path.RUNNABLE— executing Java code (or blocked in an OS call the JVM can’t see, e.g., some native I/O).
Look at the lock addresses. <0x00000007a0e12340> is a specific monitor. If thread T1 is waiting for that address and thread T2 is locked on it, T2 is blocking T1.
The deadlock section¶
At the bottom of a jstack dump, if there’s a cycle, the JVM prints an explicit deadlock report:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f8a... (object 0x00000007a0e12340, a Account),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00007f8a... (object 0x00000007a0e12350, a Account),
which is held by "Thread-1"
This is the fastest debugging experience in Java. If you see it, you have the exact fix path: figure out why the two threads acquire in different orders, impose an ordering, done.
2. jcmd — The Diagnostic Swiss Army Knife¶
jcmd has replaced most legacy tools. Learn these commands.
jcmd <pid> help # list all commands
jcmd <pid> Thread.print # thread dump
jcmd <pid> GC.heap_info # heap layout
jcmd <pid> GC.heap_dump /tmp/heap.hprof # heap dump
jcmd <pid> VM.system_properties # -D props
jcmd <pid> VM.flags # all JVM flags in effect
jcmd <pid> VM.native_memory summary # NMT if -XX:NativeMemoryTracking=summary
jcmd <pid> JFR.start duration=60s filename=/tmp/rec.jfr # start a Flight Recording
jcmd is delivered with every JDK. Nothing to install. Runs in the target JVM’s process context via the Attach API. Master this before anything else.
3. Deadlock Prevention Techniques (Runtime)¶
Re-cap from file 02, now with runtime detection:
Impose a global lock order — acquire by increasing ID or
System.identityHashCode.Prefer
tryLockwith timeout — replace deadlock (unrecoverable) with livelock (recoverable) or a fail-fast error.Watchdog: in production, sample thread dumps periodically and alert if any pair of threads has been BLOCKED > threshold. Both Datadog and New Relic have this out of the box.
For debugging a suspected deadlock right now: run the process, wait for the hang, take a jstack, read the deadlock section. Done.
4. Race Conditions — The Hard Class¶
A deadlock stops the program. A race condition silently produces wrong answers, sometimes. Debugging races is harder because they’re non-reproducible on demand.
4.1 jcstress — The Only Correct Tool¶
Java Concurrency Stress Tests is the framework that Aleksey Shipilëv (OpenJDK performance lead) built to test the JMM itself. It runs your code in tight loops on many threads for millions of iterations and enumerates the actual observed outcomes, so you can catch memory-model violations that your ad-hoc tests will never trigger.
@JCStressTest
@Outcome(id = "1, 1", expect = ACCEPTABLE, desc = "Both threads saw the update.")
@Outcome(id = "0, 0", expect = ACCEPTABLE_INTERESTING, desc = "Reordering visible.")
@State
public class RaceTest {
int x, y;
@Actor public void thread1(II_Result r) { x = 1; r.r1 = y; }
@Actor public void thread2(II_Result r) { y = 1; r.r2 = x; }
}
Run it, and jcstress will tell you exactly which outcomes your code produces — including the interesting ones that only happen once in ten million. This is how the JDK itself validates concurrent code. If you’re writing a lock-free algorithm, you write a jcstress test alongside it.
4.2 Chaos with Thread.yield()¶
A cheaper first-pass technique for tests: sprinkle Thread.yield() at suspected interleaving points. It hints to the scheduler that another thread may run. Not a proof of correctness, but it dramatically increases the odds of hitting a race in a unit test.
public void increment() {
int c = counter;
Thread.yield(); // encourage interleaving in tests
counter = c + 1;
}
Remove before shipping. This is for CI stress runs, not production.
4.3 TSAN — Concepts, Not the Tool¶
Go and Rust have thread sanitizers (TSAN) that instrument every load/store and detect races at runtime with vector clocks. Java doesn’t ship one. The philosophical replacement is jcstress plus the JMM: prove correctness by argument, verify by stress test.
5. Reproducing a Race — The Workflow¶
You have a report: “user occasionally sees stale data”. Nothing reproduces. Here’s the drill:
Read the code. Identify all shared mutable state. If you cannot list every field that two threads touch, stop and re-read.
List the happens-before edges. For each pair of read/write, ask: is there a lock, a
volatile, a queue handoff between them? If not, mark it suspect.Write a jcstress test targeting that pair. Run for a few minutes with many actors. Watch for
ACCEPTABLE_INTERESTINGoutcomes.Fix by adding an edge: a lock,
volatile, immutable snapshot, or replacement with a concurrent collection.Rerun jcstress. The interesting outcome should be gone.
Regression-test with a chaos test (many threads,
Thread.yieldsprinkled) as CI protection.
Do not try to reproduce a race with a main method that loops 1000 times. That’s how one-in-a-million bugs stay one-in-a-million.
6. Beyond Java-Level: When the JVM Is the Problem¶
Some symptoms look like concurrency bugs but are actually GC, JIT, or kernel issues:
Sudden 200 ms hiccups: possibly a GC pause. Check
-Xlog:gc*output. Covered in Phase 06.Thread stuck in
runnablebut no CPU used: possibly a native syscall the JVM can’t introspect. Useperf,strace, or Async Profiler (Phase 06).All threads suddenly go
RUNNABLEwith weird stacks: possibly a JIT deoptimization storm. Add-XX:+PrintCompilation.
When your Java-level debugging bottoms out, the JVM-level tools in Phase 06 are the next layer.
7. Cheat Sheet¶
Symptom |
First thing to do |
|---|---|
Process hangs |
3 × |
High CPU, no progress |
|
Occasional wrong result |
jcstress test on the suspect pair |
p99 latency spikes |
JFR recording; check |
“Works locally, fails in prod” |
Different scheduler + different load. Trust jcstress over anecdote. |
⚠️ What Most People Get Wrong¶
They “debug” concurrency by adding println and staring at the log. Print statements are internally synchronized, so they linearize execution and hide the very race you’re trying to catch. If you must instrument, use a lock-free ring buffer or JFR events — not System.out.
They also treat a passing test suite as proof. It is not proof. A single passing run of a race-condition test proves that on that machine, that JIT compile, that scheduler decision, the code didn’t fail. Only jcstress produces evidence approaching proof. Everything else is anecdote.
And the study classic: they cannot read a jstack dump. Practice on real dumps. Print one from your own project, right now, and try to name what each thread is doing. If you can’t, that’s your first hour of homework for this file.
Return to README.md · Next: projects.md