Definition
- synchronized contention – threads entering a contended
synchronizedblock are placed in BLOCKED state. jstack shows "BLOCKED on com.bank.Service@0x...". Easy to detect by counting BLOCKED threads. - ReentrantLock contention – threads that fail to acquire a ReentrantLock are parked by AQS using
LockSupport.park(), entering WAITING state (or TIMED_WAITING fortryLock(timeout)). jstack shows "WAITING (parking)" with a stack trace ending inUnsafe.park(). - Hidden contention – basic thread dump analysis that counts BLOCKED threads misses ReentrantLock contention entirely because the threads are WAITING, not BLOCKED.
- JFR –
jdk.ThreadParkevents record park duration, blocker class, and stack trace. Filter for duration > 1ms to find contention hotspots. - async-profiler –
-e lockmode profiles lock contention directly. The resulting flame graph shows method call stacks where width represents time waiting for locks, making hotspot methods visually obvious.
synchronized contention is like tellers standing outside a locked vault door, clearly visible as BLOCKED. ReentrantLock contention is like tellers sitting quietly in a waiting room with a buzzer (WAITING), looking like they are resting rather than blocked. To a casual observer (basic jstack analysis), the waiting room looks peaceful. You need a surveillance camera (JFR, async-profiler) to see how long tellers actually sit there.
Understanding the Concepts
Why does ReentrantLock show WAITING instead of BLOCKED?
synchronized uses intrinsic JVM monitors. A thread that fails to enter a synchronized block enters BLOCKED state, which is tracked by the JVM's monitor system. ReentrantLock uses AbstractQueuedSynchronizer (AQS), which parks threads using LockSupport.park(). Parked threads enter WAITING state (or TIMED_WAITING for timed variants). This is a fundamentally different JVM state from BLOCKED.
# synchronized contention in jstack:
"Worker-1" BLOCKED (on object monitor)
at com.bank.service.TransferService.transfer(TransferService.java:42)
- waiting to lock <0x7f2a4e012345> (com.bank.service.TransferService)
# ReentrantLock contention in jstack:
"Worker-1" WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:194)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(...)
Basic thread dump analysis that only counts BLOCKED threads completely misses ReentrantLock contention.
How do you detect ReentrantLock contention with JFR?
JDK Flight Recorder captures jdk.ThreadPark events for every park/unpark operation. Filter for long durations to find contention hotspots.
# Start a JFR recording for 60 seconds
jcmd <PID> JFR.start duration=60s filename=locks.jfr
- In JDK Mission Control:
- The "Thread Park" events tab shows park duration per event.
- Filter:
jdk.ThreadParkwhere duration > 1ms to find contention. - The "Lock Instances" tab shows the most contended locks by total wait time.
- The "Hot Methods" tab shows which methods hold locks the longest.
How do you use async-profiler for lock contention?
async-profiler with -e lock mode profiles lock contention directly. The resulting flame graph shows method call stacks where the width represents time spent waiting for locks, making hotspot methods visually obvious.
# Profile lock contention for 30 seconds, output as flame graph
./profiler.sh -d 30 -e lock -o flamegraph -f lock_flame.html <PID>
# In the flame graph:
# - Width = time spent WAITING for locks (not CPU time)
# - Tall stacks = deep call chains holding locks
# - Wide methods at the top = contention hotspots
What does time in Unsafe.park() mean?
Time spent in Unsafe.park() or LockSupport.park() is contention time: the thread is waiting for a lock and doing no useful work. If 50% of a thread's time is in park(), it spends half its life waiting for locks instead of processing settlements.
How do you fix ReentrantLock contention?
The fixes are the same principles as for any lock contention, applied to the specific structure.
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
public class PerAccountLocking {
// WRONG: coarse-grained lock, all accounts contend
private final ReentrantLock globalLock = new ReentrantLock();
// CORRECT: fine-grained per-account locks
private final ConcurrentHashMap<String, ReentrantLock> accountLocks =
new ConcurrentHashMap<>();
private ReentrantLock getLock(String accountId) {
return accountLocks.computeIfAbsent(accountId, id -> new ReentrantLock());
}
public void debit(String accountId, BigDecimal amount) {
ReentrantLock lock = getLock(accountId);
lock.lock();
try {
// Only threads accessing the SAME account contend
BigDecimal balance = getBalance(accountId);
updateBalance(accountId, balance.subtract(amount));
} finally {
lock.unlock();
}
}
}
How does StampedLock reduce contention for read-heavy workloads?
If the workload is 95% reads and 5% writes, StampedLock's optimistic read avoids acquiring any lock at all for the common case.
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.locks.StampedLock;
public class OptimisticReadExample {
private final StampedLock sl = new StampedLock();
private BigDecimal balance = new BigDecimal("100000.00");
public BigDecimal getBalance() {
long stamp = sl.tryOptimisticRead(); // no lock acquired!
BigDecimal result = balance;
if (!sl.validate(stamp)) { // check if a write happened
stamp = sl.readLock(); // fall back to read lock
try { result = balance; }
finally { sl.unlockRead(stamp); }
}
return result;
}
}
Can ThreadMXBean detect ReentrantLock deadlocks?
Yes. findDeadlockedThreads() detects deadlocks involving both intrinsic monitors (synchronized) and owner-based locks (ReentrantLock). The older findMonitorDeadlockedThreads() only detects synchronized deadlocks.
What are common interview follow-up questions?
- Is ReentrantLock slower than synchronized? Under low contention, synchronized is slightly faster because the JVM applies thin-lock and biased-lock optimizations (biased locking removed in Java 21). Under high contention, ReentrantLock's non-blocking CLH queue is faster.
- How do you choose between narrowing the critical section and switching to finer-grained locking? Start by narrowing the critical section (move IO outside the lock). If contention is still high, switch to per-entity locks. If the workload is read-heavy, use StampedLock or ReadWriteLock.
Visual Overview
ReentrantLock vs synchronized in Thread Dumps ══════════════════════════════════════════════ synchronized: Thread state: BLOCKED ← visible in jstack! jstack: "waiting to lock <0x...>" Detection: count BLOCKED threads ReentrantLock: Thread state: WAITING (parking) ← hidden in basic analysis! jstack: "Unsafe.park()" Detection: JFR ThreadPark events or async-profiler -e lock Both detected by: ThreadMXBean.findDeadlockedThreads() ← deadlocks JFR + JDK Mission Control ← contention async-profiler -e lock ← flame graph
Key Takeaway
ReentrantLock contention is hidden in basic thread dump analysis because waiters show as WAITING (parking), not BLOCKED. Only synchronized contention appears as BLOCKED. This means counting BLOCKED threads in jstack misses ReentrantLock contention entirely.
To detect ReentrantLock contention, use JFR jdk.ThreadPark events (shows park duration, blocker class, and stack trace) or async-profiler with -e lock mode (generates flame graphs where width represents time waiting for locks). Time in Unsafe.park() is contention time, not useful work.
At National Bank of India, a rate limiter using a coarse ReentrantLock showed 100 threads spending 1.5 seconds each in park() per request. JFR revealed the hotspot method, and switching to per-account locking reduced wait time from 1.5 seconds to 5 milliseconds.
Remember one sentence: ReentrantLock contention shows as WAITING, not BLOCKED, so use JFR ThreadPark events or async-profiler -e lock to find and measure it.
---