1. 问题
最近有同事问了我一个问题, 在 Java 编程中, 当有一条线程要获取 ReentrantReadWriteLock 的读锁, 此时已经有其他线程获得了读锁, AQS 队列里也有线程在等待写锁. 由于读锁是共享锁, 当前线程是马上获得读锁, 还是排队? 如果是马上获得读锁, 那岂不是阻塞的等待写锁的线程有可能一直 (或长时间) 拿不到写锁(写锁饥饿)?
带着这个问题, 我打开读写锁的源码, 来看一下 JDK 是怎么实现的.(注: 读写锁指 ReentrantReadWriteLock, 以下说到的读锁和写锁, 都是指属于同一个读写锁的情况. 读锁和共享锁, 写锁和独占锁, 在这里是同样的意思. 如无特殊说明, 提到的模式都是默认的非公平模式)
2. JUC 万物皆有 AQS
2.1 读锁的实现.
先来看看读锁的实现. 持有一个 AQS, 所以说, JUC 万物皆有 AQS(大雾).
顺便提一下写锁, 写锁也是类似的实现, 而且传入的是同一个读写锁, 那么读锁和写锁, 都拥有同一个 AQS, 这样才能实现互相阻塞.
读锁是共享模式.
2.2 tryAcquireShared(int arg)的实现.
熟悉 AQS 的同学就知道, 共享锁的实现, AQS 已经写好了流程. 但留下了一个钩子, tryAcquireShared(int arg) 供各种场景实现.
那么我们就来看看, 读写锁里面, 共享锁 (读锁) 是怎么实现的.
step1. 红框一, 如果当前已经有线程持有了独占锁(即写锁), 且不是当前线程持有, 那么无法重入, 直接返回 - 1, 获取共享锁失败.
step2. 如果 step1 的情况被排除, 那么进行 readerShouldBlock()的判断. 在读写锁中, AQS 有两种实现, 公平和非公平模式, 默认是非公平模式.
也就是说, 上面所说的 sync 变量的实际类型, 可以是公平模式, 也可以是非公平模式.
因此, readerShouldBlock()也有公平和非公平两种不同的实现.
公平模式下, 只要前面有阻塞排队的节点, 就返回 true, 表示不能抢占.
非公平模式下, 看看第一个等待的阻塞节点是不是独占式的, 如果是, 返回 true, 有可能不可以抢在人家前面(为什么是有可能? 要考虑可重入的场景, 下面分析). 这是为了避免写锁饥饿.
所以, 如果 readerShouldBlock()返回 false, 并且读锁获取的总次数不溢出, 且 CAS 成功, 说明获取共享锁成功, 下面进入 if 块, 设置一些变量, 并将当前线程持有的该读锁的次数递增加 1, 返回成功标志.
看到这里, 也许你会有疑惑, 仅仅是因为 CAS 失败, 就获取共享锁失败了吗? 而且, ReentrantReadWriteLock 是一个可重入锁, 这里也没看到有重入的地方啊.
别急, 如果 step2 失败, 会进入 step3, 到第三个红框, 进入 fullTryAcquireShared(Thread current)方法.
2.3 final int fullTryAcquireShared(Thread current)
这个方法比较长, 里面用了 for(;;) 自旋 CAS, 为什么呢? 因为 CAS 还是可能会失败啊...... 失败就得继续再尝试一把.
我就贴出 for(;;) 里的代码, 分为两段, 第一段判断是否可以尝试获取锁(与上面类似, 加了重入的判断), 第二段 CAS 和成功后的一些操作.
先看第一段, 判断是否可以尝试获取锁.
step1. 如果有线程持有独占锁, 并且不是当前线程, 返回失败标志 - 1. 如果是当前线程, 由于可重入的语义, 通过了判断, 直接跑到第二段代码了. 说明在持有独占锁的情况下可以获取共享锁(锁降级).
step2. 如果当前没有线程持有独占锁, 那么再来看看熟悉的 readerShouldBlock(). 通过上面的分析我们知道, 在公平模式下有节点在阻塞就得排队, 在非公平模式下有可能不可以抢在人家前面. 为什么是有可能? 因为要考虑可重入的场景.
如果 firstReader 是当前线程, 或者当前线程的 cachedHoldCounter 变量的 count 不为 0(表示当前线程已经持有了该共享锁), 均说明当前线程已经持有共享锁, 此次获取共享锁是重入, 这也是允许的, 可以通过判断.
如果可以顺利通过上面两步判断, 说明获取共享锁成功, 下面开始熟悉的 CAS.
失败了咋办? 别忘记是自旋啊, 外层是 for(;;), 那就再来一发~~. 当然还得再来一遍第一段的判断.
3. 结论
经过上面的分析, 可以来回答我的同事的问题了.
在 Java 编程中, 当有一条线程要获取 ReentrantReadWriteLock 的读锁, 此时已经有其他线程获得了读锁, AQS 队列里也有线程在等待写锁. 由于读锁是共享锁, 当前线程是马上获得读锁, 还是排队? 如果是马上获得读锁, 那岂不是阻塞的等待写锁的线程有可能一直 (或长时间) 拿不到写锁(写锁饥饿)?
1. 如果已经有线程持有独占锁
1.1 该线程不是当前线程, 不用想了, 乖乖排队;
1.2 该线程就是当前线程, 重入, CAS 获取共享锁;
2. 如果没有线程持有独占锁, 检查当前线程是否需要 block(readerShouldBlock 方法).
block 的判断, 有两种模式, 公平和非公平(默认模式). 如果不需要 block, 必须满足: 公平模式下, 没有节点在 AQS 等待; 非公平模式下, AQS 第一个等待的节点不是独占式的;
2.1 不需要 block, 可以 CAS 获取共享锁;
2.2 需要 block;
2.2.1 当前线程已经持有了共享锁, 重入, 还是可以 CAS 获取共享锁;
2.2.2 当前线程前没有已经持有共享锁, 则获取失败, 只能排队.
上面是根据代码逻辑整理的, 可以换为更简洁的语言.
如果当前线程已经持有独占锁或共享锁 (重入) 或不需要 block, 则 CAS 获取共享锁; 否则, 排队.
在问题的场景中, 当前线程并没有获取到写锁或读锁, 不能重入; 非公平模式下 AQS 中第一个等待的是想要获取独占锁的节点(公平模式不赘述), 必须 block, 所以当前线程只能排队, 并不会出现阻塞的想获取写锁的节点一直拿不到写锁的情况.
为什么知道等待写锁的节点一定是第一个等待的节点呢? 因为如果它前面还有节点, 获取到读锁的节点会唤醒后面同样等待读锁的节点 (共享锁的特点), 所以等待写锁的节点, 大概率是第一个节点(也有可能等待读锁的节点还没被唤醒, 当然这是瞬时发生的, 这种场景应该小概率吧......). 我想, 这也是 readerShouldBlock() 要判断第一个等待节点的原因吧.
4. 举个栗子
- package com.khlin.my.test;
- import java.util.concurrent.locks.ReentrantReadWriteLock;
- public class RRWLockTest {
- public static void main(String[] args) throws InterruptedException {
- final ReentrantReadWriteLock LOCK = new ReentrantReadWriteLock();
- Thread reader1 = new Thread(new Runnable() {
- public void run() {
- try {
- LOCK.readLock().lock();
- System.out.println("reader1 locked.");
- Thread.sleep(3000L);
- System.out.println("reader1 finished.");
- } catch (InterruptedException e) {
- e.printStackTrace();
- } finally {
- LOCK.readLock().unlock();
- }
- }
- });
- Thread reader2 = new Thread(new Runnable() {
- public void run() {
- try {
- LOCK.readLock().lock();
- System.out.println("reader2 locked.");
- System.out.println("reader2 finished.");
- } finally {
- LOCK.readLock().unlock();
- }
- }
- });
- Thread writer = new Thread(new Runnable() {
- public void run() {
- try{
- LOCK.writeLock().lock();
- System.out.println("writer locked.");
- System.out.println("writer finished.");
- }finally {
- LOCK.writeLock().unlock();
- }
- }
- });
- reader1.start();
- Thread.sleep(1000L);
- writer.start();
- Thread.sleep(1000L);
- reader2.start();
- }
- }
reader1 获取了读锁, 正在执行, 随后 writer 来获取写锁, 失败, 入队等待. reader2 由于 writer 正在等待(通过 readerShouldBlock 判断), 无法获取读锁, 入队, 等待. 输出如下:
如果把 writer 去掉, 虽然 reader1 还没执行完, 但 reader2 可以马上获得读锁, 无需等待. 把上面第 49,50 行注释掉, 输出如下:
来源: https://www.cnblogs.com/kingsleylam/p/11235293.html