我用StampedLock解决了读多写少场景下ReentrantReadWriteLock的性能瓶颈
2026/9/7 19:21:35 网站建设 项目流程
❃博主首页 :「程序员1970」,同名公众号「程序员1970」
☠博主专栏 :<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) | writeCount
2.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.put
4.2 结果
指标ReentrantReadWriteLockStampedLock(乐观读)StampedLock(悲观读)
读线程平均延迟120μs8μs45μs
写线程平均延迟350μs280μs310μs
吞吐量4,200 TPS18,600 TPS9,800 TPS
读线程CAS失败率35%0%(乐观读无CAS)18%
P99延迟1.2ms120μs380μ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改为volatileStampedLock不保证读到的对象是最新的(乐观读不加锁),需要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);}}

九、总结对比

维度ReentrantReadWriteLockStampedLock
底层AQS + CLH队列volatile long + 乐观读
读模式悲观(加锁)乐观(无锁验证)
重入支持不支持
写饥饿
适用读写比均衡场景读远多于写
复杂度高(容易写错)
吞吐量(读密集)基准3-5倍

StampedLock的本质是把"锁的开销"转移到了"业务代码的正确性保障"上——你需要自己管理volatile、验证逻辑、不可重入约束。换来的是极端读密集场景下接近无锁的性能。


关注技术号获取更多技术干货 !

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询