Lock contention in Java: finding the monitor every thread is waiting for

The service is slow, but the machine is not busy. CPU sits at 30 percent, the heap is healthy, the GC log is quiet, and adding instances helps less than it should. A common explanation is that the threads are queuing for the same lock. Lock contention does not show up on the usual dashboards, because a blocked thread consumes nothing. It has to be looked for, and the JDK ships everything needed to find it.
What contention looks like from the outside
A lock serialises the code it protects. As long as threads rarely meet inside it, the cost is negligible. When the arrival rate approaches what the critical section can serve, waiting time grows faster than the load, exactly like a single checkout lane on a Saturday. The usual symptoms:
- throughput that stops increasing when you add threads or traffic, while CPU stays well below saturation;
- latency percentiles that degrade sharply under load, with the median almost unchanged at low traffic;
- more instances helping almost linearly (each has its own lock) while bigger instances barely help;
- a high rate of voluntary context switches for the process, since every thread that blocks gives up its CPU.
None of these proves contention on its own. Slow I/O behind a connection pool produces similar curves. The next steps are about evidence.
First evidence: a series of thread dumps
Take three to five dumps a few seconds apart with jcmd <pid> Thread.print -l. The -l option adds the locked ownable synchronizers section, which is where java.util.concurrent locks appear. Our guide to reading thread dumps explains each line; for contention, two patterns matter.
For synchronized code, blocked threads are in state BLOCKED (on object monitor) with a line such as:
"http-nio-8080-exec-37" #91 ... waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.pricing.RateCache.get(RateCache.java:58)
- waiting to lock <0x00000006c1a2b3c8> (a java.util.HashMap)
Search for the address, here 0x00000006c1a2b3c8, and you find one thread whose stack contains - locked <0x00000006c1a2b3c8>. That thread is inside the critical section; its stack shows what the lock holder spends its time doing, which is usually more interesting than the lock itself.
For ReentrantLock and other AbstractQueuedSynchronizer-based locks, waiting threads are WAITING (parking) with - parking to wait for <0x...> (a java.util.concurrent.locks.ReentrantLock$NonfairSync), and the owner lists the same address under Locked ownable synchronizers.
Counting gives a quick measure. This line groups blocked threads by the address they wait for:
jcmd <pid> Thread.print -l | grep -E 'waiting to lock|parking to wait for' \
| sed -E 's/.*<(0x[0-9a-f]+)> \((.*)\)/\1 \2/' | sort | uniq -c | sort -rn | head
Forty threads on one address in every dump of the series is contention. Forty threads on forty different addresses is something else, often an executor queue or a pool.
A thread in WAITING (on object monitor) inside Object.wait() released the monitor on purpose and is waiting for a notification. Idle pool workers parked in LinkedBlockingQueue.take() are waiting for work. Neither is contention.
Measuring blocked time with ThreadMXBean
Dumps are snapshots. To know how much time threads lose, the platform MBean java.lang.management.ThreadMXBean keeps per-thread counters. According to its API documentation, the number of times a thread blocked to enter or re-enter a monitor is always available through ThreadInfo.getBlockedCount(), while the accumulated time, getBlockedTime(), is only measured once thread contention monitoring is enabled, and returns -1 otherwise:
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
if (mx.isThreadContentionMonitoringSupported()) {
mx.setThreadContentionMonitoringEnabled(true);
}
for (ThreadInfo ti : mx.dumpAllThreads(true, true)) {
System.out.printf("%s blocked=%d (%d ms) waited=%d (%d ms)%n",
ti.getThreadName(), ti.getBlockedCount(), ti.getBlockedTime(),
ti.getWaitedCount(), ti.getWaitedTime());
}
Contention monitoring is off by default because it adds work to every contended acquisition. Turning it on for a few minutes on one instance, and comparing the counters between two samples, gives a rate of blocked milliseconds per second per thread. Note that getBlockedTime() only covers monitors; time parked on a ReentrantLock is counted as waiting, in getWaitedTime(), together with every other kind of wait.
The best tool: Flight Recorder events
Flight Recorder records contention with stack traces at a cost low enough for production. Three events matter, all covered in the JFR guide:
| Event | Records | Useful fields |
|---|---|---|
jdk.JavaMonitorEnter | a thread that had to wait to enter a synchronized block or method | monitor class, previous owner, duration, stack |
jdk.ThreadPark | a thread parked by LockSupport.park, which is how ReentrantLock, semaphores and most of java.util.concurrent wait | parked class, timeout, duration, stack |
jdk.JavaMonitorWait | calls to Object.wait() | monitor class, notifier, timed out or not |
These events have a duration threshold: only waits longer than the threshold are written, which is what keeps the overhead small. The profile settings file uses a lower threshold than default; check the values in $JAVA_HOME/lib/jfr/ for your JDK release rather than relying on memory. A short targeted recording is enough:
jcmd <pid> JFR.start name=locks settings=profile duration=2m filename=/var/tmp/locks.jfr
jfr print --events jdk.JavaMonitorEnter /var/tmp/locks.jfr | less
jfr summary /var/tmp/locks.jfr
From JDK 21, jfr view aggregates a recording into predefined tables, and jfr help view lists the contention views available in your release. In JDK Mission Control, the Lock Instances page groups monitor waits by object and class, and the stack trace panel shows the calling code. Sort by total duration, not by count: a lock that is taken a million times without waiting is harmless, one that makes a hundred requests wait 200 ms each is not.
Take jdk.ThreadPark results with care. Parking is also how executors wait for tasks and how CompletableFuture.get waits for results, so filter on the parked class and look at the stacks before concluding.
Usual culprits
- A synchronized cache or registry: a
HashMapwrapped insynchronizedorCollections.synchronizedMap, read on every request. - Work done while holding the lock: a remote call, a file write or a heavy computation inside the critical section, so that the lock is held for milliseconds instead of microseconds. The lock holder's stack in the dump shows it directly.
- Logging: synchronous appenders that serialise every log line through one lock, especially when the destination is slow.
- Shared counters and statistics updated by every thread, and legacy synchronized classes such as
Hashtable,StringBufferorVectorshared between threads. - Class initialisation and lazy singletons with a synchronized getter on the hot path.
- Pools: a connection or object pool too small for the traffic, where threads park waiting for a free entry. The fix there is sizing or a faster dependency, not a different lock.
Reducing contention, in order of preference
- Hold the lock for less time. Prepare data before entering, do I/O and callbacks after leaving. This one change often removes the problem entirely.
- Use a structure built for concurrency.
ConcurrentHashMapwithcomputeIfAbsentfor caches,LongAdderinstead of a synchronized or heavily updatedAtomicLongcounter, copy-on-write collections for data read constantly and changed rarely. - Split the lock. Separate locks for independent state, or striping by key, so that unrelated requests stop meeting.
- Separate readers from writers with
ReentrantReadWriteLockorStampedLock, but only after measuring: they cost more per acquisition and help only when reads dominate and the critical section is long enough. - Remove sharing. Per-thread or per-request state, immutable snapshots replaced atomically, asynchronous appenders for logging.
Switching from synchronized to ReentrantLock does not reduce contention by itself; it adds features such as tryLock with a timeout and fairness options. Two version notes are worth knowing. Biased locking, an old optimisation for uncontended monitors, was disabled by default in JDK 15 under JEP 374, so measurements taken on JDK 8 or 11 do not always transfer to recent releases. And with virtual threads, a contended lock limits throughput just as much: millions of virtual threads queuing for one monitor are still a single queue, as explained in virtual threads in production.
Checking the fix
Repeat the same measurement under the same load: the count of blocked threads per address in the dumps, the total jdk.JavaMonitorEnter duration per minute in a two-minute recording, and the latency percentiles. Contention removed in one place often reveals the next lock, or simply turns into CPU work, which is good news: CPU-bound code is the kind of problem the high-CPU method knows how to handle.
Sources
- Java SE 21 API, java.lang.management.ThreadMXBean and ThreadInfo (blocked and waited counts and times, contention monitoring)
- JEP 374: Deprecate and Disable Biased Locking, OpenJDK
- JDK tool references for
jcmd(Thread.print, JFR.start) andjfr, and the event settings files shipped inlib/jfr/(default.jfc and profile.jfc)
Threads & CPUReading thread dumps
Take a series, read states and lock lines, and let the JVM point out deadlocks for you.
ProductionFlight Recorder and Mission Control
Continuous recording, on-demand dumps with jcmd, the events that matter, and reading .jfr files.
Threads & CPUHigh CPU: find the thread
From top -H to the nid in a thread dump: a repeatable way to name the code that is burning CPU.