CodeGym /Courses /JAVA 25 SELF /StampedLock and low-contention counters

StampedLock and low-contention counters

JAVA 25 SELF
Level 58 , Lesson 3
Available

1. Why ReadWriteLock is not always enough

High write contention

ReadWriteLock (most often — ReentrantReadWriteLock) works well when most threads only read data and there are few writers. In this case, multiple threads can read concurrently, and a write blocks access only for a short moment.

The problem arises when there are more writing threads than expected, or they often switch between reading and writing. If write operations take a long time, threads start waiting longer for locks to be released. As a result, contention for data access grows, both reads and writes slow down, and the effectiveness of ReadWriteLock declines.

Expensive lock switching

When a thread switches from read mode to write mode (or vice versa), a complex check happens under the hood: the system must ensure no one else is writing, that all readers have exited, and only then allow the write.

If such transitions happen frequently, this creates delays. Threads begin to queue up, and performance drops — especially when there are many read and write operations at the same time.

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

// Read
readLock.lock();
try {
    // read data
} finally {
    readLock.unlock();
}

// Write
writeLock.lock();
try {
    // modify data
} finally {
    writeLock.unlock();
}

Every time a thread switches between readLock and writeLock, the system performs all checks to avoid conflicts. If there are many such switches — it’s expensive.

2. StampedLock: a modern approach to synchronization

StampedLock is a modern synchronization mechanism introduced in Java 8. It combines ideas from ReadWriteLock but adds a new mode — optimistic reads, and works not with locks but with “stamps” — special tokens that must be explicitly released.

Key features:

  • Three modes: write lock (exclusive write), read lock (shared read), optimistic read (lock-free optimistic read).
  • No reentrancy: you cannot re-enter a lock from the same thread.
  • High performance with many reads and rare writes.
  • Requires explicit stamp management (stamp).

Optimistic reads: tryOptimisticRead + validate

An optimistic read is a mode where a thread reads data without any lock at all, hoping that no one is writing at the same time. After the read, the thread must check whether a write occurred during the read using validate(stamp).

import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock lock = new StampedLock();

    public double distanceFromOrigin() {
        long stamp = lock.tryOptimisticRead();
        double currentX = x;
        double currentY = y;
        // Check if a write occurred during the read
        if (!lock.validate(stamp)) {
            // If a write occurred — acquire a regular read lock
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.hypot(currentX, currentY);
    }
}

How it works:

  • tryOptimisticRead() returns a stamp (long) that is “valid” as long as nobody is writing.
  • Read the values of x and y.
  • validate(stamp) checks whether a write occurred between the start and the end of the read.
  • If everything is OK — use the read values; if a write occurred — acquire a regular readLock and read again.

This pays off when writes are very rare and reads are frequent. In most cases, validate returns true, and the read is almost free.

Scenarios with many reads and few writes

  • Data changes rarely but is read frequently (e.g., a cache, coordinates, metadata).
  • It’s important to minimize read latency.
  • It’s acceptable to “re-read” data if a write occurred.

Fallback to a read lock

If the optimistic read fails (validate returns false), the thread falls back to a regular readLock. This guarantees correctness but happens rarely.

3. Pitfalls of StampedLock

No reentrancy

Unlike ReentrantReadWriteLock, StampedLock does not support re-entry. If a thread already holds a lock and tries to acquire it again — you can deadlock.

long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! The thread is waiting on itself

Be mindful of interruptions

StampedLock does not react to thread interrupts the same way classic locks do. If a thread is interrupted while waiting for a lock, it doesn’t always “wake up” immediately. For tasks where quick interrupt handling is important, use other mechanisms.

Correctly releasing stamps

Every call to readLock(), writeLock(), or tryOptimisticRead() returns a unique stamp (long). You must pass it to the corresponding unlock method:

  • unlockRead(stamp)
  • unlockWrite(stamp)

Error: If you mix up stamps or forget to call unlock, you’ll leak locks and the program will hang.

4. Comparison with ReentrantReadWriteLock

Characteristic ReentrantReadWriteLock StampedLock
Reentrancy Yes No
Optimistic read No Yes
Performance under high contention Average High (with few writes)
Explicit lock management No (automatic) Yes (stamps)
Response to interruptions Yes Not always
Fairness Yes (can be enabled) No

Fairness modes and impact on starvation

  • In ReentrantReadWriteLock you can enable a “fair” mode (fair) so threads are served in queue order. This prevents starvation.
  • In StampedLock there is no fairness: threads may wait longer if others keep “snatching” the lock. Occasional starvation is possible.

5. Counters: LongAdder/LongAccumulator vs AtomicLong

The AtomicLong problem under high contention

AtomicLong is an atomic variable that provides a thread-safe increment. But when many threads call incrementAndGet() simultaneously, they all “fight” over a single variable, which leads to performance degradation.

LongAdder: striped counters

LongAdder solves the problem differently: it splits the counter into several stripes, each serving its own group of threads. A thread increments one of the stripes, and the final value is the sum of all stripes.

Advantage:

  • Under high contention, threads barely interfere with each other.
  • Performance is many times higher than with AtomicLong.
import java.util.concurrent.atomic.LongAdder;

LongAdder adder = new LongAdder();

Runnable task = () -> {
    for (int i = 0; i < 100_000; i++) {
        adder.increment();
    }
};

Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
    threads[i] = new Thread(task);
    threads[i].start();
}
for (Thread t : threads) t.join();

System.out.println("Final value: " + adder.sum());

LongAccumulator

LongAccumulator is a generalized version of LongAdder, where you can set an arbitrary accumulation function (for example, max, min, etc.).

import java.util.concurrent.atomic.LongAccumulator;

LongAccumulator max = new LongAccumulator(Long::max, Long.MIN_VALUE);

max.accumulate(10);
max.accumulate(42);
max.accumulate(7);

System.out.println("Maximum: " + max.get()); // 42

Striped locks and reducing contention

The striped locks technique splits a shared resource into several independent parts (stripes), each protected by its own lock or variable. Threads are evenly distributed across stripes, which reduces contention and increases performance. This is exactly the approach used by LongAdder and LongAccumulator.

6. Practice: a read-dominant cache

Task: we have a map (Map) with data and metadata (for example, a hit counter). Reads happen frequently; writes are rare.

Implementation with StampedLock:

import java.util.*;
import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.atomic.LongAdder;

public class MetadataCache<K, V> {
    private final Map<K, V> map = new HashMap<>();
    private final StampedLock lock = new StampedLock();
    private final LongAdder hits = new LongAdder();

    public V get(K key) {
        long stamp = lock.tryOptimisticRead();
        V value = map.get(key);
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                value = map.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        if (value != null) hits.increment();
        return value;
    }

    public void put(K key, V value) {
        long stamp = lock.writeLock();
        try {
            map.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    public long getHits() {
        return hits.sum();
    }
}
  • Optimistic mode is used for reads: if nobody is writing — the read is almost free.
  • For writes — a write lock.
  • For counting hits — LongAdder: even under high contention increments don’t interfere with each other.

7. Profiling LongAdder vs AtomicLong under load

Test: 8 threads, 1 million increments each

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

public class CounterBenchmark {
    public static void main(String[] args) throws InterruptedException {
        int threads = 8;
        int increments = 1_000_000;

        // AtomicLong
        AtomicLong atomic = new AtomicLong();
        long start = System.nanoTime();
        Thread[] t1 = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            t1[i] = new Thread(() -> {
                for (int j = 0; j < increments; j++) atomic.incrementAndGet();
            });
            t1[i].start();
        }
        for (Thread t : t1) t.join();
        long timeAtomic = System.nanoTime() - start;

        // LongAdder
        LongAdder adder = new LongAdder();
        start = System.nanoTime();
        Thread[] t2 = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            t2[i] = new Thread(() -> {
                for (int j = 0; j < increments; j++) adder.increment();
            });
            t2[i].start();
        }
        for (Thread t : t2) t.join();
        long timeAdder = System.nanoTime() - start;

        System.out.printf("AtomicLong: %d ms, LongAdder: %d ms%n", timeAtomic / 1_000_000, timeAdder / 1_000_000);
    }
}

Typical result:

AtomicLong: 2500 ms, LongAdder: 200 ms

Conclusion: Under high contention, LongAdder is many times faster than AtomicLong.

8. Common mistakes when using StampedLock and LongAdder

Error No. 1: forgot to call unlockRead/unlockWrite. If you don’t release the stamp, other threads will wait forever. Always use try/finally!

Error No. 2: attempting reentrancy. StampedLock does not support re-entry. Do not acquire the lock twice from the same thread.

Error No. 3: incorrect use of validate. If you don’t check validate after tryOptimisticRead, you can get inconsistent data.

Error No. 4: using AtomicLong under high contention. AtomicLong is fine for 1–2 threads, but with 8+ threads it becomes a “bottleneck.” Use LongAdder.

Error No. 5: forgot about striped locks. If you implement your own striped lock, make sure threads are evenly distributed across stripes; otherwise, some stripes will be overloaded while others idle.

Error No. 6: expecting fairness from StampedLock. StampedLock does not guarantee the order of servicing threads. In rare cases, starvation is possible.

1
Task
JAVA 25 SELF, level 58, lesson 3
Locked
Flight Control Center: Altitude Caching System 🛰️
Flight Control Center: Altitude Caching System 🛰️
1
Task
JAVA 25 SELF, level 58, lesson 3
Locked
Drone Navigator: Optimistic Position Calculation 🚁
Drone Navigator: Optimistic Position Calculation 🚁
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION