A thread dump (jcmd <pid> Thread.print or jstack) shows each thread’s state, stack, and the locks it holds or waits for. Take three dumps a few seconds apart, because threads stuck on the same frame across all three are your problem. Java-level deadlocks are reported automatically at the bottom of the dump, while livelock and starvation only show up by comparing dumps or watching metrics. JFR and async-profiler measure lock contention directly. Most concurrency performance problems come down to holding a hot lock too long or with too coarse a scope. Amdahl’s law sets the ceiling: if 5% of the work is serial, no number of cores will make it more than 20× faster.
Part of the Java Concurrency Roadmap. Builds on Concurrency problems.
Thread dumps#
jcmd <pid> Thread.print # platform threads, with lock info
jcmd <pid> Thread.dump_to_file -format=json dump.json # Java 21+, includes virtual threads
kill -3 <pid> # dump goes to the app's stdout
In Spring Boot, /actuator/threaddump returns the same data. How to read a dump:
- locked <0x...>means this thread holds that monitor.- waiting to lock <0x...>means it’sBLOCKEDon a monitor. Search for the same address to find the owner.- parking to wait for <0x...> (a ...ReentrantLock$NonfairSync)means it’s waiting on ajava.util.concurrentlock.RUNNABLEinsidesocketRead0or a JDBC driver frame means it’s waiting on I/O, not using CPU (Fundamentals).RUNNABLEon the same line of your own code in every dump, while the process uses 100% of a core, means a spin loop that never exits. The hungVolatileExamplefrom Synchronization basics looks like this:
Line 21 isjava.lang.Thread.State: RUNNABLE at VolatileExample.lambda$withoutVolatileVar$1(VolatileExample.java:21)while(!nonVolatileFlag) {}. When a busy-wait never ends, check first whether the flag it waits on is missingvolatile.
Deadlock detection#
jstack and jcmd end the dump with Found one Java-level deadlock: and the full cycle. This covers both monitors and java.util.concurrent locks. To check from code, for example in a health check:
long[] ids = ManagementFactory.getThreadMXBean().findDeadlockedThreads(); // null if none
There is no automatic detector for livelock, starvation, or a thread pool waiting on its own tasks. Look for the same frames across repeated dumps, a queue depth that only grows, or active threads stuck at the maximum.
Profiling tools#
- JFR (Java Flight Recorder): start it with
jcmd <pid> JFR.start duration=60s filename=rec.jfr. Its events includejdk.JavaMonitorEnter(time spent blocked),jdk.ThreadPark, andjdk.VirtualThreadPinned. Open the recording in JDK Mission Control. The overhead is low enough to run in production. - async-profiler:
-e lockprofiles lock contention, and-e wallshows where threads spend time, waiting included. It produces flame graphs. - VisualVM gives a quick live view of threads.
- JMH for microbenchmarks. Hand-written timing loops get skewed by JIT warm-up and dead-code elimination.
Contention and lock granularity#
If a profile shows threads queueing on one lock, try these in order:
- Shorten the hold. Move I/O, logging, and allocation out of the critical section.
- Split the lock. Use separate locks for independent state, like
LinkedBlockingQueue’s separate put and take locks. - Stripe it. Use N locks chosen by
hash(key) % N, the way pre-Java 8ConcurrentHashMapused segments. - Stop sharing. Confine data to one thread, use a
LongAdderfor counters, or swap in immutable snapshots through anAtomicReference.
False sharing is contention without any lock. Two hot fields written by different threads sit on the same 64-byte cache line, so the line bounces between cores. Padding fixes it, as does @jdk.internal.vm.annotation.Contended with -XX:-RestrictContended. LongAdder pads its cells for this reason.
Amdahl’s law#
speedup(N) = 1 / ((1 - p) + p / N), where p is the fraction of the work that can run in parallel and N is the number of cores.
Parallel share p | 4 cores | 16 cores | ∞ cores |
|---|---|---|---|
| 50% | 1.6× | 1.9× | 2× |
| 95% | 3.5× | 9.1× | 20× |
| 99% | 3.9× | 13.9× | 100× |
Amdahl's law
1 / ((1 - p) + p / n)1 / (1 - p)Code that runs under a contended lock counts as serial. Shrinking critical sections raises p, which usually helps more than adding cores.
