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

PrimitiveMechanismBest use
synchronizedOS mutex (biased → thin → inflated)General-purpose mutual exclusion; simple to read
ReentrantLockSame as synchronized + rich APIWhen you need try-lock, timed-lock, or fairness
AtomicLongSingle CAS instructionLow-to-medium contention; result needed immediately
LongAdderStriped 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:

  • LongAdder is ~2× faster than AtomicLong under 4-thread contention because threads never contend on the same cell.
  • ReentrantLock and synchronized are 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 LongAdder over AtomicLong?LongAdder for high-throughput counters/histograms where you only need the total at the end. AtomicLong when 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) vs Scope.Thread) mean for contention?Scope.Benchmark shares one instance across all JMH worker threads (real contention); Scope.Thread gives each thread its own instance (no contention).
  • Why is ReentrantLock not faster than synchronized here? — both park threads in the OS kernel when uncontended paths are taken; the extra API flexibility of ReentrantLock doesn't help for simple mutual exclusion.