Java StampedLock 实战:乐观读比 ReadWriteLock 快在哪、锁升级与三个坑

发布时间:2026/8/30 22:39:11
Java StampedLock 实战:乐观读比 ReadWriteLock 快在哪、锁升级与三个坑 Java StampedLock 实战:乐观读比 ReadWriteLock 快在哪、锁升级与三个坑读多写少的场景,很多人第一反应是ReentrantReadWriteLock。但当读并发很高时,你会发现它并没有想象中快——每个读线程都要真实地拿一次读锁、改一次锁状态(CAS),线程一多,这个共享的锁状态本身就成了竞争热点。JDK 8 引入的StampedLock给了第三条路:乐观读——读的时候压根不加锁,先读完再回头验一下「刚才有没有人写过」。这篇讲清楚它快在哪、怎么用、以及三个能让程序直接死锁或读到脏数据的坑。先看 ReadWriteLock 的瓶颈ReentrantReadWriteLock的读锁虽然「共享」,但获取读锁本身是有代价的:所有读线程要去改同一个 AQS 的 state。读线程越多,这个 CAS 竞争越激烈,缓存行在核心间来回弹跳(false sharing 的近亲)。也就是说,读锁不阻塞读锁,但「拿锁」这个动作本身在高并发下并不便宜。StampedLock的乐观读直接跳过了「拿锁」:读的时候只拿一个版本号(stamp),读完再比对版本号有没有变。没人写过,这次读就是零竞争的纯内存读取。乐观读的标准写法(务必照抄这个模板)乐观读有个固定套路,少一步都可能读到脏数据。以一个二维坐标点为例:importjava.util.concurrent.locks.StampedLock;classPoint{privatedoublex,y;privatefinalStampedLockslnewStampedLock();voidmove(doubledx,doubledy){longstampsl.writeLock();// 写:独占锁try{xdx;ydy;}finally{sl.unlockWrite(stamp);// 用拿锁时的 stamp 解锁}}doubledistanceFromOrigin(){longstampsl.tryOptimisticRead();// 1) 拿一个乐观读版本号,不加锁doublecurXx,curYy;// 2) 把要用的字段全读进局部变量if(!sl.validate(stamp)){// 3) 验证:期间有没有人拿过写锁stampsl.readLock();// 4) 验证失败 - 降级为真正的读锁重读try{curXx;curYy;}finally{sl.unlockRead(stamp);}}returnMath.sqrt(curX*curXcurY*curY);// 用局部变量算,别再读字段}}关键三步:tryOptimisticRead()→ 读进局部变量→validate(stamp)。这个模板里有两个魔鬼细节,下面的坑一和坑二会展开。坑一:必须先把字段拷进局部变量,再 validate初学者常写成「先 validate 通过,再去读x、y」——这是错的。乐观读的逻辑是「我先读了一份快照,再验证这份快照期间没被写过」。如果你先验证再读,验证和读之间又可能插进一次写,快照就脏了。正确顺序永远是:先把所有要用的字段读进局部变量,再validate。而且验证通过后,后续计算只能用局部变量curX/curY,不能再回头读this.x——否则等于又开了一个没保护的读窗口。// 错误:先验证后读,验证到读之间的写会漏掉if(sl.validate(stamp)){returnMath.sqrt(x*xy*y);// x/y 可能已被改,脏读}// 正确:先读快照进局部变量,再验证这份快照doublecurXx,curYy;if(sl.validate(stamp)){returnMath.sqrt(curX*curXcurY*curY);}坑二:StampedLock 不可重入,重入直接死锁这是最致命的一条。ReentrantReadWriteLock名字里带 Reentrant,同一线程可以重复拿。StampedLock完全不可重入——同一个线程已经持有写锁,再调一次writeLock()会永远阻塞在那,自己把自己锁死。longs1sl.writeLock();longs2sl.writeLock();// 死锁!当前线程已持写锁,这里永久阻塞所以用StampedLock时,持锁方法里绝不能再调用另一个也会拿同一把锁的方法。写代码时要盯紧调用链,别让一个加锁方法间接又走回另一个加锁方法。坑三:锁升级要用 tryConvertToWriteLock,别先解读锁再拿写锁有时读到一半发现需要改数据,要从读锁「升级」成写锁。天真写法是先unlockRead再writeLock,但这中间有个空档,别的线程可能抢先改掉数据,你的判断就作废了。正确做法是用tryConvertToWriteLock原子升级:voidmoveIfAtOrigin(doublenewX,doublenewY){longstampsl.readLock();try{while(x0.0y0.0){// 尝试把读锁原子升级为写锁,成功返回新 stamp,失败返回 0longwssl.tryConvertToWriteLock(stamp);if(ws!0L){stampws;// 升级成功,更新 stampxnewX;ynewY;break;}else{// 升级失败:老实释放读锁,拿写锁重来sl.unlockRead(stamp);stampsl.writeLock();}}}finally{sl.unlock(stamp);// unlock 能同时处理读/写 stamp,省心}}升级失败(返回 0)时不要硬等,按上面那样退回去重拿写锁再循环判断,才是安全的。什么时候别用 StampedLock它不是银弹,以下场景老实用别的:需要重入:回到ReentrantReadWriteLock。需要条件变量(Condition):StampedLock不支持Condition,要 await/signal 就别选它。读操作很重、很慢:乐观读一旦validate失败就要整段重读,读越重浪费越大,不如直接读锁。写很频繁:乐观读几乎必然验证失败,退化成读锁还多绕一圈,得不偿失。乐观读的红利只在读远多于写时才有。小结StampedLock的乐观读在「读多写少 读操作轻量」时明显快过ReadWriteLock,因为读路径零加锁、零竞争。乐观读铁三步:tryOptimisticRead()→字段读进局部变量→validate(stamp);验证后只用局部变量。三个坑:先读快照再验证(顺序不能反)、不可重入(重入即死锁)、锁升级用tryConvertToWriteLock原子做。需要重入、Condition,或读很重/写很频繁,就别用它。一句话记忆:乐观读是「先斩后奏」——先读快照再回头验版本;但它不认人(不可重入),持锁时千万别再叫它一次。