Threads & CPU

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

Threads & CPU · · Published 2026-11-12

A tangle of grey network cables hanging in front of patch panels and switches in a rack
Contention is a queue you cannot see from the CPU graph. Photo: Moses Cursor Ssebunya, WordPress Photo Directory, CC0.

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:

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.

Do not confuse

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:

EventRecordsUseful fields
jdk.JavaMonitorEntera thread that had to wait to enter a synchronized block or methodmonitor class, previous owner, duration, stack
jdk.ThreadParka thread parked by LockSupport.park, which is how ReentrantLock, semaphores and most of java.util.concurrent waitparked class, timeout, duration, stack
jdk.JavaMonitorWaitcalls 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

Reducing contention, in order of preference

  1. 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.
  2. Use a structure built for concurrency. ConcurrentHashMap with computeIfAbsent for caches, LongAdder instead of a synchronized or heavily updated AtomicLong counter, copy-on-write collections for data read constantly and changed rarely.
  3. Split the lock. Separate locks for independent state, or striping by key, so that unrelated requests stop meeting.
  4. Separate readers from writers with ReentrantReadWriteLock or StampedLock, but only after measuring: they cost more per acquisition and help only when reads dominate and the critical section is long enough.
  5. 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