写点什么

Java 并发原理抽丝剥茧,读写锁 ReadWriteLock 实现深入剖析

用户头像
极客good
关注
发布于: 刚刚


读写锁性质 5

简单的实现版本

为了加深对读写锁的理解,在分析 JDK 实现的读写锁之前我们先来看一个简单的读写锁实现版本。其中三个整型变量分别表示持有读锁的线程数、持有写锁的线程数以及请求获取写锁的线程数,四个方法分别对应读锁、写锁的获取和释放操作。acquireReadLock 方法用于获取读锁,如果持有写锁的线程数量或请求读锁的线程数大于 0 则让线程进入等待状态。releaseReadLock 方法用于释放读锁,将读锁线程数减一并唤醒其它线程。acquireWriteLock 方法用于获取写锁,如果持有读锁的线程数量或持有写锁的线程数量大于 0 则让线程进入等待状态。releaseWriteLock 方法用于释放写锁,将写锁线程数减一并唤醒其它线程。



读写锁简单版本

读锁升级为写锁

在某些场景下,我们希望某个已经拥有读锁的线程能够获得写锁,并将原来的读锁释放掉,这种情况就涉及到读锁升级为写锁操作。读写锁的升级操作需要满足一定的条件,这个条件就是某个线程必须是唯一拥有读锁的线程,否则将无法成功升级。如下图中,线程二已经持有读锁了,而且它是唯一的一个持有读锁的线程,所以它可以成功获得写锁。



读锁升级

写锁降级为读锁

与锁升级相对应的是锁降级,锁降级就是某个已经拥有写锁的线程希望能够获得读锁,并将原来的写锁释放掉。锁降级操作几乎没有什么风险,因为写锁是独占锁,持有写锁的线程肯定是唯一的,而且读锁也肯定不存在持有线程,所以写锁可以直接降级为读锁。如下图中,线程三持有写锁,此时其它线程不可能持有读锁和写锁,所以可以安全地将写锁降为读锁。



写锁降级

ReadWriteLock 接口

ReadWriteLock 实际上是一个接口,它仅仅提供了两个方法:readLock 和 writeLock。分别表示获取读锁对象和获取写锁对象,JDK 为我们提供了一个内置的读写锁工具,那就是 ReentrantReadWriteLock 类,我们将对其进行深入分析。ReentrantReadWriteLock 类包含的属性和方法较多,为了让分析思路清晰且方便读者理解,我们将剔除非核心源码,只对核心功能进行分析。


ReentrantReadWriteLock 三要素

ReentrantReadWriteLock 类的三要素为:公平/非公平模式、读锁对象和写锁对象。其中公平/非公平模式表示多个线程同时去获取锁时是否按照先到先得的顺序获得锁,如果是则为公平模式,否则为非公平模式。读锁对象负责实现读锁功能,而写锁对象负责实现写锁功能,这两个类都属于 ReentrantReadWriteLock 的内部类,下面会详细讲解。

ReentrantReadWriteLock 实现思想

总的来说,ReentrantReadWriteLock 类的内部包含了 ReadLock 内部类和 WriteLock 内部类,分别对应读锁和写锁,这两种锁都提供了公平模式和非公平模式。不管公平模式还是非公平模式、不管是读锁还是写锁都是基于 AQS 同步器来实现的。实现的主要难点在于只使用一个 AQS 同步器对象来实现读锁和写锁,这就要求读锁和写锁共用同一个共享状态变量,下面会具体讲解如何用一个状态变量来供读锁和写锁使用。



实现思想


对应 ReentrantReadWriteLock 类的结构如下,ReentrantReadWriteLock.ReadLock 和 ReentrantReadWriteLock.WriteLock 分别为读锁对象和写锁对象。Sync 对象表示 ReentrantReadWriteLock 类的同步器,它基于 AQS 同步器,而 FairSync 类和 NonfairSync 类分别表示公平模式和非公平模式的同步器,可以看到默认情况下使用的是非公平模式。


读写锁共用状态变量

前面提到过 ReentrantReadWriteLock 的难点在于读锁和写锁都共用一个共享变量,下面看具体是如何共用的。我们知道 AQS 同步器的共享状态是整型的,即 32 位,那么最简单的共用方式就是读锁和写锁分别使用 16 位。其中高 16 位用于读锁的状态,而低 16 位则用于写锁的状态,这样便达到共用效果。但是这样设计后当我们要获取读锁和写锁的状态值时则需要一些额外的计算,比如一些移位和逻辑与操作。



共用状态变量


ReentrantReadWriteLock 的同步器共用状态变量的逻辑如下,其中 SHARED_SHIFT 表示移动的位数为 16;SHARED_UNIT 表示读锁每次加锁对应的状态值大小,1 左移 16 位刚好对应高 16 位的 1;MAX_COUNT 表示读锁能被加锁的最大次数,值为 16 个 1(二进制);EXCLUSIVE_MASK 表示写锁的掩码,值为 16 个 1(二进制)。sharedCount 方法用于获取读锁(高 16 位)的状态值,左移 16 位即能得到。exclusiveCount 方法用于获取写锁(低 16 位)的状态值,通过掩码即能得到。


ReadLock 与 WriteLock 简介

ReadLock 与 WriteLock 是 ReentrantReadWriteLock 的两个要素,它们都属于 ReentrantReadWriteLock 的内部类。它们都实现了 Lock 接口,我们主要关注 lock、unlock 和 newCondition 这几个核心方法。分别表示对读锁和写锁的加锁操作、释放锁操作和创建 Condition 对象操作,可以看到这些方法都间接调用了 ReentrantReadWriteLock 的同步器的方法,需要注意的是读锁不支持创建 Condition 对象。我们在可重入锁 ReentrantLock 章节中已经讲解过 Condition 对象,本节将不再赘述。


公平/非公平模式

ReentrantReadWriteLock 的默认模式为非公平模式,其内部类 Sync 是公平模式 FairSync 类和非公平模式 NonfairSync 类的抽象父类。因为 ReentrantReadWriteLock 的读锁使用了共享模式,而写锁使用了独占模式,所以该父类将不同模式下的公平机制抽象成 readerShouldBlock 和 writerShouldBlock 两个抽象方法,然后子类就可以各自实现不同的公平模式。换句话说,ReentrantReadWriteLock 的公平机制就由这两个方法来决定了。



下面看公平模式的 FairSync 类,该类的 readerShouldBlock 和 writerShouldBlock 两个方法都直接返回 hasQueuedPredecessors 方法的结果,这个方法是 AQS 同步器的方法,用于判断当前线程前面是否有排队的线程。如果有排队队列就要让当前线程也加入排队队列中,这样按照队列顺序获取锁也就保证了公平性。


继续看非公平模式 NonfairSync 类,该类的 writerShouldBlock 方法直接返回 false,表明不要让当前线程进入排队队列中,直接进行锁的获取竞争。readerShouldBlock 方法则调用 apparentlyFirstQueuedIsExclusive 方法,这个方法是 AQS 同步器的方法,用于判断头结点的下一个节点线程是否在请求获取独占锁(写锁)。如果是则让其它线程先获取写锁,而自己则乖乖去排队。如果不是则说明下一个节点线程是请求共享锁(读锁),此时直接与之竞争读锁。



公平/非公平

写锁 WriteLock 的实现

上面的介绍中我们知道 WriteLock 有两个核心方法:lock 和 unlock。它们都会间接调用了 ReentrantReadWriteLock 内部同步器的对应方法,在同步器中需要重写 tryAcquire 方法和 tryRelease 方法,分别用于获取写锁和释放写锁操作。


先看 tryAcquire 方法的逻辑,获取状态值并通过 exclusiveCount 方法得到低 16 位的写锁状态值。c!=0 时有两种情况,一种是高 16 位的读锁状态不为 0,一种是低 16 位的写锁状态不为 0。w 等于 0 时表示还有线程持有读锁,直接返回 false 表示获取写锁失败。如果持有写锁的线程为当前线程,则表示写锁重入操作,此时需要将状态变量进行累加,此外需要校验的是写锁重入状态值不能超过 MAX_COUNT。通过 writerShouldBlock 方法判断是否需要将当前线程放入排队队列中,同时通过拥有 CAS 算法的 compareAndSetState 方法对状态变量进行累加操作,CAS 失败的话也需要将当前线程放入排队队列中。对于非公平模式,这里的 CAS 操作就是闯入操作,即线程先尝试一次竞争写锁。最后通过 setExclusiveOwnerThread 设置当前线程持有写锁,该方法只是简单的设置变量方法。


继续看 tryRelease 方法的逻辑,先用 isHeldExclusively 方法检查当前线程


【一线大厂Java面试题解析+核心总结学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


必须为写锁持有线程。然后将状态值减去释放的值,并通过 exclusiveCount 得到低 16 位的写锁状态值,如果其值为 0 则表示已经没有重入可以彻底释放锁了,调用 setExclusiveOwnerThread(null)设置没有线程持有写锁。最后设置新的状态值。


读锁 ReadLock 的实现

ReadLock 同样有两个核心方法:lock 和 unlock。它们都会间接调用了 ReentrantReadWriteLock 内部同步器的对应方法,在同步器中需要重写 tryAcquireShared 方法和 tryReleaseShared 方法,分别用于获取读锁和释放读锁操作。


tryAcquireShared 方法的逻辑为:先通过 getState 方法获取状态值,然后通过 exclusiveCount 方法获取低 16 位的写锁状态,如果不为 0 则表示有其它线程持有写锁而且当前线程没有持有写锁,则此时尝试获取读锁失败,返回-1,即将当前线程放到排队队列。注意这里如果当前线程持有写锁的话则可以继续获取读锁。继续通过 sharedCount 得到高 16 位的读锁,然后尝试用 CAS 算法设置新的状态值,如果成功则返回 1 表示成功获取读锁。如果不成功则继续调用 fullTryAcquireShared 方法。


fullTryAcquireShared 方法的逻辑为:这是一个无限自旋操作,首先获取状态值,如果写锁不为 0 且当前线程不为持有写锁程序,则返回-1,表示尝试获取读锁失败,将当前线程加入排队队列中。如果写锁的状态为 0,则表示没有线程持有写锁,继续通过 readerShouldBlock 方法判断是否需要将该线程加入到排队队列中,如果需要则返回-1,AQS 同步器会将其加入到排队队列中。此外,读锁的状态值不能等于 MAX_COUNT,即已经达到最大读锁数了。最后,通过 CAS 算法的 compareAndSetState 方法设置新的状态值,这里的 for 无限循环就是自旋,指通过自旋方式来竞争读锁。需要注意的是,在非公平模式下如果排队队列中下一个线程是要获取写锁,则这个自旋操作也会被打破。

用户头像

极客good

关注

还未添加个人签名 2021.03.18 加入

还未添加个人简介

评论

发布
暂无评论
Java并发原理抽丝剥茧,读写锁ReadWriteLock实现深入剖析