StampedLock乐观读:比ReadWriteLock更高效的新型锁
StampedLock乐观读:比ReadWriteLock更高效的新型锁
适读人群:使用过ReadWriteLock、关心读多写少场景性能的Java工程师 | 阅读时长:约15分钟
开篇故事
2022年,我们有个地理位置服务,存储了城市坐标数据(经纬度)。读操作频率极高(每秒几十万次,客户端实时查询附近门店),写操作很少(城市开新店时更新,每天几十次)。
原来用的是ReadWriteLock,读锁共享,写锁独占。但压测时发现,即使是读锁,在极高并发下(几千个并发读),锁竞争依然不小——几千个线程同时readLock.lock(),AQS的队列管理开销累积起来也相当可观,P99在峰值时有明显抖动。
同事推荐了JDK 8新增的StampedLock,特别是它的乐观读模式。
换上去之后,相同并发下P99从3ms降到了0.8ms。原理其实很简单:乐观读不获取任何锁,直接读数据,读完之后验证一下数据是否被写操作修改过。如果没有修改(绝大多数情况),读操作完全没有锁的开销。
今天把StampedLock的三种模式和底层原理讲清楚。
一、StampedLock的三种模式
1.1 读写锁(悲观读)
类似ReadWriteLock,但用时间戳(stamp)管理:
long stamp = lock.readLock(); // 获取悲观读锁
try {
read();
} finally {
lock.unlockRead(stamp);
}
long stamp = lock.writeLock(); // 获取写锁
try {
write();
} finally {
lock.unlockWrite(stamp);
}1.2 乐观读(核心创新)
乐观读不获取任何锁,只取一个版本号(stamp):
long stamp = lock.tryOptimisticRead(); // 获取stamp(不加锁!)
// 读数据
if (!lock.validate(stamp)) {
// 期间有写操作,数据可能不一致,升级为悲观读
stamp = lock.readLock();
try { read(); } finally { lock.unlockRead(stamp); }
}validate(stamp)检查从取stamp到现在,是否有写操作发生。如果没有,数据一致,可以使用;如果有,降级为悲观读重新获取。
1.3 锁转换
// 悲观读→写锁(如果可以升级则成功,否则返回0)
long newStamp = lock.tryConvertToWriteLock(stamp);
// 写锁→悲观读锁(降级)
long readStamp = lock.tryConvertToReadLock(writeStamp);二、StampedLock vs ReadWriteLock
2.1 性能差异分析
ReadWriteLock(AQS实现)的悲观读:
- 每次
readLock()需要CAS修改AQS的state - 高并发读时,多个线程CAS同一个state(缓存行竞争)
- 读线程越多,竞争越激烈
StampedLock乐观读:
tryOptimisticRead()只是读取一个volatile的stamp值(极低开销)validate(stamp)同样只是一次volatile读比较- 不修改任何共享状态,多线程读不产生任何竞争
基准测试结果(JMH,JDK 11,16核机器,16读线程,读:写=100:1):
- ReadWriteLock悲观读:约120ns/op
- StampedLock悲观读:约85ns/op
- StampedLock乐观读(无写竞争):约15ns/op(约8倍提速!)
2.2 StampedLock的限制
- 不支持重入:同一个线程重复
writeLock()会死锁(与ReentrantLock不同) - 不支持Condition:没有等待/通知机制
- 不支持中断:
readLock()/writeLock()不响应中断(需要用readLockInterruptibly()) - 不公平:写锁可能饥饿(写等待时,新的乐观读还可以继续)
三、完整代码实现
3.1 乐观读的完整使用示例
package com.laozhang.concurrent.lock;
import java.util.concurrent.locks.StampedLock;
/**
* StampedLock乐观读实战:地理坐标缓存
*
* 场景:
* - 地图服务,存储各门店的经纬度坐标
* - 读操作:用户查询附近门店(高频)
* - 写操作:运营人员更新门店位置(低频)
*
* 测试环境:JDK 8+(StampedLock是JDK 8新增的)
*/
public class GeoLocationCache {
// 经纬度坐标(多个字段需要保持一致性,不能用volatile)
private double latitude;
private double longitude;
private final StampedLock lock = new StampedLock();
public GeoLocationCache(double lat, double lon) {
this.latitude = lat;
this.longitude = lon;
}
/**
* 乐观读:读取坐标
* 典型的乐观读模板代码:
* 1. 取stamp
* 2. 读数据到本地变量
* 3. 验证stamp
* 4. 验证失败则升级为悲观读
*/
public double[] getLocation() {
// 步骤1:乐观读,获取stamp(不加锁!)
long stamp = lock.tryOptimisticRead();
// 步骤2:读取数据到本地变量
// 重要:必须先读到局部变量,再validate!
// 如果直接使用字段,validate后字段可能已经被修改
double lat = latitude;
double lon = longitude;
// 步骤3:验证stamp(期间是否有写操作)
if (!lock.validate(stamp)) {
// 步骤4:验证失败,升级为悲观读锁
stamp = lock.readLock();
try {
lat = latitude;
lon = longitude;
} finally {
lock.unlockRead(stamp);
}
}
// 验证通过,lat和lon是一致的
return new double[]{lat, lon};
}
/**
* 悲观读:当需要读取多个相关字段时(同乐观读,但始终持锁)
*/
public double[] getLocationPessimistic() {
long stamp = lock.readLock();
try {
return new double[]{latitude, longitude};
} finally {
lock.unlockRead(stamp);
}
}
/**
* 写操作:更新坐标
*/
public void updateLocation(double lat, double lon) {
long stamp = lock.writeLock();
try {
this.latitude = lat;
this.longitude = lon;
} finally {
lock.unlockWrite(stamp);
}
}
/**
* 条件写:只有当前位置偏差较大时才更新(先乐观读,再决定是否写)
*/
public boolean updateIfSignificantChange(double newLat, double newLon,
double threshold) {
// 先乐观读,避免每次都加写锁
long stamp = lock.tryOptimisticRead();
double oldLat = latitude;
double oldLon = longitude;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
oldLat = latitude;
oldLon = longitude;
} finally {
lock.unlockRead(stamp);
}
}
double distance = Math.sqrt(
Math.pow(newLat - oldLat, 2) + Math.pow(newLon - oldLon, 2));
if (distance < threshold) {
return false; // 变化不大,不更新
}
// 锁转换:尝试升级为写锁
long writeStamp = lock.tryConvertToWriteLock(stamp);
if (writeStamp != 0) {
// 成功升级(这里stamp是readLock情况下才能转换)
stamp = writeStamp;
} else {
// 升级失败,手动获取写锁
stamp = lock.writeLock();
}
try {
latitude = newLat;
longitude = newLon;
return true;
} finally {
lock.unlockWrite(stamp);
}
}
public static void main(String[] args) throws InterruptedException {
GeoLocationCache cache = new GeoLocationCache(39.9042, 116.4074); // 北京
// 模拟高并发读
Thread[] readers = new Thread[50];
long[] readCount = {0};
long start = System.currentTimeMillis();
for (int i = 0; i < 50; i++) {
readers[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
double[] loc = cache.getLocation();
synchronized (readCount) { readCount[0]++; }
}
});
readers[i].start();
}
// 模拟低频写
Thread writer = new Thread(() -> {
for (int i = 0; i < 10; i++) {
try { Thread.sleep(100); } catch (InterruptedException e) { return; }
cache.updateLocation(39.9042 + i * 0.001, 116.4074 + i * 0.001);
System.out.println("写操作:更新位置 #" + i);
}
});
writer.start();
for (Thread r : readers) r.join();
writer.join();
long elapsed = System.currentTimeMillis() - start;
System.out.printf("总读次数:%d,耗时:%dms,吞吐:%.0f次/s%n",
readCount[0], elapsed, readCount[0] * 1000.0 / elapsed);
}
}3.2 性能对比:StampedLock vs ReadWriteLock
package com.laozhang.concurrent.lock;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.StampedLock;
/**
* StampedLock vs ReadWriteLock性能对比
*
* 测试:16读线程,1写线程,各执行100万次操作
* 测试环境:JDK 11,8核机器
*
* 预期结果(读:写=100:1的场景):
* ReadWriteLock悲观读:约800ms
* StampedLock悲观读:约600ms
* StampedLock乐观读:约150ms
*/
public class LockPerformanceComparison {
private double x = 1.0, y = 2.0;
private static final int READER_COUNT = 16;
private static final int OPS = 1_000_000;
private long testRWL() throws InterruptedException {
ReadWriteLock rwl = new ReentrantReadWriteLock();
CountDownLatch latch = new CountDownLatch(READER_COUNT + 1);
long start = System.currentTimeMillis();
for (int i = 0; i < READER_COUNT; i++) {
new Thread(() -> {
for (int j = 0; j < OPS; j++) {
rwl.readLock().lock();
try { double val = x + y; }
finally { rwl.readLock().unlock(); }
}
latch.countDown();
}).start();
}
new Thread(() -> {
for (int j = 0; j < OPS / 100; j++) { // 写:读 = 1:100
rwl.writeLock().lock();
try { x += 1; y += 1; }
finally { rwl.writeLock().unlock(); }
}
latch.countDown();
}).start();
latch.await();
return System.currentTimeMillis() - start;
}
private long testStampedOptimistic() throws InterruptedException {
StampedLock sl = new StampedLock();
CountDownLatch latch = new CountDownLatch(READER_COUNT + 1);
long start = System.currentTimeMillis();
for (int i = 0; i < READER_COUNT; i++) {
new Thread(() -> {
for (int j = 0; j < OPS; j++) {
long stamp = sl.tryOptimisticRead();
double localX = x;
double localY = y;
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try { localX = x; localY = y; }
finally { sl.unlockRead(stamp); }
}
double val = localX + localY;
}
latch.countDown();
}).start();
}
new Thread(() -> {
for (int j = 0; j < OPS / 100; j++) {
long stamp = sl.writeLock();
try { x += 1; y += 1; }
finally { sl.unlockWrite(stamp); }
}
latch.countDown();
}).start();
latch.await();
return System.currentTimeMillis() - start;
}
public static void main(String[] args) throws InterruptedException {
LockPerformanceComparison test = new LockPerformanceComparison();
// 预热
test.testRWL();
test.testStampedOptimistic();
Thread.sleep(2000);
long rwlTime = test.testRWL();
Thread.sleep(1000);
long stampedTime = test.testStampedOptimistic();
System.out.printf("ReadWriteLock:%dms%n", rwlTime);
System.out.printf("StampedLock乐观读:%dms%n", stampedTime);
System.out.printf("性能提升:%.1f倍%n", (double) rwlTime / stampedTime);
}
}四、踩坑实录
坑1:乐观读后没有validate,读到不一致数据
报错现象: 用乐观读读取两个字段(经纬度),偶发出现经度是新值但纬度是旧值的情况,计算出错误的距离。
原因分析: 乐观读不加锁,读取期间写操作可能修改其中一个字段。如果在读latitude和longitude之间发生了写操作,读到的是一个"混合版本",两个字段是不一致的。
必须在使用数据前调用validate(stamp),失败时降级到悲观读重新读取。
解法: 乐观读的正确模板(不可省略validate):
long stamp = lock.tryOptimisticRead();
// 读所有需要保持一致的字段到本地变量
double lat = this.lat;
double lon = this.lon;
// 必须validate!
if (!lock.validate(stamp)) {
// 降级
}
// 只有validate通过才能用lat和lon坑2:StampedLock不可重入,重复加锁死锁
报错现象: 某个方法内部调用了另一个也使用StampedLock的方法,两次都加写锁,第二次永远无法获取,线程死锁。
原因分析: StampedLock是不可重入的。同一个线程如果持有写锁,再次调用writeLock()会死锁(等待自己释放的锁)。
long s1 = lock.writeLock();
try {
long s2 = lock.writeLock(); // 死锁!等待s1被释放,但s1由当前线程持有
try { ... } finally { lock.unlockWrite(s2); }
} finally { lock.unlockWrite(s1); }解法:
- 避免同一线程重复加锁
- 用锁转换(
tryConvertToWriteLock)替代重复加写锁 - 如果必须重入,改用
ReentrantReadWriteLock
坑3:stamp必须来自同一个StampedLock实例
报错现象: 代码报错或validate总是返回false,排查发现是把一个StampedLock的stamp传给了另一个StampedLock的validate。
原因分析: stamp是StampedLock内部的版本号,只对发出它的那个StampedLock有意义。不同实例的stamp没有任何关联。
解法: 确保stamp的获取和使用在同一个StampedLock实例上。
坑4:tryOptimisticRead()返回0时不能使用
报错现象: lock.tryOptimisticRead()返回0,代码没有判断直接使用,读到了错误数据。
原因分析: 如果StampedLock当前处于写锁状态(写锁已被获取),tryOptimisticRead()返回0。此时不能乐观读,必须等写锁释放。
long stamp = lock.tryOptimisticRead();
if (stamp == 0) {
// 当前有写锁,无法乐观读,降级到悲观读
stamp = lock.readLock();
try { ... } finally { lock.unlockRead(stamp); }
return;
}
// stamp != 0,继续乐观读流程解法: 检查tryOptimisticRead()返回值,为0时直接降级为悲观读。
五、总结与延伸
StampedLock vs ReadWriteLock选型:
| 场景 | 推荐 |
|---|---|
| 读多写少,读操作极度频繁 | StampedLock乐观读 |
| 需要重入锁 | ReentrantReadWriteLock |
| 需要Condition | ReentrantReadWriteLock |
| 需要公平锁 | ReentrantReadWriteLock(true) |
| 需要可中断等待 | ReentrantReadWriteLock |
| 简单读写分离 | ReadWriteLock更安全(不易误用) |
StampedLock乐观读的适用场景:
- 读操作耗时极短(几十纳秒级别),减少持锁时间
- 写操作极少(写冲突概率低),validate失败率低
- 不需要重入,不需要Condition
JDK 17的改进(ProjectLoom预期): StampedLock在虚拟线程场景下可能有特殊行为,writeLock()阻塞虚拟线程时会固定平台线程,需要注意。
