Java高并发底层原理(二十)—— ReentrantReadWriteLock 与 StampedLock
一、从互斥锁到读写锁普通互斥锁解决的是“同一时刻只能有一个线程进入临界区”的问题。无论临界区里是读取数据还是修改数据只要一个线程持有锁其他线程都必须等待。以一个二维坐标对象为例classPoint{privatedoublex;privatedoubley;doubledistanceFromOrigin(){returnMath.sqrt(x*xy*y);}voidmove(doubledeltaX,doubledeltaY){xdeltaX;ydeltaY;}}distanceFromOrigin()只是读取x和ymove()才会修改它们。如果所有方法都用同一把互斥锁保护那么多个读线程之间也会互相阻塞。但从语义上看多个线程同时读并不会破坏x / y的一致性真正需要互斥的是读和写、写和写。读写锁就是在这个问题上继续细分锁语义读操作之间可以共享写操作仍然独占。操作关系是否可以并发原因读 读可以多个线程只读取数据不修改共享状态读 写不可以写线程可能改变读线程正在读取的数据写 写不可以多个写线程同时修改会破坏一致性ReentrantReadWriteLock对外暴露读锁和写锁两个入口privatefinalReentrantReadWriteLockrwnewReentrantReadWriteLock();privatefinalLockreadLockrw.readLock();privatefinalLockwriteLockrw.writeLock();改造后的Point可以写成classPoint{privatedoublex;privatedoubley;privatefinalReentrantReadWriteLockrwnewReentrantReadWriteLock();privatefinalLockreadLockrw.readLock();privatefinalLockwriteLockrw.writeLock();doubledistanceFromOrigin(){readLock.lock();try{returnMath.sqrt(x*xy*y);}finally{readLock.unlock();}}voidmove(doubledeltaX,doubledeltaY){writeLock.lock();try{xdeltaX;ydeltaY;}finally{writeLock.unlock();}}}这段代码的重点不是 API 本身而是锁语义发生了变化读方法不再和其他读方法互斥但仍然会和写方法互斥。二、两个锁入口为什么要共用同一份状态ReentrantReadWriteLock表面上有两个锁ReadLock和WriteLock。但它们不能真的各管各的因为读锁和写锁之间必须互相感知。读线程申请读锁时需要知道当前有没有写线程正在写写线程申请写锁时需要知道当前有没有读线程正在读以及有没有其他写线程正在写。因此读锁和写锁背后必须共享同一份同步状态。可以把ReadLock和WriteLock理解成两个入口把Sync理解成统一的状态管理者。线程从不同入口进入但最终都要根据同一份状态判断能不能获取锁。这个设计和普通互斥锁不同。普通ReentrantLock只需要表达一种独占语义state 0表示没有线程持有锁state 0表示锁被某个线程持有并可能发生重入。而读写锁要同时表达读锁数量和写锁重入次数所以它需要在同一个状态值里编码两类信息。三、state 如何同时表示读锁和写锁AQS 中的核心状态是一个intprivatevolatileintstate;state不是对象也不保存线程对象。它只是一个整数。ReentrantReadWriteLock把这个 32 bit 的整数拆成高低两部分state 00000000 00000000 00000000 00000000 └────── high 16 bit ─────┘ └────── low 16 bit ──────┘ read count write count高 16 bit 表示读锁总数低 16 bit 表示写锁重入次数。源码中的核心常量也体现了这个拆分思路staticfinalintSHARED_SHIFT16;staticfinalintSHARED_UNIT(1SHARED_SHIFT);staticfinalintMAX_COUNT(1SHARED_SHIFT)-1;staticfinalintEXCLUSIVE_MASK(1SHARED_SHIFT)-1;写锁增加一次本质上是低 16 bit 加 1state1读锁增加一次本质上是高 16 bit 加 1也就是state(116)因此读写计数可以这样取出intwriteCountstate0xFFFF;intreadCountstate16;写锁放在低 16 bit是因为它和普通独占锁类似重入时直接加 1释放时直接减 1。读锁放在高 16 bit则是为了和写锁计数隔离避免两类计数互相影响。四、写锁和读锁的申请规则在前面的state拆分基础上写锁的判断逻辑就比较直接了写锁是独占锁它要求当前没有读锁如果已经有写锁则只能是当前线程自己重入。当前状态写锁是否可以获取说明没有读锁没有写锁可以当前没有任何线程持有锁当前线程已经持有写锁可以写锁可重入有读锁不可以读写互斥别的线程持有写锁不可以写写互斥读锁的判断规则不同。读锁只排斥“别人持有的写锁”。如果当前没有写锁读线程可以获取读锁如果写锁正由当前线程自己持有当前线程也可以继续获取读锁。当前状态读锁是否可以获取说明没有写锁可以读读共享别的线程持有写锁不可以写锁独占当前线程持有写锁可以当前线程已经独占数据可以再读因此ReentrantReadWriteLock的基本规则可以压缩为两句话写锁怕任何读锁也怕别人持有的写锁读锁只怕别人持有的写锁。五、为什么写锁可以降级为读锁由于当前线程持有写锁时可以继续获取读锁所以ReentrantReadWriteLock支持锁降级。锁降级指的是线程先以写锁身份修改数据然后在释放写锁之前先获取读锁最后只保留读锁继续读取。以前面的Point为例假设写线程修改坐标后还需要继续读取修改后的坐标快照那么正确顺序是writeLock.lock();try{xdeltaX;ydeltaY;readLock.lock();}finally{writeLock.unlock();}try{// 这里只剩读锁可以继续读取 x / y}finally{readLock.unlock();}关键是先获取读锁再释放写锁。如果先释放写锁再去获取读锁中间会出现无锁空档其他写线程可能插入并修改x / y当前线程随后读到的就不一定是自己刚刚修改后的状态。锁降级的本质是从“独占写”平滑过渡到“共享读”。它不是为了让锁释放顺序符合嵌套结构而是为了避免写锁释放到读锁获取之间出现状态失控的窗口。反过来的锁升级通常不允许。一个线程持有读锁时再去申请写锁写锁会要求所有读锁都释放如果多个读线程都尝试升级就容易互相等待形成死等。六、读锁为什么还要记录每个线程自己的持有次数state的高 16 bit 只记录读锁总数但它不知道这些读锁分别属于哪个线程。例如从state看只能知道当前总共有 3 次读锁持有无法知道线程 A 持有了几次线程 B 持有了几次。问题会出现在释放读锁时如果线程 A 调用readLock.unlock()它必须确认自己确实持有读锁并且只能减少自己的那一部分计数。因此读锁需要两类记录配合记录位置记录内容作用state高 16 bit所有线程的读锁总数判断写锁能不能获取每个线程自己的计数当前线程持有读锁的次数判断当前线程能不能释放读锁线程 A 获取两次读锁后释放一次只能表示它自己的读锁计数从 2 变成 1同时总读锁数从 3 变成 2。此时线程 A 仍然持有读锁。只有当前线程自己的读锁计数减到 0它才算完全释放读锁。源码中为了减少ThreadLocal访问还会使用firstReader、firstReaderHoldCount、cachedHoldCounter等优化字段。它们不改变读锁计数的基本含义只是为了让常见路径更快。七、读写线程获取失败后如何等待读锁和写锁虽然是两个入口但获取失败后都会进入同一条 AQS 等待队列。区别在于读锁失败后进入共享模式写锁失败后进入独占模式。申请的锁AQS 节点模式含义读锁失败shared后续可能和其他读节点一起被放行写锁失败exclusive成功后独占临界区队列结构可以这样理解当锁释放后如果队列前面是连续的读节点reader A和reader B可以一起被放行因为读读共享。传播到writer C时停止因为写节点必须等所有reader A和reader B可以一起被放读锁释放后才能获取写锁。reader D虽然也是读节点但它排在writer C后面不能越过写节点。所以队列内部的推进规则是连续读节点可以共享传播遇到第一个写节点就形成边界。这个边界可以避免写节点后面的读线程不断越过写节点从而导致写线程长期饥饿。公平性会影响新来的线程是否可以插队。默认构造方法创建的是非公平锁newReentrantReadWriteLock();也可以显式创建公平锁newReentrantReadWriteLock(true);公平锁会更严格地尊重队列顺序非公平锁更倾向于先尝试当前状态是否允许获取。但即使是非公平读锁也会对“队列前方已有写节点等待”的情况做一定限制以降低写线程饥饿风险。八、StampedLock 为什么还要引入乐观读ReentrantReadWriteLock已经允许多个读线程并发但读线程仍然需要真正加锁。每次读操作都要修改读锁计数释放时还要再修改回来。对于读多写少并且读操作很短的场景这部分同步成本可能变得明显。继续沿用Point例子。distanceFromOrigin()只是读取x / y并计算距离。如果写操作很少大多数读操作期间根本没有写线程修改坐标那么每次读都加读锁就显得偏保守。StampedLock在读写锁语义之外增加了乐观读。乐观读的思想不是先阻止写线程而是先假设没有写线程干扰直接把共享字段读到局部变量然后再校验读取期间有没有写发生。StampedLock提供三种主要模式模式方法是否真正加锁典型用途写锁writeLock()是修改共享数据悲观读锁readLock()是稳定读取阻止写线程乐观读tryOptimisticRead()否先读取局部快照再校验中途有没有写写锁和悲观读锁仍然是真正的锁。乐观读则不阻塞写线程它依赖后置校验判断本次读取是否可信。九、stamp 是状态版本凭证StampedLock的方法通常会返回一个long类型的stamplongstamplock.writeLock();longstamplock.readLock();longstamplock.tryOptimisticRead();stamp可以粗略理解成“版本时间戳”但它不是物理时间不表示毫秒或纳秒。更准确地说它是锁状态的版本凭证。不同方法返回的stamp有不同用途来源stamp 的作用tryOptimisticRead()用于后续validate(stamp)校验readLock()作为读锁凭证用于释放读锁writeLock()作为写锁凭证用于释放写锁对乐观读来说stamp主要用于判断从获取它到校验它之间有没有写锁成功修改过状态。只要写锁成功介入内部版本就会变化旧的乐观读stamp就会失效。十、乐观读读取的是一次局部快照乐观读最容易误解的地方是它不保证validate()之后共享变量不再变化它只保证本次已经复制到局部变量里的快照没有被写操作打断。正确写法如下classPoint{privatedoublex;privatedoubley;privatefinalStampedLocklocknewStampedLock();doubledistanceFromOrigin(){longstamplock.tryOptimisticRead();doublecurrentXx;doublecurrentYy;if(!lock.validate(stamp)){stamplock.readLock();try{currentXx;currentYy;}finally{lock.unlockRead(stamp);}}returnMath.sqrt(currentX*currentXcurrentY*currentY);}voidmove(doubledeltaX,doubledeltaY){longstamplock.writeLock();try{xdeltaX;ydeltaY;}finally{lock.unlockWrite(stamp);}}}这个模板必须遵守一个顺序先拿stamp再把共享字段复制到局部变量然后调用validate(stamp)。如果校验成功后续只能使用已经复制出来的currentX / currentY如果校验失败则退回悲观读锁重新读取。错误写法是先校验再读取共享字段longstamplock.tryOptimisticRead();if(lock.validate(stamp)){returnMath.sqrt(x*xy*y);}这段代码的问题是validate()通过之后写线程仍然可能立刻修改x / y。当前线程随后再读取共享字段依然可能读到不一致状态。另一个错误写法是已经复制了局部变量也完成了校验但最后又重新访问共享字段longstamplock.tryOptimisticRead();doublecurrentXx;doublecurrentYy;if(!lock.validate(stamp)){stamplock.readLock();try{currentXx;currentYy;}finally{lock.unlockRead(stamp);}}returnMath.sqrt(x*xy*y);最后一行应该使用currentX / currentY而不是重新读取x / y。乐观读要保护的是“这一次读取形成的局部快照”而不是之后所有对共享字段的访问。十一、StampedLock 的限制和转换StampedLock不是ReentrantReadWriteLock的简单替代品。它为了支持乐观读牺牲了一些传统锁能力其中最重要的是不可重入。ReentrantReadWriteLock的写锁可以被同一个线程重复获取state的低 16 bit 会记录写锁重入次数。但StampedLock不围绕“当前线程重入几次”设计而是围绕stamp凭证设计。下面这种写法在StampedLock中是危险的longs1lock.writeLock();try{longs2lock.writeLock();try{// ...}finally{lock.unlockWrite(s2);}}finally{lock.unlockWrite(s1);}第一次writeLock()后当前线程已经持有写锁。第二次再调用writeLock()时StampedLock不会识别“这是同一个线程所以允许重入”而是看到写锁已经被持有于是当前线程可能等待自己释放锁。同理在已经持有读锁时也不要直接阻塞等待写锁。因为写锁要求没有任何读锁而当前线程自己的读锁也会阻止写锁成功。StampedLock为这类场景提供了转换方法longnewStamplock.tryConvertToWriteLock(stamp);转换成功会返回新的写锁stamp转换失败会返回0L。典型写法是先尝试转换失败后释放原来的读锁再正式申请写锁longstamplock.readLock();try{while(needUpdate()){longwslock.tryConvertToWriteLock(stamp);if(ws!0L){stampws;update();break;}else{lock.unlockRead(stamp);stamplock.writeLock();}}}finally{lock.unlock(stamp);}这里的关键不是 API 名称而是避免“持有读锁时阻塞等待写锁”。转换是一次非阻塞尝试成功就直接切换为写锁失败就说明当前条件不满足必须先放开原有读锁。十二、两者如何选择ReentrantReadWriteLock更像传统读写锁。它支持可重入写锁支持Condition代码结构也更符合常见锁模型。StampedLock更像凭证式读写控制器它通过stamp表示锁状态版本或锁凭证适合在特定场景下减少读锁成本。场景更适合需要可重入ReentrantReadWriteLock需要ConditionReentrantReadWriteLock的写锁希望代码简单、稳定、容易维护ReentrantReadWriteLock读很多、写很少且读操作短小StampedLock乐观读读取的是一组相关字段的快照StampedLock乐观读读取过程较长或逻辑复杂悲观读锁必须阻止写线程进入读取过程ReentrantReadWriteLock或StampedLock.readLock()实际选择时可以先考虑ReentrantReadWriteLock。只有当读操作非常频繁、写操作很少并且读逻辑能够清晰写成“复制局部快照 validate 校验”时再考虑StampedLock。总结从互斥锁走到读写锁是因为读操作和写操作对共享状态的破坏性不同读读之间不需要互斥读写和写写才需要互斥。ReentrantReadWriteLock把这种语义落实到 AQS 状态上用同一个state同时记录读锁总数和写锁重入次数再通过共享模式和独占模式把读线程、写线程放入同一条等待队列中协调。但读写锁仍然要求读线程真正加锁。对于读多写少、读取过程很短的场景StampedLock进一步把“读取”拆成悲观读和乐观读悲观读仍然阻止写线程乐观读则先形成局部快照再用stamp校验这次快照是否被写操作打断。它用更复杂的使用约束换取更低的成功读成本因此适合性能敏感但读取边界清晰的场景而不适合作为所有读写锁场景的默认替代品。