共享锁重入次数怎么记录都不知道,谁敢给你涨薪(AQS源码阅读之读写锁)

读锁重入次数怎么分别保存?读写锁的获取数量如何原子性修改?

其实之前在学习 Lock 的时候,学得比较粗糙,我也相信很多人都知道,像 ReentrantLock,ReadWriteLock 都是基于 AQS,CAS 实现的。
通过状态位(或者说标志位)state 来 CAS 抢锁,通过一个 AQS 链表队列,来实现线程的排队,LockSupport 来实现线程的阻塞与唤醒,通过模板方法设计模式,来对代码进行封装。
甚至,可以说基于这些思想,手写一个简化版 lock。

确实,如果能理解、学习到这些知识,已经足够去面试一些中小企业。只不过,相信很多人都有一颗积极向上的心,想要更好的就业平台,那么,这些知识还是比较 表层 的。
很多大厂的面试官只要一问,就知道,你是随便背了几篇博客,还是真正从 源码 角度去研究过。这些源码中的每一处细节,都是写作者的 思维的结晶,体现出 高度严谨、缜密 的编程思想。

学习忌浮躁

下面举几个 ReentrantReadWriteLock 的几道题目,你来简单检测一下自己的学习情况。

  1. 读锁写锁,被获取的次数怎么保存(并且如何保证原子性)
  2. 写锁是公平锁还是非公平锁
  3. 读锁面对写锁是否公平
  4. 读锁多线程共享,如何记录每个线程的重入次数
  5. 多线程 交替 重入读锁(不存在线程竞争),是否会产生性能影响
  6. 共享锁的自旋机会有哪些
  7. 读锁因为写锁阻塞后,是如何被唤醒的

下面我会基于源码,对 ReentrantReadWriteLock 进行分析。不过在此之前,我需要你阅读过我的上一篇文章。
99%的人不知道AQS还有这种操作(源码分析ReentrantLock的实现和优秀设计)(写这么详细不信你还看不懂)

在上一篇文章中,我从源码,并且画图,十分详细地 讲解了很多的 AQS 的设计理念,方法实现过程。
因为 AQS 是一个抽象类,而 lock 不过是用一个 内部类 重写需要的方法完成自己的实现,所以很多地方都是互通的。

而从 ReentrantLock 开始,学习 AQS,是一个不错的入门,也是对基础的扎根。
上一篇文章我写得很详细,而这一篇文章则会有涉及很多重复的知识点,我都不再去写。
所以学完上一篇文章的知识,以此为基础,学习这里的知识便会很轻松。

你学习完上一篇 ReentrantLock 之后,再来看这一偏文章,就会发现许多相通之处。
此时,基于 互斥锁 和 共享锁,你便可以将内部知识连接起来,便融会贯通。
从此,再去学习其他有关 AQS 的代码时,便能心领神会,入眼即已了然。

ReadWriteLock 大致原理

要实现读锁共享,写锁互斥,那么就分别需要两个值来记录锁的重入次数

如果写锁的重入 >0,那么就应该阻塞住所有的其他抢锁线程
自己可以重入写锁,同时,也可以重入读锁

如果读锁的获取次数大于 0,那么此时一定没有写锁被获取,
要获取写锁的必须进入队列,在队列中阻塞

源码如何保证读写分别 CAS 抢锁的原子性

两个 int 会出现的问题

因为 CAS 只能对一个 int 操作,没有办法同时对 两个 int 操作
所以,如果用一个 state 记录读锁数,一个 state 记录写锁数就会出现问题(不加锁,没有办法对两个 int 进行原子操作)

在 JDK 源码中,我们发现这是用一个 int 来实现的(所以可以用 CAS 保证原子)
可是一个 int 如何表示两个值??

Doug Lee 很聪明,他把 32 位的 int,用前 16 位表示 共享锁,后 16 位表示 互斥锁。

从源码分析互斥锁的 state

获取互斥锁的 state

// 获取互斥锁的 state
static int exclusiveCount(int c) { 
    // 将整个int数 和 后16位都是1的二进制数做与运算
    return c & EXCLUSIVE_MASK; 
}

就像这样,做与运算的这个数,二进制表示的后 16 位全部是1,
00000000000000001111111111111111
那么运算结果只保留后 16 位的值,而前 16 位全部为 0,就相当于互斥锁的 state

对于修改,我们只需要对 CAS 的结果值 加上我们期待的值便可

compareAndSetState(c, c + acquires)

从源码分析共享锁的 state

获取共享锁的 state

// 获取共享锁的 state
static int sharedCount(int c)    { 
    // 将整个in整数无符号右移16位
    return c >>> SHARED_SHIFT; 
}

将整个整数无符号右移 16 位,那么最右边的数已经全部被移除界外没有了,而前 16 位则正好补在后 16 位,前面全部补 0,那么这样就能得到前 16 位的数字

设置共享锁的 state

compareAndSetState(c, c + SHARED_UNIT))

其中的 SHARED_UNIT = 1 << 16
用二进制表示就是:
0000000000000001 0000000000000000
后面 16位 都是0,就不会影响到共享锁的 state,每次加这个值,都相当于在前 16位 +1

读锁先来加锁

首先调 AQS 的获取共享锁的模板方法

首先调用了 sync 的 acquireShared() 方法

我们都知道 AQS 是一个 抽象类,是基于 互斥锁共享锁封装

之前学习的 ReentrantLock 的加锁方法,就是去调用 AQS 获取互斥锁的方法 acquire(),只不过对公平和非公平的 tryAcquire() 进行了不同的重写

而此处的 ReadLock 读锁属于共享锁,所以其中的 lock 调用了 AQS 的获取共享锁的方法 acquireShared(),所以很容易联想到,此处的 ReadLock 一定对 tryAcquireShared() 方法进行了重写

// 首先调用sync的模板方法
// sync集成了AQS,但不对该方法重写
// 所以本质上调用了AQS的 acquireShared 获取共享锁
public void lock() {
    sync.acquireShared(1);
}

我们点进这个方法
首先尝试加锁,此时由于是第一次来加锁,之前还没有任何线程来过,所以一定会加锁成功
于是 tryAcquireShared 返回当前读锁的数目,也就是只有当前线程,返回 1。

// 获取共享锁
public final void acquireShared(int arg) {
    // if 中尝试获取共享锁
    // 此时一定获取到,返回 1
    if (tryAcquireShared(arg) < 0)
        // 这里就不执行
        doAcquireShared(arg);
}

tryAcquireShared 尝试获取共享锁(读锁实现的方法)

tryAcquireShared 方法是 AQS 没有实现的,此时由读锁进行实现

首先要获取 当前的线程 和 state 值
然后判断是否被写锁占有(我们知道写锁是互斥锁,会阻塞所有其他需要抢锁的线程),如果被写锁占有了,那么此时读锁一定会获取失败,直接返回 -1 表示失败

如果没有读锁获取,那么继续执行,获取读锁的占有数量
但是,在尝试 CAS 加锁之前,它先判断自己应不应该阻塞 !!!

有没有觉得很奇怪,这个时候没有写锁获取锁,按道理读锁应该去抢锁才对,但是,
它先判断要不要阻塞,
这是什么神仙逻辑????
你有没有想过

这其实跟我们之前看 ReentrantLock 的时候碰到的逻辑类似
在 ReentrantLock 中,公平锁 在 CAS 抢锁前,要先看一下队列里有没有人,不然就不抢锁

这里,读锁,首先 CAS 抢锁之前,我们应该看一下队列里排队的第一个是不是写锁,
如果下一个就是写锁了,那么就不该去抢锁

读锁,抢来抢去无所谓,因为可以共享,第一次抢完了,没抢到,根本不用等抢到锁的线程释放,就可以再去抢锁,把持有数 加上一
但是写锁不一样,它会阻塞,所以不能在写锁排队结束,要开始抢锁的时候,读锁去和写锁争抢
所以要在 CAS 抢锁前,先判断一次是否有读锁在最前边要出队了

此时,没有其他线程争抢,所以一定会获取,
然后设置 第一个读锁线程 为自己

但是,有个问题,它可以设置第一个持有读锁的线程为自己
那第二个线程获取读锁了怎么办???它拿什么记录自己???
有第二个的变量吗???
可是没有
那第二个抢读锁的线程该怎么记录??
后文解答

这时返回 1,最终让 lock() 方法返回,加锁就成功结束了

protected final int tryAcquireShared(int unused) {
    // 开头与 ReentrantLock 的加锁方法如出一辙
    // 先获取当前线程和state值
    Thread current = Thread.currentThread();
    int c = getState();
    // 我们知道写锁是互斥锁,会阻塞其他任何要获取锁的线程
    // 所以 如果写锁重入次数不为0,说明有人加了写锁
    // 并且如果写锁不是自己(因为如果是自己,自己既可以重入写锁,也能加读锁)
    // 那么肯定不能获取读锁
    // (此时由于第一次来,所以肯定没有锁在,所以不进 if)
    if (exclusiveCount(c) != 0 &&
        // 如果写锁不是自己
        getExclusiveOwnerThread() != current)
        return -1;
    // 获取读锁的占有数量
    // (读锁是很多线程都能获得的)
    int r = sharedCount(c);
    // 先判断读锁是不是应该被阻塞
    /**
     * 这里你有没有觉得奇怪,为什么互斥锁没人持有
     * 还要先判断要不要阻塞???
     * 没人持有锁不应该去抢锁吗?
     */
    if (!readerShouldBlock() &&
        // 再判断数量有没有超过上限(正常情况下不会)
        r < MAX_COUNT &&
        // 这时候 CAS 去获取读锁
        // 抢锁成功就进入了if中的方法
        compareAndSetState(c, c + SHARED_UNIT)) {
        // 如果本来没有人持有读锁,那么自己设置成第一个读锁
        /**
         * 但是你有没有想过,第二个线程怎么办
         * 第一个占了 第一个读锁的变量
         * 但是有第二个读锁变量吗??
         * 没有
         * 那第二个线程如何保存重入次数??
         * 后文解答
         */
        if (r == 0) {
            firstReader = current;
            // 重入次数设置 1
            firstReaderHoldCount = 1;
            // 如果是自己来重入的,那就把重入次数加 1
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {
            // 这里不进 else 方法,我们先不看
            // ... 省略无关代码
        }
        return 1;
    }
    // 没有进if
    // 可能是判断需要需要阻塞
    // 可能是 CAS 抢锁失败
    // (正常情况不会数量达到上限的,一个 JVM 上抢几万个读锁也太夸张了)
    // 于是进入 fullTryAcquireShared 方法(传入当前线程做参数)
    return fullTryAcquireShared(current);
}

第一次读锁加锁小结

第一次加锁很简单

  1. 只需要先判断是否有写锁持有锁(此时没有)
  2. 然后判断是否有写锁正排队结束需要抢锁(此时肯定没有)
  3. 然后 CAS 抢锁,设置自己为第一个 读锁

第二次读锁加锁

进入 tryAcquireShared 方法

加锁的过程和之前相同,都是:

  1. 先获取 Thread 和 state
  2. 然后判断有没有互斥锁
  3. 需不需要阻塞(有互斥锁在队列第一个正要抢锁)
  4. CAS 抢锁

关键是抢锁结束后的操作不同

之前第一个线程读锁抢锁后,设置自己为第一个读锁线程,然后重入设置为 1

这时不是同一个线程了,但是 !!
没有第二个线程的变量给它直接设置为自己,也没有直接的 重入次数给它记录。

所以用到了一个计数器的类 HoldCounter,专门来记录读锁的重入次数

  1. 先取出缓存的 HoldCounter计数器
  2. 如果没有(null),或者不属于当前线程自己的
  3. 就去 readHolds 里把自己的取出来,并且存入缓存
  4. 否则,如果计数器的值为 0,就放入 readHolds
    (如果计数器为 0,说明还没用过,不敢保证 readHolds 中一定含有这个计数器,所以要把它放进去)
  5. 计数器的 值+1

(这样用一个每个线程专有的计数器,保证了每个线程的读锁重入次数都能够记录)

// 进入尝试加读锁的方法
protected final int tryAcquireShared(int unused) {

    // ...省略相同代码(忘记的回到前面复习)
    
        } else {
            // 这一次由于不是第一个线程获取读锁了,所以进 else 方法
            // 首先获取一个 统计重入数量的 缓存
            // 因为第一次获取,所以一定是 空(null)
            /**
             * 这个东西是用来统计共享锁的重入次数的
             */
            HoldCounter rh = cachedHoldCounter;
            /**
             * 如果为空 或者 它的线程id不是当前线程id
             * 说明没有直接拿到当前线程的 重入计数器
             * 那就要去 readHolds 取出它的计数器
             */
            if (rh == null ||    // 空
                rh.tid != LockSupport.getThreadId(current))//不是当前线程的
                // 从 readHolds 取出当前线程的计数器
                cachedHoldCounter = rh = readHolds.get();
            // 如果计数器为 0,说明还没用过
            // 不敢保证 readHolds 中一定含有这个计数器,所以要把它放进去
            else if (rh.count == 0)
                readHolds.set(rh);
            // 计数器的值+1
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}

HoldCounter 计数器

一共有两个变量,count 和 tid

  • count:记录重入次数
  • tid:线程 id(final 不可更改保证安全)
static final class HoldCounter {
    int count;          // initially 0
    // 创建计数器初始化时 LockSupport 获取线程 id
    // 并且 final 不可改变保证了安全
    final long tid = LockSupport.getThreadId(Thread.currentThread());
}

readHolds 保存各个计数器

首先我们可以看到 readHolds 是一个 ThreadLocalHoldCounter 对象

private transient ThreadLocalHoldCounter readHolds;

点进去,发现实际上,不过是一个 ThreadLocal
因此每个 不同的线程 都可以从 readHolds 里取出属于自己的 HoldCounter 计数器
(ThreadLocal 不知道的先去补补课。。。)

// 本质上是一个 ThreadLocal
static final class ThreadLocalHoldCounter
    // 泛型用 HoldCounter计数器 替换
    extends ThreadLocal<HoldCounter> {
    // 重写了初始化方法,这样使得第一次获取的时候不为null
    // 而是一个 属于自己的 新的HoldCounter计数器
    public HoldCounter initialValue() {
        return new HoldCounter();
    }
}

cachedHoldCounter 作用

其实我觉得这个不说你们应该也能自己想出来
(但是我害怕,你们万一犯懒了,没有去想,那会不会就遗漏了点东西)
(我真是用心良苦 T_T)

这里很明显是以空间换时间的思想

首先,每一次操作的 HoldCounter计数器,都会缓存在这个变量中
这样,如果下一次使用同样的计数器,就不用从 readHolds 中去取了

所以这样对同一个读锁线程重入可以提高效率(因为这个 计数器被缓存了)
但是由于只缓存一个,所以一直切换线程重入读锁,就起不到提高效率的作用

firstReader 再思考

可以发现,从第二个来获取读锁的线程开始,都是用 TheadLocal 来进行记录的

但是,为什么第一个要单独保存???

其实我觉得,看完上一个 cachedHoldCounter 的作用,你应该已经能明白

对于只有一个线程获取读锁的情况下,就不用用到 readHolds。
这样每次都能直接拿到变量,对重入次数进行操作
而不用去 readHolds 中把计数器找出来,就能提高效率

而对于多个线程的情况下,其它线程就不能提高效率了

第二个线程加读锁小结

首先和之前一样

  1. 先判断是否有写锁持有锁(此时没有)
  2. 然后判断是否有写锁正排队结束需要抢锁(此时肯定没有)
  3. 然后 CAS 抢锁

但是抢锁成功之后就不同了

  1. 先从缓存获取计数器
    (如果是自己的,可以不用去 readHolds 拿,提高效率)
    (如果多个线程频繁切换来获取读锁,那么缓存起不到作用)
  2. 如果为空,或者不是自己的
  3. 就去 readHolds 拿去(本质是一个 ThreadLocal)
  4. 然后计数器自增 1

多线程竞争读锁

CAS 失败后作何抉择

当时说了 3 种情况

  1. 判断发现有 写锁 的线程正准备出队,要获取写锁
  2. 发现读锁的获取次数爆满
  3. CAS 失败

这些情况会使得代码进入最后一行的方法(fullTryAcquireShared),并传入当前线程作为参数

protected final int tryAcquireShared(int unused) {

    // ...省略无关代码

    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        // 竞争条件总有线程会 CAS 失败
        compareAndSetState(c, c + SHARED_UNIT)) {
        // ...省略无关代码
    }
    // 所以不会进 if
    // 会进入该方法(传入当前线程作为参数)
    return fullTryAcquireShared(current);
}

fullTryAcquireShared CAS失败后的加锁方法

当时判断有写锁线程正准备出 AQS 队列抢写锁,方法叫 readerShouldBlock(读锁应该阻塞)
但是它并不是立刻就真的阻塞了
而是运行到这个方法里面之后,又进行了一次重复的判断,如果这次的 readerShouldBlock 还是返回 true,那么才真的让方法返回 -1,结束该方法
(这个方法和上个方法有很多地方类似、冗余,很容易理解)

不过想一想,这样有什么好处??
为什么不直接阻塞,而是要多给它一次机会去再判断一次??
如果我的上一篇 ReentrantLock 的 AQS 讲解你们看了,我觉得你们应该能反应过来

这就是多自旋一次,避免直接阻塞

还有:
这一次也会重新判断是不是,读锁的获取数量是否爆满,这一次如果还是爆满,那就抛出异常了

当然最常见的还是 CAS:
第一次 CAS 失败后,会进入到这个方法继续给它机会去不断地 CAS(只要没有写锁来阻塞它)
我们看代码:

// 读锁加锁方法,会不断循环
// 直到成功,或发现写锁,需要阻塞
final int fullTryAcquireShared(Thread current) {
    HoldCounter rh = null;
    // 熟悉的死循环
    // (拿不到锁誓不罢休)
    for (;;) {
        // 获取 state
        int c = getState();
        // 判断是否有其他线程占有写锁
        // 如果有就 返回 -1 加锁失败
        if (exclusiveCount(c) != 0) {
            if (getExclusiveOwnerThread() != current)
                return -1;
        } else if (readerShouldBlock()) {
            /**
             * 熟悉的 应该阻塞方法
             * 此时需要阻塞,所以必须加锁失败,返回 -1
             * (除非是来重入的,也就是之前已经加过锁了,
             * 那么此次加锁,不必阻塞)
             */
            // 如果不是第一个加读锁线程
            // (如果是的话,肯定是来重入的,就不用判断了)
            if (firstReader == current) {
            } else {
                // 否则,就去找它的计数器,如果不为0,那么也是来重入的
                if (rh == null) {
                    rh = cachedHoldCounter; // 缓存中找
                    if (rh == null ||
                        rh.tid != LockSupport.getThreadId(current)) {
                        // 缓存没就去 readHolds 中拿
                        rh = readHolds.get();
                        // 如果为 0,那么说明不是来重入的,此时要 返回-1
                        // 但是在返回 -1 之前,要把 readHolds 中的记录先清除(避免内存泄漏)
                        // (ThreadLocal不手动清除会内存泄漏)
                        if (rh.count == 0)
                            readHolds.remove();
                    }
                }
                // 前面已经清楚了readHolds的记录,这时可以返回了
                if (rh.count == 0)
                    return -1;
            }
        }
        // 如果读锁获取数量爆满了,就抛异常(一般不会)
        if (sharedCount(c) == MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // CAS 抢锁
        if (compareAndSetState(c, c + SHARED_UNIT)) {
            // (感觉这个if永远不会执行到,因为这是在CAS成功的条件下执行的
            // 所以至少会有 sharedCount 至少也要是 1)
            // 也许是 Doug Lee 只是为了保证代码不会出错才写的吧
            // 如果你们发现有这个情况的话可以给我留言
            if (sharedCount(c) == 0) {
                firstReader = current;
                firstReaderHoldCount = 1;
            } // 后面的代码与之前的无太大差别
            else if (firstReader == current) { // 重入
                firstReaderHoldCount++;
            } else { // 将自己的计数器 +1
                if (rh == null)
                    rh = cachedHoldCounter;
                if (rh == null ||
                    rh.tid != LockSupport.getThreadId(current))
                    rh = readHolds.get();
                else if (rh.count == 0)
                    readHolds.set(rh);
                rh.count++;
                cachedHoldCounter = rh; // cache for release
            }
            return 1; // 加锁成功,返回 1
        }
    }
}

写锁阻塞读锁

我们在上文分析过,
如果没有写锁来阻塞的话,那么在 tryAcquireShared 方法中,读锁一定会在某一刻抢到锁(默认不要管爆满情况)
所以 tryAcquireShared 就会返回获取读锁的数量,也就不会 <0

假设,这时,有写锁来了,那么读锁的 tryAcquireShared 就会失败,返回 -1
那么这时就会运行到下面的方法 doAcquireShared

public final void acquireShared(int arg) {
    // 尝试加锁失败,说明碰到了写锁
    if (tryAcquireShared(arg) < 0)
        // 那么才会进下面的方法
        doAcquireShared(arg);
}

doAcquireShared 阻塞获取读锁

看到这里我们会发现,和之前我们阅读 ReentrantLock 时的方法非常类似

首先也是调用 addWaiter 入队,但是,
注意,这里的 addWaiter 传入了一个新的参数,叫 Node.SHARED

然后就会进入一个死循环,开始熟悉的套路

  1. 获取前置结点
  2. 如果是头结点,就尝试加锁(自旋的套路)
  3. 如果加锁成功,就设置头结点
  4. 否则判断是否应该 park
  5. 如果应该 park,就开始 park 阻塞

这里的套路和之前一模一样
又是两次自旋,
第一次,会将前一个等待的线程的结点的 ws 设置为 -1
然后第二次,看到前一个 ws 是 -1,于是开始 阻塞

private void doAcquireShared(int arg) {
    // 熟悉的 addWaiter 方法
    // 但是注意,这里的传的参数就不一样了
    // 用带 Node.SHARED 参数的构造方法,创建了结点
    // (表示这是要获取共享锁的线程的结点)
    final Node node = addWaiter(Node.SHARED);
    
    // 下面的方法和我们学习过的一致
    boolean interrupted = false; // 记录是否被打断过
    try {
        // 熟悉的死循环
        for (;;) {
            // 获取前置结点
            final Node p = node.predecessor();
            // 如果前面的结点就是头结点
            if (p == head) {
                // 这时就再次尝试加锁
                int r = tryAcquireShared(arg);
                // 如果加锁成功,就将自己设置为头结点
                // 不过要注意,此时的设置头结点和ReentrantLock的互斥锁有所不同
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    return;
                }
            }
            // 如果应该park
            if (shouldParkAfterFailedAcquire(p, node))
                // 然后开始park
                interrupted |= parkAndCheckInterrupt();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        throw t;
    } finally {
        if (interrupted)
            selfInterrupt();
    }
}

设置头结点的注意点

之前我们学习 ReentrantLock 的时候,头结点的设置仅仅是:
把前一个头结点剔除,自己充当头结点,并且 thread 属性设置为 null(当前持有锁线程不在队列中)

而此处的 读锁 则不同,在调用 setHead 方法之后,
要去唤醒后面的所有读锁

之前学习 ReentrantLock 的时候,我们知道,互斥锁只会去唤醒后面那一个正在等待的线程
而读锁是共享的,不能只有自己一个线程被唤醒,
所以要去后面叫醒所有的读锁,才能让读锁一起共享。

private void setHeadAndPropagate(Node node, int propagate) {
    // 设置头结点
    Node h = head; // Record old head for check below
    setHead(node);
    // 我们是加锁成功后来到的这个方法,所以 propagate 的值 >0
    // 这时我们进 if 方法
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        // 获取下一个结点
        Node s = node.next;
        // 如果 为空 或者是 共享锁
        // 就去释放共享锁
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

doReleaseShared 唤醒读锁

唤醒共享锁的方法,是读锁特有的,
因为传统的互斥锁,只会去叫醒身后的那一个线程,
而读锁,是多线程共享的,因此要去唤醒身后的读锁,才能大家共享

private void doReleaseShared() {
    // 死循环,用来唤醒下一个线程
    // 其中如果CAS失败,就会continue重新一轮循环来操作
    for (;;) {
        // 获取头结点
        Node h = head;
        // 头不为空,并且不是尾(说明队列中有其他线程在排队)
        // (否则说明没有其他线程在等)
        if (h != null && h != tail) {
            // 获取 ws
            int ws = h.waitStatus;
            // 如果是 -1 (SIGNAL = -1)
            // (我们之前学习过ReentrantLock,已经知道 -1 表示后面有线程在等待)
            if (ws == Node.SIGNAL) {
                // 这时 CAS 把 -1 改成 0
                if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
                    continue; // 否则,说明被其他线程改了,那就去下一次循环
                // 改成功就唤醒下一个线程
                unparkSuccessor(h);
            }
            // 如果 ws 为 0 (说明下一个线程开始排队了,但还没开始阻塞)
            else if (ws == 0 &&
                     // 那就用 CAS 改成 -3
                     // (因为改成功了就保证它并没有开始阻塞)
                     !h.compareAndSetWaitStatus(0, Node.PROPAGATE))
                // 如果 CAS 失败,就进行下一次循环
                // 因为可能 ws 已经从0,变成了-1,这时就需要多一个循环去将其唤醒
                continue;
        }
        // 如果 h == head,就可以结束循环
        // 如果head被修改,说明队列状态改变,则重新进行一次循环
        if (h == head)
            break;
    }
}

写锁加锁

互斥锁:类似 ReentrantLock

不管怎么样,互斥锁的加锁都会调用 AQS 的模板方法 acquire
这里我在 ReentrantLock 里面详细分析过了

我们需要关注的,则是 它们的不同点,子类重写的 tryAcquire 方法

// final 模板方法
public final void acquire(int arg) {
    // tryAcquire 由子类实现,是不同之处
    if (!tryAcquire(arg) &&
        // 后面代码都是相同的
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

写锁的重写方法 tryAcquire

AQS 你把之前基础的方法和队列关系学习理解之后,其实你再来看这些方法,都是类似的,
像这些方法,都是一些基本套路的简单修改

ReadWriteLock 的 写锁 和 ReentrantLock 一样,都是互斥锁
所以它们的方法都是共用一个 模板方法
而不同点,只是在于它们重写的 tryAcquire 而已

  1. 获取当前线程
  2. 获取 state,
    通过 state,可以获得 互斥锁的 重入值
  3. 如果不为 state 不为 0,说明有线程持有锁
  4. 如果写锁的重入为 0,说明读锁抢占着锁
    否则,如果不是当前线程持有写锁,就是别的线程正持有写锁
    那么就无法上锁
  5. 否则就可以重入
  6. 如果 c 为 0,那么就说明没有任何线程持有锁
    这时就直接 CAS 抢锁
    (虽然说前面有一个判断应不应该阻塞的方法,但是写锁重写的方法是直接返回 true,也就是写锁是不管有没有排队,直接 CAS 抢锁,是非公平的)
protected final boolean tryAcquire(int acquires) {
    // 先获取当 前线程和 state值
    Thread current = Thread.currentThread();
    int c = getState();
    // 获取出 互斥锁的重入次数(state的后16位)
    int w = exclusiveCount(c);
    // c不为0,说明有线程持有锁(不管是读锁还是写锁)
    if (c != 0) {
        // 如果w(互斥锁的重入次数)为0,那么目前只有读锁被线程持有
        // 或者 持有写锁的线程不是当前线程
        if (w == 0 || current != getExclusiveOwnerThread())
            return false; // 这样就无法获取写锁
        // 如果互斥锁的重入次数,加上这次,就超过上线(正常没人重入那么多次。。)
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            // 就抛异常(不过一般不会发生)
            throw new Error("Maximum lock count exceeded");
        // 前面的 if 都没有进
        // 那么说明目前只有自己持有锁,可以重入
        setState(c + acquires);
        return true;
    }
    // c为0,那么说明目前没有任何线程持有锁
    // 还是先判断 写锁是否应该阻塞
    // (不过这个方法,写锁的重写是直接 返回 true)
    // 也就是说,这里的写锁是非公平的(不像ReentrantLock,可以公平)
    if (writerShouldBlock() ||
        // 因为非公平,所以不管怎样都允许抢锁
        // 因此重写的方法一定返回 true,所以会进行CAS加锁
        !compareAndSetState(c, c + acquires))
        return false; // 加锁不成功返回 false
    // 老套路,加锁成功设置互斥锁的持有者为当前线程
    setExclusiveOwnerThread(current);
    return true;
}

writerShouldBlock 代表非公平

我们可以看到,写锁重写的 writerShouldBlock 方法直接返回 false
可见 ReentrantReadWriteLock 的写锁是非公平的

final boolean writerShouldBlock() {
    return false; // writers can always barge
}

写锁的解锁

final 模板方法 release

同样的,类似 ReentrantLock,解锁方法调用 release 方法
写锁只是对 tryRelease 方法进行了重写

// 互斥锁模板方法
public final boolean release(int arg) {
    // 我们只需要关注 重写的 tryRelease 方法
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

写锁重写的方法 tryRelease

同样的,类似 ReentrantLock
都是对 互斥锁的重入次数 -1

只有当重入的次数变为 0,这时说明锁已经完全释放,
于是设置持有锁的线程为 null,然后返回 true;
否则返回false。

protected final boolean tryRelease(int releases) {
    // 如果持有锁的不是当前线程,抛异常(很好理解,不解释)
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    // 这里直接获取 state 作为写锁的重入次数
    // 虽然写锁只有 后16位 !
    // 不过,由于写锁占有者锁,所以读锁的 前16位 一定全为0
    // 所以,此时的 state 的值就等于 写锁的重入次数
    // (就没必要再 与运算 了)
    int nextc = getState() - releases;
    // 减了 1 之后,看看互斥锁的重入数 是不是 0
    boolean free = exclusiveCount(nextc) == 0;
    // 0的话表示,锁已经完全释放
    if (free)
        // 然后就可以设置互斥锁的 持有线程为 空
        setExclusiveOwnerThread(null);
    // 将 state 写回内存
    setState(nextc);
    // 返回是否完全解锁
    return free;
}

从 ReentrantLock 到 ReentrantReadWriteLock 做小结

  1. AQS 是一个抽象类,里面封装了 共享、互斥 获取锁的逻辑,而各种实现,都是对 AQS 的未实现方法做一个重写
  2. AQS 内部提供了一个 state 值,不同的实现可以用它作为不同的用途。
    比如,ReentrantLock 那它做重入次数。ReadWriteLock 分别各用一半作为锁的加锁次数。
  3. 读锁可以共享,于是用 ThreadLocal 来分别记录每一个线程 重入次数
  4. 共享锁充分利用了缓存,如果同一个线程反复重入共享锁,是不会去 ThreadLocal 中取,而在缓存中拿。
    多个线程依次重入共享锁就无法利用缓存。
  5. 如果是单个线程获取共享锁,是不会用到 ThreadLocal 的
  6. AQS 内部有一个队列,而且只有在尝试加锁失败之后,在入队的时候,才会初始化
    所以没有互斥锁竞争的情况下,是不会初始化队列的
  7. ReentrantLock 可以公平是因为可以在 CAS 抢锁前看一下队列里有没有线程在排队
  8. ReentrantReadWriteLock 中的写锁是非公平的;
    而读锁面对写锁时,是公平的
  9. 队列设计巧妙,用一个 ws(waitStatus)状态值来表示目前的线程状态
    而对状态值的修改,是后一个来排队时修改前一个的状态(将前一个改成 -1,表示自己要开始阻塞了)
    而前一个看到自己变成 -1,就知道后面有线程开始阻塞了,则会唤醒后一个
  10. 有个小细节,就是队列的头中存储的线程永远为 null(也就是持有锁的线程,是不存储在队列结点中的)
  11. 有个精妙的设计,就是位于下一个获取锁的线程,不会立刻阻塞,会有一定次数的自旋
  12. interrupt 的处理十分精妙,如果不允许被打断,先把它获取并抹去,最后再给线程添加回去
  13. interrupt 的获取也有个小细节, |= 保证了是否被打断的记录,不会被覆盖
  14. 如果可以被打断,自己抛出异常,自己 catch,处理了队列之后,再把它重新抛出去
  15. 带超时的 lock 有个小细节,如果等待时间 < 1秒,就不阻塞了,自旋获取锁
  16. 结点被打断失效时,会尝试将前面的有效结点,连接后面的有效结点。
    如果失败了,就叫醒后面的有效结点,让它自己去前面找有效结点连接。
    如果前一个就是头结点,叫醒后一个是为了避免剔除自己后,头结点已经去唤醒过了
  17. 在写锁释放锁后,会先唤醒第一个等待的线程,如果是读锁
    则被唤醒的读锁,会去唤醒后面的读锁

作者的话

其实,一开始我是以为这篇文章我会写很久很久,因为之前写 ReentrantLock 的时候,花了几天时间才写完。
而且我写 ReadWriteLock 的时候,之前也没有直接阅读过源码。
结果只用了八九个小时,我就将其写完了。

但是,写着写着才发现,其实很多地方都是共通的,很多知识点和我在 ReentrantLock 里面写到的知识点都相同。
毕竟它们都是基于 AQS 实现的。掌握了 AQS 的核心,这些实现便很容可以领会。

所以我很推荐,大家从我的那篇 ReentrantLock 的文章来开始学习。
我从底层的源码,几乎将所有涉及的知识点,和其中的设计思想都给讲到了。

所以这一篇文章,我也没有花精力去画图,也没有去讲解知识点重复的代码段,因为通过上一篇 ReentrantLock 的学习,如果真的学习明白了,那你们就要已经具备了直接能看懂这些代码的能力。
而且我认为,你们也完全可以不用看博客,直接阅读源码进行学习。

不过,实际上,很少有用心学习的。很多人对源码的惧怕,很多人对阅读和分析的懒惰,致使 AQS 真正理解的人不多。

其实这也无妨,毕竟对技术的学习,本来就会有精通的人,也本来就会有只是需要使用的人。
所以,我的文章博客,也仅需要供给给那些真正需要,从底层掌握透彻的人。

发布了12 篇原创文章 · 获赞 146 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/weixin_44051223/article/details/104986892
今日推荐