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.
GO TO FULL VERSION