This example measures the cost of a zero-delay retry in a distributed lock service, and says what
LivelockDetector does and does not report about it.
PoliteRetryLockService.acquireLock(nodeId) retries lock acquisition immediately
on contention, yielding with Thread.yield() and no sleep. When multiple nodes
simultaneously detect contention, they all back off and retry at exactly the same
moment — creating a symmetric cycle that repeats indefinitely.
Thread 1: CAS(null → node-1) FAILS → yield → retry → CAS FAILS → yield → retry …
Thread 2: CAS(null → node-2) FAILS → yield → retry → CAS FAILS → yield → retry …
Thread 3: CAS(null → node-3) WINS → critical section → release
Thread 1: CAS(null → node-1) FAILS → yield → retry … (immediately re-contested)
Thread 2: CAS(null → node-2) FAILS → yield → retry …
All threads are continuously RUNNABLE — they are not blocked or waiting — yet
no thread accumulates useful work because the lock is never held long enough for
a second winner to emerge before all losers flood back in.
| Deadlock | Livelock | |
|---|---|---|
| Thread state | BLOCKED |
RUNNABLE |
| CPU usage | Near zero | High (busy-spinning) |
| Appears to make progress | No | Appears active, but doesn't |
| What a round timeout shows | Workers BLOCKED or WAITING |
Workers still RUNNABLE |
Because livelock threads are RUNNABLE, ThreadMXBean.findDeadlockedThreads()
returns nothing. LivelockDetector catches it from the one thing that shows it: the body did not
finish. When a round times out, it reads the state of each worker still in its body.
@Test
void testAcquireLock_singleThread_succeeds() {
boolean acquired = service.acquireLock("node-1");
assertTrue(acquired); // ✅ Passes — no contention, first CAS always wins
}With one thread there is never a competing CAS. The single node acquires the lock immediately and completes without retrying.
cd examples/07-livelock
mvn clean test
# ✅ All @Test methods passacquireLock gives up after 200 attempts, so a round ends and the detector correctly has nothing to
say: what this design loses is the work the other nodes threw away, which is a throughput bug rather
than a stall. The tests measure that directly. A retry loop with no such limit does draw the finding,
which agent_retryLoop_permitNeverReleased in corpus-eval holds against its twin.
No manual instrumentation is needed in the test body. The runner marks each worker in and out of the
body, and when a round times out, before it interrupts anyone, it asks the detector to sample the
workers still inside through Thread.getState(), on platform and virtual workers alike:
- Livelock candidate:
RUNNABLEin any sample, which is a spin, a retry that never succeeds, a long computation or a blocking native call - Starvation:
BLOCKEDorWAITINGin every sample - No progress: anything else, such as a retry loop asleep in
TIMED_WAITING
A round that finishes is never reported. The only threshold is the timeoutMs the test chose.
Introduce randomised exponential back-off before each retry:
// FIXED: exponential back-off with jitter breaks the symmetry
public boolean acquireLockFixed(String nodeId) throws InterruptedException {
long delayMs = 1L;
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
if (lockHolder.compareAndSet(null, nodeId)) {
performCriticalWork(nodeId);
lockHolder.set(null);
acquisitionCount++;
return true;
}
// Different wait times mean not all nodes retry simultaneously
Thread.sleep(delayMs + (long)(Math.random() * delayMs));
delayMs = Math.min(delayMs * 2, 100L);
}
return false;
}The randomised delay ensures at least one node is sleeping while another holds the lock. When the holder releases, a subset of contenders retries — not all of them simultaneously — so one makes progress while the rest wait.
PoliteRetryLockService.java— Buggy lock service with zero-delay retry + fixed versionPoliteRetryLockServiceTest.java— Sequential@Testmethods that pass +@AsyncTestthat triggers LivelockDetectorpom.xml— Maven dependencies (JUnit 5 + async-test-lib 1.7.0-RC2)
- Livelocks are invisible to standard timeouts: threads appear active and healthy
from a monitoring perspective.
LivelockDetector's snapshot-based analysis is the only automated way to catch the pattern. - Zero-delay retry loops are dangerous under contention: always add back-off in any retry loop that competes for a shared resource.
- Randomness breaks symmetry: a deterministic back-off (e.g. fixed
5 ms) can still cause livelock if all threads pick identical delays. Jitter is essential.