上一篇文章我们介绍了一个显式锁,ReentrantLock ,了解到它是一个『独占式』锁,简而言之就是,
我拿到锁以后,不管我是读或是写操作,其他人都不能和我抢,都得等着。
因而在某些读操作远大于写操作的场景之下,即便我只是读数据也不得不排队一个一个来,于是有人提出了一个『读写锁』的概念。
『读写锁』并不是真正意义上的读写分离,它只允许读读共存,而读写、写写依然是互斥的,所以只有在大量读操作、少量甚至没有写操作的情境之下,读写锁才具有较高的性能体现。
类的基本结构
来自父接口的规范
ReentrantReadWriteLock 继承了接口 ReadWriteLock,而父接口约束它必须提供的能力如下:
而 ReentrantReadWriteLock 对该接口的实现也是简单明了了的:
显然,ReentrantReadWriteLock 通过在内部定义两个静态内部类来分别实现接口 Lock,以达到内嵌读写锁的能力,而两个内部类的实现是如何的?区别在哪?怎么实现一个读一个写?我们稍后会详细地从源码层面一点点分析,不要着急。
自定义实现 AQS
AQS 是什么呢?相信看过之前文章的朋友是一定知道的,AQS 指的是 AbstractQueuedSynchronizer,就是一个同步容器。简而言之就是:
一个队列、一个状态、一个线程对象。
线程对象保存的当前被允许访问代码块的线程实例,队列中每一个线程都是一个节点,这些线程都是由于没能获取到锁而阻塞排队在这里。状态可以取值为零或正正整数,零表示当前无人持有该锁,正数表示当前线程多次重入该锁的次数。
除此之外,ReentrantReadWriteLock 中剩余的一些方法主要提供了该锁的一些状态信息的返回,这部分比较简单。本文的重点将放在对那两个内部类实现的读锁写锁原理的分析。
读读共存
下面我们深入到源码层面去看看读锁在何种情况下才能成功的加在临界资源上,哪些情况下不得加读锁。另外说一句,对于有些方法我并不会一跟到底,不然篇幅太长了,我会大体概括这些方法的作用与核心逻辑,具体的大家可以自行阅读分析。
ReadLock 是 ReentrantReadWriteLock 中定义的一个内部类,它实现了 Lock 接口,提供基本的 lock、unlock 等方法,我们先看 lock 方法:
public void lock() { sync.acquireShared(1); }lock 方法很简单,调用了外部类同步容器实例的同步方法,因为需要读写分离,所以读锁写锁必须共用同一个 AQS,而这个 AQS 则定义在外围类 ReentrantReadWriteLock 之中,供两种锁使用。
简而言之,无论是读锁或是写锁,他们共用的一个 AQS 同步器,同一个阻塞队列,同一个状态,同一个线程持有器。ReentrantReadWriteLock 也正是通过这个公用的 AQS 同步器来协调读锁写锁能同时工作。
acquireShared 方法实现如下,这里我们先以公平策略作引例:
public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); }tryAcquireShared 方法实现如下:
这个方法的代码不铺开分析了,主要切三个部分总结下逻辑及完成的功能,具体源码大家自行分析了,如有疑问欢迎加我微信一起探讨(文末)。
- 如果有线程对临界资源加了写锁,并且该线程不是自己,那么认为自己应当退出阻塞,不能再加读锁,返回负值。
- 到达这里必然说明临界资源没有被任何其他写锁占用,然后这部分首先会通过 CAS 去修改状态,为读锁计数增一。除此之外,还将计算并保存当前线程重入该读锁的次数,这里的记录算法也是很有意思的,如果你有些疑惑欢迎和我讨论讨论。
- 第三个步骤是上两个步骤的综合,这个方法体中将循环的执行上述 1、2 两个步骤,直到成功加上读锁或是条件发生改变,不再具备尝试获取读锁的能力,例如当前的临界资源已经被写锁占用、等待队列中有其他线程正在等待向临界资源添加锁限于公平策略,当前线程不得继续竞争并尝试加锁。
分析完了 tryAcquireShared 方法以后,我们知道如果此次尝试加锁失败,方法会返回值 -1,意味着加读锁失败,当前线程需要被阻塞排队等待。
于是就有了我们的 doAcquireShared 方法,该方法会将当前线程包装成节点添加到阻塞队列尾部,排队等待再次竞争临界资源。
- addWaiter 会将当前线程包装成一个 Node 节点添加到队列的尾部,如果队列没有初始化会优先做初始化队列的操作。
- 接着在一个死循环准备阻塞当前线程,当然阻塞之前会取出当前节点的前一个节点,比较看是不是 head 节点,如果是则说明当前线程排在队列的第一位置,于是再次尝试添加读锁,如果成功方法即刻返回。
- 如果当前线程并没有排在队列第一的位置,亦或是再次的尝试也失败,那么将在这部分的 parkAndCheckInterrupt 方法中被阻塞。
- 如果上述步骤失败了,也就是 failed 的值是 true,那么将取消当前试图添加读锁的操作,删除当前线程对应阻塞队列中的节点,唤醒下一个节点对应的线程。
