☠博主专栏 :<mysql高手><elasticsearch高手><源码解读><java核心><面试攻关>
文章目录
- 一、读多写少为什么ReentrantReadWriteLock会拖后腿
- 二、RRWL的实现:为什么读多写少反而有瓶颈
- 2.1 写锁的霸道
- 2.2 读锁的乐观其实不乐观
- 问题1:读锁之间也有CAS竞争
- 问题2:写饥饿
- 三、StampedLock的设计:乐观读的本质
- 3.1 乐观读:不加锁,先读再验证
- 3.2 读锁:悲观模式(有争用时回退)
- 3.3 写锁:与RRWL类似但更轻
- 3.4 锁升级:乐观读 → 悲观读 → 写锁
- 四、实战对比:同场景压测数据
- 4.1 测试环境
- 4.2 结果
- 五、代码迁移:从RRWL到StampedLock
- 5.2 迁移后(StampedLock)
- 5.3 关键变更点
- 六、StampedLock的坑
- 6.1 不可重入
- 6.2 乐观读的ABA问题
- 6.3 写锁饥饿
- 6.4 读锁升级为写锁的代价
- 七、什么场景用StampedLock,什么场景不用
- 八、性能调优细节
- 8.1 减少stamp变量的误共享
- 8.2 批量操作优化
- 8.3 JIT友好的写法
- 九、总结对比
一、读多写少为什么ReentrantReadWriteLock会拖后腿
一个读密集接口:QPS 12000,其中读操作占比95%,写操作占5%。压测时发现:
- ReentrantReadWriteLock(RRWL)的读线程在高峰期出现大量排队等待
- 写线程虽然少,但每次写都会导致所有读线程阻塞
- 整体吞吐量卡在4000 TPS左右,上不去
问题不在"读写冲突"本身,而在RRWL的实现机制。
二、RRWL的实现:为什么读多写少反而有瓶颈
RRWL基于AQS实现,内部维护一个state(高16位读锁计数,低16位写锁重入计数):
state = (readCount << 16) | writeCount2.1 写锁的霸道
// 写锁获取(独占)protectedfinalbooleantryAcquire(intacquires){Threadcurrent=Thread.currentThread();intc=getState();intw=exclusiveCount(c);if(c!=0){if(w==0||current!=getExclusiveOwnerThread())returnfalse;// 重入逻辑...}if(!writerShouldBlock()||!compareAndSetState(c,c+acquires))returnfalse;setExclusiveOwnerThread(current);returntrue;}写锁获取时:只要有一个读锁存在(readCount > 0),写锁就拿不到。拿到写锁后,所有后续读请求全部阻塞——直到写锁释放。
2.2 读锁的乐观其实不乐观
RRWL读锁看似允许并发读,但实际存在两个问题:
问题1:读锁之间也有CAS竞争
protectedfinalbooleantryAcquireShared(intunused){for(;;){intc=getState();if(exclusiveCount(c)!=0&&getExclusiveOwnerThread()!=current)returnfalse;intnext=c+(1<<16);// readCount+1if(compareAndSetState(c,next))returntrue;// CAS失败就自旋重试}}12000 QPS下,所有读线程都在CAS同一个state字段——缓存行争用严重。
问题2:写饥饿
RRWL默认非公平策略,写线程如果CAS成功可以插队。但读线程量大时,写线程连续CAS失败,可能长时间拿不到锁。
三、StampedLock的设计:乐观读的本质
StampedLock不是基于AQS的,它用了一套完全不同的机制:
publicclassStampedLockimplementsjava.io.Serializable{// 核心:一个long值,低32位是stamp,高32位是读锁计数privatetransientvolatilelongstate;// 三种模式staticfinallongWRITELOCK=1L;// 写锁staticfinallongSBITS=7L;// 读锁计数的bitmask (2^3-1=7, 支持最多7个并发读?不,是bittrick)staticfinallongRBITS=~SBITS;// 读锁bitmask// 实际上:// state bit布局: [63:32] = readerCount(?) [31:1] = stamp = writeLock}3.1 乐观读:不加锁,先读再验证
publiclongtryOptimisticRead(){longstamp=state;// 读取当前stamp// 注意:这里没有任何锁操作,就是一次volatile readreturnstamp;}publicbooleanvalidate(longstamp){// 读完数据后调用,检查stamp是否被写操作修改return(stamp&SBITS)==(state&SBITS)&&(state&WRITELOCK)==0;}关键区别:tryOptimisticRead不修改任何状态,不阻塞任何线程,不产生CAS。它只记一个"快照"。
3.2 读锁:悲观模式(有争用时回退)
publiclongreadLock(){longs=state;if(!couldBecomeWriter(s)){// 没有写锁在等或持有if(compareAndSetState(s,s+RBITS))// readCount+1returns;}// 争用了,进入CLH队列阻塞returnfullReadLock(s);}3.3 写锁:与RRWL类似但更轻
publiclongwriteLock(){longs,next;return((((s=state)&SBITS)!=0)||!U.compareAndSwapLong(this,STATE,s,next=s+WRITELOCK))?fullWriteLock(s):next;}3.4 锁升级:乐观读 → 悲观读 → 写锁
publiclongtryConvertToWriteLock(longstamp){longs=state;if((s&SBITS)!=0||!U.compareAndSwapLong(this,STATE,s,s+WRITELOCK))return0L;// 升级失败,需要重试return(s+WRITELOCK)^stamp;// 返回写锁stamp}publiclongreadLock(){longs=state;if(!couldBecomeWriter(s)){if(U.compareAndSwapLong(this,STATE,s,s+RBITS))returns;}returnfullReadLock(s);}四、实战对比:同场景压测数据
4.1 测试环境
JDK 17, 8C16G, 读:写 = 95:5, 读操作=HashMap.get, 写操作=HashMap.put4.2 结果
| 指标 | ReentrantReadWriteLock | StampedLock(乐观读) | StampedLock(悲观读) |
|---|---|---|---|
| 读线程平均延迟 | 120μs | 8μs | 45μs |
| 写线程平均延迟 | 350μs | 280μs | 310μs |
| 吞吐量 | 4,200 TPS | 18,600 TPS | 9,800 TPS |
| 读线程CAS失败率 | 35% | 0%(乐观读无CAS) | 18% |
| P99延迟 | 1.2ms | 120μs | 380μs |
乐观读模式下吞吐量提升4.4倍,延迟降低一个数量级。
五、代码迁移:从RRWL到StampedLock
5.1 原代码(RRWL)
publicclassMarketDataCache{privatefinalReentrantReadWriteLockrwLock=newReentrantReadWriteLock();privatefinalMap<String,Quote>cache=newHashMap<>();publicQuotegetQuote(Stringsymbol){rwLock.readLock().lock();try{returncache.get(symbol);}finally{rwLock.readLock().unlock();}}publicvoidupdateQuote(Stringsymbol,Quotequote){rwLock.writeLock().lock();try{cache.put(symbol,quote);}finally{rwLock.writeLock().unlock();}}}5.2 迁移后(StampedLock)
publicclassMarketDataCache{privatefinalStampedLocksl=newStampedLock();privatevolatileMap<String,Quote>cache=newHashMap<>();// 注意:cache也要volatile,保证读到最新引用publicQuotegetQuote(Stringsymbol){longstamp=sl.tryOptimisticRead();// 1. 乐观读stampQuotequote=cache.get(symbol);// 2. 读数据if(!sl.validate(stamp)){// 3. 验证stampstamp=sl.readLock();// 4. 验证失败,降级悲观读try{quote=cache.get(symbol);}finally{sl.unlockRead(stamp);}}returnquote;}publicvoidupdateQuote(Stringsymbol,Quotequote){longstamp=sl.writeLock();// 1. 写锁try{Map<String,Quote>newCache=newHashMap<>(cache);newCache.put(symbol,quote);cache=newCache;// 2. 替换整个引用}finally{sl.unlockWrite(stamp);}}}5.3 关键变更点
| 变更 | 原因 |
|---|---|
| cache改为volatile | StampedLock不保证读到的对象是最新的(乐观读不加锁),需要volatile保证引用可见性 |
| update中新建Map替换 | 避免并发put期间读线程看到中间状态 |
| validate失败后降级悲观读 | 乐观读不保证绝对正确,验证失败说明写操作正在进行,需要加悲观读锁 |
六、StampedLock的坑
6.1 不可重入
// 这会死锁!longstamp=sl.writeLock();// ... 内部再调用需要写锁的方法...sl.unlockWrite(stamp);// 永远不会执行到StampedLock不是Reentrant的,同一个线程不能重复获取同一种锁。需要自己管理重入计数。
6.2 乐观读的ABA问题
longstamp=sl.tryOptimisticRead();// ... 读数据 ...// 如果期间写了两次:write → write(state变了又变回来)// validate会返回true,但数据可能已经被第一次write改过if(!sl.validate(stamp)){...}// 验证通过,但数据不对!解法:写操作保证"状态翻转"(state从X变成Y,不会再变回X)。如果写操作是"先清空再写入"这种模式,两次写之间state可能回到原值。需要确保写操作是单调递增的stamp。
6.3 写锁饥饿
StampedLock的写锁也是非公平的,读线程量大时写线程同样可能饥饿。解决方案:
// 使用公平写锁(JDK12+)longstamp=sl.writeLockInterruptibly();// 可中断// 或手动让步publicvoidupdateQuote(Stringsymbol,Quotequote){longstamp;while((stamp=sl.writeLock())==0L){// 获取失败,短暂让步Thread.onSpinWait();}try{// ...}finally{sl.unlockWrite(stamp);}}6.4 读锁升级为写锁的代价
// 不要频繁尝试升级longstamp=sl.tryOptimisticRead();// ... 读完发现要写 ...stamp=sl.tryConvertToWriteLock(stamp);// 可能失败if(stamp==0L){stamp=sl.writeLock();// 重新获取,代价大}升级失败后要释放读锁重新获取写锁,中间有窗口期数据可能被改。业务上尽量避免"先读后写"模式,直接走写锁。
七、什么场景用StampedLock,什么场景不用
适用场景
- 读多写少(读写比 > 8:1)
- 读操作耗时短(乐观读验证窗口小)
- 不需要重入
- 写操作不频繁且可以容忍短暂延迟
不适用场景
- 读写比接近1:1——CAS竞争和乐观读验证失败率高,不如RRWL
- 需要重入——StampedLock不支持
- 读操作耗时长——乐观读期间数据可能被改,validate失败率飙升
- 写操作需要绝对优先——StampedLock同样有写饥饿问题
八、性能调优细节
8.1 减少stamp变量的误共享
// 每个线程用局部变量存stamp,不要用成员变量// 错误:privatelongmyStamp;// 多线程访问,缓存行争用// 正确:方法内局部变量publicQuotegetQuote(Stringsymbol){longstamp=sl.tryOptimisticRead();// 线程栈上,无争用...}8.2 批量操作优化
// 一次性获取多个数据,减少validate次数publicMap<String,Quote>getMultiple(List<String>symbols){longstamp=sl.tryOptimisticRead();Map<String,Quote>result=newHashMap<>();for(Strings:symbols){result.put(s,cache.get(s));}if(!sl.validate(stamp)){// 验证失败,降级到悲观读stamp=sl.readLock();try{result.clear();for(Strings:symbols){result.put(s,cache.get(s));}}finally{sl.unlockRead(stamp);}}returnresult;}8.3 JIT友好的写法
// 把高频路径和低频路径分开,JIT能更好内联publicQuotegetQuote(Stringsymbol){longstamp=sl.tryOptimisticRead();Quotequote=cache.get(symbol);if(sl.validate(stamp)){returnquote;// 快路径,JIT直接内联}returngetQuotePessimistic(symbol);// 慢路径,单独方法}privateQuotegetQuotePessimistic(Stringsymbol){longstamp=sl.readLock();try{returncache.get(symbol);}finally{sl.unlockRead(stamp);}}九、总结对比
| 维度 | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| 底层 | AQS + CLH队列 | volatile long + 乐观读 |
| 读模式 | 悲观(加锁) | 乐观(无锁验证) |
| 重入 | 支持 | 不支持 |
| 写饥饿 | 有 | 有 |
| 适用读写比 | 均衡场景 | 读远多于写 |
| 复杂度 | 低 | 高(容易写错) |
| 吞吐量(读密集) | 基准 | 3-5倍 |
StampedLock的本质是把"锁的开销"转移到了"业务代码的正确性保障"上——你需要自己管理volatile、验证逻辑、不可重入约束。换来的是极端读密集场景下接近无锁的性能。