Concurrency
Livelock and lock convoy on a hot shared registry
Written and reviewed by Sahil Srivastav
$ jcmd 5120 Thread.print | grep -c "BLOCKED (on object monitor)"
61
"api-exec-44" #118 prio=5 os_prio=0 tid=0x00007fb3240f1800 nid=0x7c21 waiting for monitor entry [0x00007fb2e3bfc000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.registry.ServiceRegistry.lookup(ServiceRegistry.java:57)
- waiting to lock <0x00000006d1042a18> (a com.example.registry.ServiceRegistry)
at com.example.api.RouteHandler.handle(RouteHandler.java:31)
# 61 of 64 workers blocked on ONE monitor. No deadlock is reported — the holder
# changes on every dump, so the system is making progress, just barely.
$ vmstat 1 3 | awk 'NR>2 {print "cs/s:", $12, "idle:", $15}'
cs/s: 412883 idle: 71
cs/s: 431027 idle: 74What this error actually means
A lock convoy is not a deadlock. Every thread eventually acquires the lock, does its work, and releases it — the system makes progress, so the JVM reports no cycle and nothing is stuck forever. What collapses is throughput, because the critical section has become a single-lane bridge that every request must cross one at a time, and the cost of queuing for it exceeds the cost of the work inside it.
The reason it gets *worse* with more threads is the part that surprises people. When a monitor is contended, a thread that cannot acquire it is parked by the OS and must be woken and rescheduled when the lock is released. That wake-up is not free and it is not instant: the releasing thread hands over, the woken thread has to be scheduled onto a CPU, and its cache lines for the guarded data are cold because another core just touched them. Add threads and you add context switches and cache-line transfers, not throughput. The `cs/s` figure of 400,000 with 70% idle CPU in the output above is the signature — the machine is busy scheduling, not working.
A convoy has a self-sustaining quality that makes it sticky. Once a queue forms on the monitor, every release hands the lock to a thread that then takes time to warm up, extending the hold time slightly, which lengthens the queue further. Throughput settles at a level far below what the same code achieves with two threads, and it stays there until load drops. This is why a service can be fast at 10 requests per second, fast at 100, and fall off a cliff at 250.
Livelock is a different failure that people reach for the same word for, and the distinction is diagnostic. In a convoy, threads are `BLOCKED` and idle, CPU is low, and progress is slow. In livelock, threads are `RUNNABLE` and burning CPU, repeatedly attempting and abandoning the same operation — a `tryLock`-and-back-off pair that keeps colliding, or a CAS retry loop where contention is so high that almost every attempt fails and retries. CPU near 100% with no completed work is livelock; CPU idle with a long monitor queue is a convoy.
Causes, most common first
- 1One coarse lock guarding a hot, mostly read-only structure. The dominant cause. A registry, a routing table, a feature-flag snapshot, or a configuration map read on every request and written rarely, guarded by a single `synchronized` on the whole object. Readers do not conflict with each other at all, yet they serialise completely.
- 2Slow work inside the critical section. Logging, metric emission, serialisation, or an allocation-heavy transformation inside the lock. Hold time is what determines queue length, so a two-microsecond section tolerates enormous concurrency and a two-millisecond one does not. Anything I/O-shaped in there is fatal.
- 3Lock granularity that does not match access patterns. One lock for a structure whose keys are independent. Requests for unrelated tenants contend even though they touch disjoint data, so contention scales with total traffic rather than with per-key traffic.
- 4Fair locking on a hot path. `new ReentrantLock(true)` enforces FIFO by handing the lock to the longest-waiting thread, which almost always means a park-and-wake for every single acquisition rather than letting a running thread barge in. Fairness improves the worst-case wait and can cost an order of magnitude in throughput.
- 5A CAS retry loop under high contention (livelock). An `AtomicReference.compareAndSet` loop, or a hand-written optimistic update, where so many threads retry that most attempts fail. Every thread is running and almost none complete. Visible as high CPU with a retry counter rising much faster than a success counter.
- 6Mutual back-off with `tryLock` (livelock). Two code paths that take locks in different orders, each using `tryLock` and releasing everything on failure to avoid deadlock, then immediately retrying. They can collide indefinitely — deadlock was avoided and progress was not achieved. A randomised back-off is what breaks the symmetry.
- 7False sharing on adjacent counters. Not a lock at all, and it produces the same "more threads, less throughput" curve. Independent fields on the same 64-byte cache line force cores to bounce ownership on every write. Suspect it when there is no contended lock in the dump but scaling is still negative.
When you see it
- Throughput falls as concurrency rises past a threshold, and rises again when you reduce the thread count
- Dozens of threads `BLOCKED (on object monitor)` on the same monitor identity, with the holder different in each dump
- CPU substantially idle while latency is terrible — the machine is not the bottleneck
- Context switches per second in the hundreds of thousands (`vmstat`), far above the work rate
- p99 latency an order of magnitude above p50, because queue position is effectively random
- No deadlock reported, which sends the investigation to the database or the network
- For the livelock variant instead: CPU pinned at 100%, threads `RUNNABLE`, and a retry counter climbing with no completions
How to diagnose it
Step 1
Count blocked threads per monitor
The fastest useful measurement. Many threads blocked on one monitor identity, with a different owner in successive dumps, is a convoy — progress plus a queue. The same owner across dumps would be a leaked lock or a lock held across I/O instead.
jcmd <pid> Thread.print | grep "waiting to lock" | sort | uniq -c | sort -rn | headStep 2
Check context switches against work done
This separates a contention problem from a capacity problem decisively. Hundreds of thousands of switches per second with idle CPU means the threads are spending their time being scheduled rather than executing.
vmstat 1 5 # cs column, and id for idle
pidstat -w -p <pid> 1 5 # cswch/s and nvcswch/s per threadStep 3
Measure the contention directly
JFR quantifies what a dump only suggests: which monitor, how long threads waited, and which stacks were blocked. This turns "there is contention somewhere" into a ranked list with durations.
jcmd <pid> JFR.start name=conv settings=profile duration=60s filename=/tmp/conv.jfr
jfr summary /tmp/conv.jfr | grep -i monitor
jfr print --events JavaMonitorEnter /tmp/conv.jfr | head -40Step 4
Run the negative-scaling experiment
Measure throughput at 2, 4, 8, 16, 32 threads. A curve that peaks early and declines is contention, and the peak tells you the effective serial fraction. A flat curve is a downstream bound; a rising one means you do not have this problem.
Step 5
Separate convoy from livelock by CPU and thread state
Read the two together. `BLOCKED` with idle CPU is a convoy. `RUNNABLE` with saturated CPU and no completions is livelock. They look identical in a latency graph and need opposite fixes, so do not skip this step.
jcmd <pid> Thread.print | grep -c "Thread.State: BLOCKED"
jcmd <pid> Thread.print | grep -c "Thread.State: RUNNABLE"Step 6
Time the critical section
Instrument entry and exit and record the hold-time distribution. Hold time multiplied by arrival rate is the utilisation of your single-lane bridge; once that product approaches one, queueing theory alone predicts the collapse you are seeing.
The fix
Get readers out of the lock entirely. For a structure that is read constantly and written rarely, hold the state in an immutable snapshot behind a `volatile` reference: readers do a plain volatile read with no lock at all, and writers build a new snapshot and publish it with one assignment. Contention goes to zero on the read path, which is where all your traffic is. `CopyOnWriteArrayList` and `ConcurrentHashMap` are the off-the-shelf versions of this idea.
Shrink the critical section to the smallest possible mutation. Move logging, metrics, serialisation, allocation, and any validation outside it. Hold time is the variable that controls queue length, so halving it roughly halves the queue — and moving a single log statement out of a hot lock has resolved more of these incidents than any amount of tuning.
Match granularity to the access pattern. If keys are independent, use `ConcurrentHashMap` with per-key atomic operations, or stripe an array of locks by key hash so only colliding keys contend. One lock for a structure with a thousand independent keys makes contention a function of total traffic instead of per-key traffic.
Do not use a fair lock on a hot path. Fairness forces a park-and-wake handoff per acquisition and can cost an order of magnitude in throughput. Leave `ReentrantLock` unfair (the default) and address worst-case latency with a `tryLock` timeout and a queue-length metric instead.
For the CAS-livelock variant, reduce the contention rather than the retries: `LongAdder` instead of a shared `AtomicLong`, striping instead of one hot cell, or batching updates so a thread makes one attempt per batch. Where a retry loop is unavoidable, add randomised exponential back-off — deterministic back-off is what lets two threads collide forever.
For mutual `tryLock` back-off, fix the underlying lock ordering so back-off is not needed, then keep a randomised delay as a safety net. A system whose liveness depends on threads happening not to collide is not designed, it is lucky.
Reducing the thread pool size is a legitimate mitigation while you fix the structure, and it is worth stating plainly because it is counter-intuitive: fewer threads on a contended lock means less scheduling overhead and higher throughput. Treat it as a way to buy time during an incident, not as the fix — the serial section is still there.
If there is no contended monitor and scaling is still negative, suspect false sharing and pad or separate the hot fields. `@Contended` (with `-XX:-RestrictContended`) or manual padding is the tool; `LongAdder` already does this internally, which is part of why it scales.
// Convoy: every request serialises on one monitor to read a rarely-changing map
public synchronized Route lookup(String path) {
log.debug("lookup {}", path); // logging inside the lock
return routes.get(path);
}
public synchronized void update(Map<String, Route> next) {
routes.clear();
routes.putAll(next);
}
// Lock-free reads: immutable snapshot published through a volatile reference
private volatile Map<String, Route> routes = Map.of();
public Route lookup(String path) {
return routes.get(path); // plain volatile read, no lock
}
public void update(Map<String, Route> next) {
routes = Map.copyOf(next); // one publishing write
}
// Independent keys: stripe so only colliding keys contend
private final Object[] stripes = IntStream.range(0, 64)
.mapToObj(i -> new Object()).toArray();
void touch(String key) {
synchronized (stripes[Math.floorMod(key.hashCode(), stripes.length)]) {
counters.merge(key, 1L, Long::sum);
}
}
// CAS livelock: one hot cell retried by 64 threads -> striped adder instead
private final LongAdder hits = new LongAdder();
void record() { hits.increment(); }How to stop it coming back
- Measure throughput against thread count as a routine benchmark; negative scaling is the only reliable early warning for contention
- Keep critical sections free of logging, metrics, allocation, serialisation, and every form of I/O — enforce it in review
- Default to immutable snapshots behind a volatile reference for read-mostly shared state, rather than a lock
- Alarm on blocked-thread counts and monitor-wait time from JFR, not only on latency, so contention is attributable before it is an incident
- Use unfair locks on hot paths and manage tail latency with timeouts and queue metrics instead of fairness
- Prefer `LongAdder` and striped structures over single hot atomics anywhere the update rate is high
- Add randomised back-off to any retry loop so two participants cannot synchronise into a livelock
Practise this failure in a real repository
Gronex ships this as a runnable repository: a shared registry read on every request under one coarse monitor, with a benchmark that shows throughput falling as threads are added. The test suite asserts a throughput floor at high concurrency, so shrinking the pool or tuning the JVM does not pass — only restructuring the read path does.
FAQ
Why does adding threads make it slower?
Because the added threads do not add capacity to the serial section; they add queueing. Each blocked thread must be parked and later woken and rescheduled, and it resumes with cold cache lines for the guarded data. Past the point where the lock is saturated, every extra thread contributes context switches and cache-line transfers and no extra work.
How is a convoy different from a deadlock?
A deadlock is a cycle: no thread involved will ever proceed, and the JVM reports it. A convoy has no cycle — the lock is acquired and released continuously, so the holder differs in every dump and the system does make progress. The dump distinguishes them: many blocked threads with a rotating owner and no reported deadlock is a convoy.
How do I tell livelock from a convoy?
Look at CPU and thread state together. A convoy shows `BLOCKED` threads and idle CPU — they are parked, waiting. Livelock shows `RUNNABLE` threads and saturated CPU with nothing completing, because they are actively retrying and abandoning work. The fixes are opposite: reduce the serial section for a convoy, reduce contention or add randomised back-off for livelock.
Should I use a fair lock to make latency more predictable?
Almost never on a hot path. Fairness forbids barging, so nearly every acquisition becomes a park-and-wake handoff, and throughput can drop by an order of magnitude. Better tail latency comes from making the critical section shorter or removing readers from it — and a `tryLock` timeout gives you a bounded worst case without paying the fairness cost on every acquisition.
Is this fixed by virtual threads?
No, and it can be made worse. A contended monitor still serialises, and virtual threads make it cheap to create far more contenders than a platform-thread pool would have allowed. The parking is cheaper, so the collapse is gentler, but the serial section is unchanged and the queue is now much longer. Contention is an architectural property, not a scheduling one.
Would biased locking have helped here?
No — biased locking only optimised the uncontended case, where one thread repeatedly reacquires the same monitor. It was disabled by default in JDK 15 and removed later precisely because it bought nothing under real contention while complicating the runtime. A hot shared registry is the contended case by definition.
Related
Other errors engineers hit next to this one
- Liveness probe failed during startup
- CreateContainerConfigError — missing Secret or ConfigMap
- Evicted — node disk pressure
- runAsNonRoot and image will run as root
- JVM container killed despite available Java heap
- exec format error — container architecture mismatch
- no space left on device — container layers
- SIGTERM misses the application — shutdown ends in SIGKILL