写在前面
作者Doug Lea_如此描述这个类:_A capability-based lock with three modes for controlling read/write access.
这是一个有三种模式的锁。具体哪三种模式,请看源码中的示例。
源码分析自 JDK 1.8.0_171
1 | /** |
在ReentrantReadWriteLock中我们知道,如果在读并发比较高的情况下,那么可能会导致写线程饥饿。而StampedLock并不会发生写饥饿。另外,它也有锁升级的特性(ReentrantReadWriteLock只有锁降级)。那么看看它是怎么实现的吧。
实现原理
锁算法是借鉴了序列锁(linux内核,参考文章1、文章2)和顺序读写锁(参考文章)。具体算法细节还可以参考源码中的描述。
简单描述一下,用一个long类型(state)的变量表示锁状态(写锁、读锁),低7位表示读锁状态,其他位表示写锁(第8位是否未1表示是否持有写锁,前面说的sequence lock),如果低7位不够表示读锁位,那么会用一个int值表示”溢出”的读锁。那么写锁可以表示为:state+=2^7;读锁可以表示为state+=1。此外,源码中还用了大量自旋来减少饥饿的概率,头部节点表示的线程不会阻塞,而是会一直自旋等待锁释放。
结合内部的变量声明理解一下:
1 | /** The number of bits to use for reader count before overflowing */ |
总结一下,用一个long型变量表示锁的计数,读锁计数用低7位表示,超出部分用一个int值表示。其他位表示写锁状态,第8位如果为1则表示写锁被持有,为0表示未被持有,也就是序列锁,这样即保证了锁(包括读写)的原子性,也能提高效率(操作读或写锁要保证互斥)。用一个CLH队列表示等待的节点,避免饥饿现象,节点首先会采用自旋的方式尝试获取锁。
接下来就跟着3个示例解析一下加解锁的流程。
Reading Mode
经过上面的分析,state上的低7位是用来表示reader count,而溢出部分,则用readerOverflow表示。
readLock
1 | /** |
首先明确获取读锁并不是互斥的,而是通过cas操作去尝试获取。其次是通过自旋去尝试获取锁,对于头部位置的节点总是在自旋等待锁。
unlockRead
接下来看一下时如何释放读锁的:
1 | /** |
Writing Mode
writeLock
读锁状态也是也是在state上表示,除了表示读锁状态的低7位,剩下的高25位都表示写锁状态。第8位为1表示写锁被占用,否则表示未被占用,写锁状态时往上递增的,也就是说获取读锁state需要+WBIT,释放也是+WBIT。
1 | public long writeLock() { |
由此可见,写锁的获取也是通过自旋,如果队列里有排队的节点,那么入队,保证个公平公正,这也是CLH队列的作用的地方。并不会有开始说的写锁饥饿的现象。
unlockWrite
1 | /** |
upgrade
文章开头的示例中,第三个示例就是读锁升级写锁的例子。在ReentranReadWriteLock中,写锁可以降级为读锁,而StampedLock可以由读锁能直接升级为写锁。首先是需要持有读锁(readLock),接着会尝试升级写锁(tryConvertToWriteLock),如果升级成功,则直接操作业务并在最后释放锁(unlock),否则需要释放读锁(unlockRead)获取写锁(writeLock)。我们主要来看_tryConvertToWriteLock_和unlock,其它的逻辑都已经在上边讨论过了。来看看具体是怎么实现的吧。
1 | /** |
Optimistic Reading Mode
示例中的第二个例子就是乐观读。先尝试获取读锁(tryOptimisticRead),接着校验(validate)下stamp是否变更,如果校验通过未发生变更则直接进行下一步,否则需要获取读锁(readLock)并操作完成之后释放(unlockRead)读锁。主要来看看_tryOptimisticRead和_validate,其它的流程逻辑参考前边的讨论。
1 | /** |
如上,tryOptimisticRead并不会去尝试获取读锁(即不会更改state),而是通过validate验证写锁状态是否在这期间改变过,如果未改变,则可以认为可以共享读锁,否则(即写线程操作过数据)需要实际去获取读锁。
总结
有以下几点值得我们注意一下:
- 读写锁状态在一个volatile long型变量上表示,低7位表示读锁状态,溢出的读锁状态用readerOverflow表示,高25位表示写锁,并且写锁是序列递增的;
- 申请锁时,使用了大量自旋操作。如果是在队列头部位置的等待线程节点,会一致自旋下去。否则当前线程节点会入队进行阻塞等待,虽然读和写具体的入队方式可能有点差别。通过这样的方式,不仅避免了饥饿现象,一定程度行还体现了公平性;
- 读锁是共享的,写锁是互斥的。此外读锁还有种乐观读的方式,即尝试获取锁时,并不会更改当前读锁状态,而是通过验证期间写锁状态是否被更改的方式保证数据一致。此外还有一个读锁升级的功能,这是跟ReentrantReadWriteLock的区别。