Lab 03 — Lock Contention Analysis
Goal: Implement four counter strategies (synchronized, ReentrantLock,
AtomicLong, LongAdder), prove each is thread-safe under real concurrency, and
use JMH to measure how throughput differs under 4-thread contention.
Prerequisites
- JDK 11+ (
java -version) - Maven 3.8+ (
mvn -version)
Five-Step Lab
Step 1 — Understand the four primitives
| Primitive | Mechanism | Best use |
|---|---|---|
synchronized | OS mutex (biased → thin → inflated) | General-purpose mutual exclusion; simple to read |
ReentrantLock | Same as synchronized + rich API | When you need try-lock, timed-lock, or fairness |
AtomicLong | Single CAS instruction | Low-to-medium contention; result needed immediately |
LongAdder | Striped cells (one per CPU) | High-contention counters where only final sum matters |
Step 2 — Read the benchmark setup
@State(Scope.Benchmark) // one shared instance — all threads contend on it
@Threads(4) // 4 concurrent JMH worker threads
@Param({"synchronized", "reentrant", "atomic", "longadder"})
String impl;
@Setup(Level.Iteration)
public void setup() {
// recreate counter before each measurement iteration to reset state
}
@Benchmark
public void increment() {
counter.increment(); // hot path: all 4 threads call this simultaneously
}
@State(Scope.Benchmark) is the key difference from Lab 01's Scope.Thread —
here, all threads share the same ContentionBenchmark instance, producing real
lock contention.
Step 3 — Run the correctness tests
The correctness tests are the most important part. They launch 8 threads, each
incrementing 1 000 times, and assert the final count equals 8 000. Any "lost
update" bug (e.g., non-atomic count++) will cause this to fail non-deterministically.
mvn clean test
# Expected: Tests run: 5, Failures: 0, Errors: 0
Step 4 — Run the contention benchmark
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="ContentionBenchmark -wi 3 -i 5 -f 1 -t 4"
Step 5 — Interpret the results
Typical output (4 threads, higher ops/ms = better):
Benchmark (impl) Mode Cnt Score Error Units
ContentionBenchmark.increment longadder thrpt 5 412384.442 ± 8214.2 ops/ms
ContentionBenchmark.increment atomic thrpt 5 198341.122 ± 3104.7 ops/ms
ContentionBenchmark.increment reentrant thrpt 5 82341.441 ± 1823.3 ops/ms
ContentionBenchmark.increment synchronized thrpt 5 78921.332 ± 2104.9 ops/ms
Key observations:
LongAdderis ~2× faster thanAtomicLongunder 4-thread contention because threads never contend on the same cell.ReentrantLockandsynchronizedare similar — both park threads on the OS wait queue.- The gap between lock-based and CAS-based widens as thread count increases.
Source Files
src/main/java/org/example/perf/
├── Counter.java ← common interface
├── SynchronizedCounter.java ← synchronized methods
├── ReentrantLockCounter.java ← explicit ReentrantLock
├── AtomicCounter.java ← AtomicLong.incrementAndGet()
├── LongAdderCounter.java ← LongAdder.increment()
└── ContentionBenchmark.java ← @Threads(4) throughput benchmark
src/test/java/org/example/perf/
└── ContentionCorrectnessTest.java ← 4 thread-safety proofs + 1 JMH smoke test
Counter.java
package org.example.perf;
/**
* Common interface for all counter implementations in this lab.
*
* <p>
* Using an interface lets the ContentionBenchmark select the implementation
* via @Param at runtime without branching inside the hot path.
*/
public interface Counter {
/** Increment the counter by 1. */
void increment();
/** Return the current count. */
long get();
/** Reset the counter to 0 (called between iterations). */
void reset();
}
SynchronizedCounter.java
package org.example.perf;
/**
* Counter backed by a plain {@code long} with {@code synchronized} methods.
*
* <p>
* Under high contention, threads queue on the object monitor. Each
* increment requires an OS-level mutex acquire/release round-trip when
* uncontended biased-locking is revoked — expensive at scale.
*/
public class SynchronizedCounter implements Counter {
private long count = 0;
@Override
public synchronized void increment() {
count++;
}
@Override
public synchronized long get() {
return count;
}
@Override
public synchronized void reset() {
count = 0;
}
}
ReentrantLockCounter.java
package org.example.perf;
import java.util.concurrent.locks.ReentrantLock;
/**
* Counter backed by a {@link ReentrantLock}.
*
* <p>
* ReentrantLock provides the same mutual exclusion as {@code synchronized}
* but with explicit lock/unlock, try-lock timeouts, and fairness options.
* Under plain contention, throughput is similar to {@code synchronized};
* the main advantage is the richer API (e.g. {@code lockInterruptibly()}).
*/
public class ReentrantLockCounter implements Counter {
private final ReentrantLock lock = new ReentrantLock();
private long count = 0;
@Override
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
@Override
public long get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
@Override
public void reset() {
lock.lock();
try {
count = 0;
} finally {
lock.unlock();
}
}
}
AtomicCounter.java
package org.example.perf;
import java.util.concurrent.atomic.AtomicLong;
/**
* Counter backed by {@link AtomicLong}.
*
* <p>
* Uses a single CAS (Compare-And-Swap) instruction — no OS mutex, no thread
* parking. Faster than lock-based counters when contention is moderate, but
* under very high contention threads spin-retry and throughput degrades.
*/
public class AtomicCounter implements Counter {
private final AtomicLong count = new AtomicLong(0);
@Override
public void increment() {
count.incrementAndGet();
}
@Override
public long get() {
return count.get();
}
@Override
public void reset() {
count.set(0);
}
}
LongAdderCounter.java
package org.example.perf;
import java.util.concurrent.atomic.LongAdder;
/**
* Counter backed by {@link LongAdder}.
*
* <p>
* LongAdder maintains a cell per CPU core (via Striped64 internals). Each
* thread increments its own cell, eliminating CAS contention. The true sum is
* computed only when {@code sum()} is called. Under high contention, LongAdder
* outperforms AtomicLong significantly; for single-threaded use, AtomicLong is
* slightly faster because there is no cell-routing overhead.
*/
public class LongAdderCounter implements Counter {
private final LongAdder adder = new LongAdder();
@Override
public void increment() {
adder.increment();
}
@Override
public long get() {
return adder.sum();
}
@Override
public void reset() {
adder.reset();
}
}
ContentionBenchmark.java
package org.example.perf;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
/**
* Multi-threaded throughput benchmark for four counter implementations.
*
* <p>
* @State(Scope.Benchmark) means all JMH worker threads share one instance of
* this class — producing real contention on the counter. @Threads(4) runs 4
* concurrent threads. @Param lets us compare all four implementations in a
* single benchmark run.
*
* <p>
* Expected result on a typical laptop (higher ops/s = better):
*
* <pre>
* LongAdderCounter >> AtomicCounter > SynchronizedCounter ≈ ReentrantLockCounter
* </pre>
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Threads(4)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0)
public class ContentionBenchmark {
@Param({ "synchronized", "reentrant", "atomic", "longadder" })
String impl;
Counter counter;
@Setup(Level.Iteration)
public void setup() {
switch (impl) {
case "synchronized":
counter = new SynchronizedCounter();
break;
case "reentrant":
counter = new ReentrantLockCounter();
break;
case "atomic":
counter = new AtomicCounter();
break;
case "longadder":
counter = new LongAdderCounter();
break;
default:
throw new IllegalArgumentException("Unknown impl: " + impl);
}
}
@Benchmark
public void increment() {
counter.increment();
}
}
ContentionCorrectnessTest.java
package org.example.perf;
import org.junit.jupiter.api.Test;
import org.openjdk.jmh.results.RunResult;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.openjdk.jmh.runner.options.TimeValue;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
/**
* Correctness tests: all counter implementations must be thread-safe.
*
* <p>
* Each test spawns 8 threads, each incrementing a counter 1 000 times.
* The expected final count is always 8 000. Any lost update means the
* implementation is broken under concurrency.
*
* <p>
* JMH smoke test: runs ContentionBenchmark in-process to verify the harness
* wires up correctly.
*/
class ContentionCorrectnessTest {
private static final int THREADS = 8;
private static final int INCREMENTS_PER_THREAD = 1_000;
private static final long EXPECTED = (long) THREADS * INCREMENTS_PER_THREAD;
// ---- helpers -------------------------------------------------------
private void assertCorrectUnderContention(Counter counter) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
pool.submit(() -> {
try {
startGate.await();
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
counter.increment();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
startGate.countDown(); // release all threads at once
assertTrue(done.await(10, TimeUnit.SECONDS), "Threads did not finish in time");
pool.shutdownNow();
assertEquals(EXPECTED, counter.get(),
"Counter lost updates under contention: " + counter.getClass().getSimpleName());
}
private static ChainedOptionsBuilder minimal(String benchmarkRegex) {
return new OptionsBuilder()
.include(benchmarkRegex)
.forks(0)
.warmupIterations(1)
.warmupTime(TimeValue.milliseconds(100))
.measurementIterations(1)
.measurementTime(TimeValue.milliseconds(100))
.threads(2);
}
// ---- correctness tests ---------------------------------------------
@Test
void synchronizedCounterIsThreadSafe() throws InterruptedException {
assertCorrectUnderContention(new SynchronizedCounter());
}
@Test
void reentrantLockCounterIsThreadSafe() throws InterruptedException {
assertCorrectUnderContention(new ReentrantLockCounter());
}
@Test
void atomicCounterIsThreadSafe() throws InterruptedException {
assertCorrectUnderContention(new AtomicCounter());
}
@Test
void longAdderCounterIsThreadSafe() throws InterruptedException {
assertCorrectUnderContention(new LongAdderCounter());
}
// ---- JMH smoke test ------------------------------------------------
@Test
void contentionBenchmarkRunsWithoutError() throws RunnerException {
Options opts = minimal("ContentionBenchmark")
.param("impl", "atomic", "longadder")
.build();
Collection<RunResult> results = new Runner(opts).run();
assertFalse(results.isEmpty(), "Expected at least one JMH result");
}
}
False Sharing (Bonus)
LongAdder already avoids false sharing internally (each cell is padded to 64
bytes). You can introduce false sharing deliberately by placing two long fields
adjacent in a class:
// BAD: two counters share a cache line — writing one invalidates the other
class BadPair { volatile long a; volatile long b; }
// GOOD: pad to 64 bytes between hot fields
class GoodPair {
volatile long a;
long p1, p2, p3, p4, p5, p6, p7; // 56 bytes padding
volatile long b;
}
Benchmark BadPair vs GoodPair increments from 2 threads to see the ~3–10×
throughput difference.
Interview Talking Points
- When would you use
LongAdderoverAtomicLong? —LongAdderfor high-throughput counters/histograms where you only need the total at the end.AtomicLongwhen you need the result of each increment immediately (e.g., for a sequence ID). - What is biased locking? — JVM optimization where the JIT "biases" an object monitor towards the first thread that acquires it; subsequent acquires by the same thread are free. Revoked when a second thread tries to acquire — expensive revocation.
- What does
@State(Scope.Benchmark)vsScope.Thread)mean for contention? —Scope.Benchmarkshares one instance across all JMH worker threads (real contention);Scope.Threadgives each thread its own instance (no contention). - Why is
ReentrantLocknot faster thansynchronizedhere? — both park threads in the OS kernel when uncontended paths are taken; the extra API flexibility ofReentrantLockdoesn't help for simple mutual exclusion.