1. 为什么 ReadWriteLock 并不总是够用
写入竞争高的问题
ReadWriteLock(最常见的是 ReentrantReadWriteLock)在大多数线程只是读取、写入者很少时效果很好。在这种情况下,多个线程可以同时读取,而写入只会在短时间内阻塞访问。
问题在于,当写入的线程比预期更多,或它们频繁在读与写之间切换时,如果写操作耗时较长,线程会更久地等待锁的释放。结果是对数据的竞争加剧,读写都变慢,ReadWriteLock 的效率下降。
昂贵的锁模式切换
当线程从读取模式切换到写入模式(或反之)时,底层会进行复杂的检查:必须确保没有其它线程在写入、所有读者已退出,才允许写入。
如果这样的切换很频繁,就会产生延迟。线程开始排队,性能下降——尤其是在同时存在大量读写操作时。
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
// 读取
readLock.lock();
try {
// 读取数据
} finally {
readLock.unlock();
}
// 写入
writeLock.lock();
try {
// 修改数据
} finally {
writeLock.unlock();
}
每当线程在 readLock 与 writeLock 之间切换时,系统都会执行所有检查以避免冲突。若切换过多——代价很高。
2. StampedLock:现代同步方式
StampedLock 是 Java 8 引入的现代同步机制。它融合了 ReadWriteLock 的思想,但新增了乐观读模式,并且不是用传统的锁对象,而是使用“戳记”(stamps)——需要显式释放的特殊令牌。
关键特性:
- 三种模式:write lock(独占写)、read lock(共享读)、optimistic read(无锁的乐观读)。
- 不支持可重入:同一线程不能重复进入同一把锁。
- 在读多写少时具有很高的性能。
- 需要对戳记(stamp)进行显式管理。
乐观读:tryOptimisticRead + validate
乐观读是一种不加任何锁就去读取数据的模式,假定此时没有线程在写。读取结束后必须通过 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;
// 检查读取期间是否发生写入
if (!lock.validate(stamp)) {
// 如果发生了写入——获取常规的读锁
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.hypot(currentX, currentY);
}
}
工作原理:
- tryOptimisticRead() 返回一个戳记(long),只要没有写入,它就“有效”。
- 读取 x 和 y 的值。
- validate(stamp) 检查从开始读到结束读之间是否发生过写入。
- 如果一切正常——使用已读取的值;如果发生过写入——获取常规的 readLock 并重新读取。
当写入极少而读取很多时,这非常有利。多数情况下 validate 会返回 true,读取几乎是免费的。
“读多写少”的场景
- 数据很少变化,但经常被读取(例如缓存、坐标、元数据)。
- 需要尽量降低读取延迟。
- 一旦发生写入,可以接受“重读”。
回退到读锁
如果乐观读失败(validate 返回 false),线程会回退到常规的 readLock。这确保了数据正确性,但通常较少发生。
3. StampedLock 的坑点
不支持可重入
与 ReentrantReadWriteLock 不同,StampedLock 不支持重复进入。如果线程已经持有锁仍尝试再次获取——会发生死锁。
long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! 线程在等待自己
对中断的敏感性
StampedLock 对线程中断的响应不同于传统锁。如果线程在等待获取锁时被中断,它并不总能立刻“醒来”。在需要快速响应中断的任务中,请使用其他机制。
正确释放戳记
每次调用 readLock()、writeLock() 或 tryOptimisticRead() 都会返回一个唯一的戳记(long)。必须将它传入对应的解锁方法:
- unlockRead(stamp)
- unlockWrite(stamp)
错误: 如果混用戳记或忘记调用解锁——会导致锁泄漏并使程序卡死。
4. 与 ReentrantReadWriteLock 的对比
| 特性 | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| 可重入(重复进入) | 是 | 否 |
| 乐观读 | 否 | 是 |
| 高竞争下的性能 | 中等 | 高(写入很少时) |
| 显式锁管理 | 否(自动) | 是(戳记) |
| 对中断的响应 | 是 | 不总是 |
| 公平性(fairness) | 是(可开启) | 否 |
公平模式与对饥饿的影响
- 在 ReentrantReadWriteLock 中可以开启“公平”模式(fair),让线程按队列顺序被唤醒与处理。这可以避免饥饿(starvation)。
- StampedLock 不提供公平性:如果其他线程一直“抢到”锁,某些线程可能等待更久,存在少见的饥饿可能。
5. 计数器:LongAdder/LongAccumulator vs AtomicLong
高竞争下 AtomicLong 的问题
AtomicLong 是保证线程安全自增的原子变量。但当许多线程同时调用 incrementAndGet() 时,大家都在“争”同一个变量,导致性能下降。
LongAdder:分段(striped)计数器
LongAdder 以另一种方式解决问题:把计数器拆成多个“段”(stripe),每个段服务于一组线程。线程只增加其中一个段,Final value是所有段的求和。
优势:
- 在高竞争下,线程几乎互不干扰。
- 性能远高于 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 是 LongAdder 的泛化版本,可以自定义累积函数(例如Max value、最小值等)。
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("Max value: " + max.get()); // 42
分段(striped)锁与降低竞争
striped locks 技术将共享资源划分为多个独立部分(段,stripes),每部分由各自的锁或变量保护。线程均匀分布到各段,从而降低竞争、提升性能。LongAdder 与 LongAccumulator 正是采用了这一思路。
6. 实践:读取占优的缓存
任务:我们有一张映射(Map)存放数据以及元数据(例如访问计数器)。读取频繁,写入稀少。
基于 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();
}
}
- 读取使用乐观模式:若无人写入——读取几乎免费。
- 写入使用 write lock。
- 访问计数使用 LongAdder:即便在高竞争下自增也互不干扰。
7. 负载下 LongAdder vs AtomicLong 的评测
测试:8 个线程,每个 100 万次递增
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);
}
}
典型结果:
AtomicLong: 2500 ms, LongAdder: 200 ms
结论:在高竞争下,LongAdder 比 AtomicLong 快数倍。
8. 使用 StampedLock 与 LongAdder 的常见错误
错误 1:忘记调用 unlockRead/unlockWrite。 如果不释放戳记,其他线程会一直等待。务必使用 try/finally!
错误 2:尝试可重入。 StampedLock 不支持重复进入。不要在同一线程中二次获取同一把锁。
错误 3:错误使用 validate。 如果在 tryOptimisticRead 之后不检查 validate,可能得到不一致的数据。
错误 4:在高竞争下仍使用 AtomicLong。 AtomicLong 适用于 1–2 个线程,但在 8+ 个线程时会成为“瓶颈”。请使用 LongAdder。
错误 5:忽视分段锁。 如果你实现自己的 striped-lock,务必确保线程均匀分布到各段,否则某些段会过载而另一些段空闲。
错误 6:期望 StampedLock 提供公平性。 StampedLock 不保证线程服务顺序。极少数情况下可能发生饥饿。
GO TO FULL VERSION