ReentrantLock 和 AQS解读

ReentrantLock 和 AQS 的关系

首先我们以大家最熟悉的方式带你进入这个核武器库的大门,Java 并发包下的 ReentrantLock大家肯定很熟悉了。

基本上学过Java 的都知道ReentrantLock,下面我就不多说了直接上一段代码。

ReentrantLock lock = new ReentrantLock();
try {
    lock.lock(); // 加锁

    // 业务逻辑代码

} finally {
    lock.unlock(); // 释放锁
}

这段代码大家应该很熟悉了,无非就是获取一把锁,加锁和释放锁的过程。

有同学就问了这和AQS有毛关系呀!别着急,告诉你关系大着去了。在Java并发包中很多锁都是通过AQS来实现加锁和释放锁的过程的,AQS就是并发包基础。

例如:ReentrantLock、ReentrantReadWriteLock 底层都是通过AQS来实现的。

那么AQS到底为何物尼?别急,我们一步一来揭开其神秘的面纱。

AQS 的全称 AbstractQueuedSynchronizers抽象队列同步器,给大家画三张图来说明其在Java 并发包的地位、 长啥样、和ReentrantLock 的关系。

通过此类图可以彰显出了AQS的地位、上层锁实现基本都是通过其底层来实现的。

有没有被忽悠的感觉?你没看错AQS就长这个鸟样。说白了其内部就是包含了三个组件

  • state 资源状态
  • exclusiveOwnerThread 持有资源的线程
  • CLH 同步等待队列。

在看这张图现在明白ReentrantLock 和 AQS 的关系了吧!大白话说就是ReentrantLock其内部包含一个AQS对象(内部类),AQS就是ReentrantLock可以获取和释放锁实现的核心部件。

ReentrantLock 加锁和释放锁底层原理实现

好了! 经过上面的介绍估计大家已经对AQS混了个脸熟,下面我们就来说说这一段代码。

ReentrantLock lock = new ReentrantLock();
try {
    lock.lock(); // 加锁

    // 业务逻辑代码

} finally {
    lock.unlock(); // 释放锁
}

这段代码加锁和释放锁到底会发生什么故事尼?

很简单在AQS 内部有一个核心变量 (volatile)state 变量其代表了加锁的状态,初始值为0。

另外一个重要的关键 OwnerThread 持有锁的线程,默认值为null

接着线程1过来通过lock.lock()方式获取锁,获取锁的过程就是通过CAS操作volatile 变量state 将其值从0变为1。

如果之前没有人获取锁,那么state的值肯定为0,此时线程1加锁成功将state = 1。

线程1加锁成功后还有一步重要的操作,就是将OwnerThread 设置成为自己。如下图线程1加锁过程。

其实到这大家应该对AQS有个大概认识了,说白了就是并发包下面的一个核心组件,其内部维持state变量、线程变量等核心的东西,来实现加锁和释放锁的过程。

大家有没有意识到不管是ReentrantLock还是ReentrantReadWriteLock 等为什么都是Reentrant 开头尼?

从单词本身意思也能看出,Reentrant 可重入的意思 ,也就说其是一个可重入锁。

可重入锁

就是你可以对一个 ReentrantLock 进行多次的lock() 和 unlock() 操作,也就是可以对一个锁加多次,叫做可重入锁。 来一段代码直观感受下。

ReentrantLock lock = new ReentrantLock();
try {
    lock.lock(); // 加锁1

    // 业务逻辑代码
    lock.lock() // 加锁2
    
    // 业务逻辑代码
    
    lock.lock() // 加锁3

} finally {
    lock.unlock(); // 释放锁3
    lock.unlock(); // 释放锁2
    lock.unlock(); // 释放锁1
}

作者:阅历笔记

注意:释放锁是由内到外依次释放的,不可缺少。

问题又来了?ReentrantLock 内部又是如何来实现的尼?

说白了!还是我们AQS这个核心组件帮我实现的,很 easy~ 上述两个核心变量 state 和 OwnerThread 还记得吧!

重入就是判断当前锁是不是自己加上的,如果是,就代表自己可以再次上锁,每重入一次就是将state值加1。就是这么简单啦!!!

说完了可重入我们再来看看锁的互斥又是如何实现的尼?

此时线程2也跑过来想加锁,CAS操作尝试将 state 从0 变成 1, 哎呀!糟糕state已经不是0了,说明此锁已经被别人拿到了。

接着线程2想??? 这个锁是不是我以前加上的,瞅瞅 OwnerThread=线程1 哎! 明显不是自己上的 ,悲催加锁失败了~~~。来张图记录下线程2的悲苦经历。

可是线程2加锁失败将何去何从尼?

线程2:想,要是有个地方让我休息下,等线程1释放锁后通知我下再来从新尝试上锁就好了。

这时我们的核心部件AQS又登场了!

AQS: OK! 好吧!那我就给你提供一个落脚地吧(CLH)进去待着吧!一会让线程1叫你。

线程2: 屁颠屁颠的就去等待区小憩一会去了。同样来张图记录下线程2高兴样。

此时线程1业务执行完了,开始释放锁

  • 将state值改为0
  • 将OwnerThread 设为null
  • 通知线程2锁我已经用完了,该你登场了

线程2一听,乐坏了!立马开始尝试获取锁,CAS 尝试将 state 值设为 1 ,如果成功将OwnerThread设为自己 线程2。
此时线程2成功获取到了锁,再来张图瞅瞅。

Reentrantkock总结

用一句话总结下:AQS就是Java并发包下的一个基础组件,用来实现各种锁和同步组件的,其核心分为三个组件。

  • Volatile state 变量
  • OwnerThread 加锁线程
  • CLH 同步等待队列

等并发核心组件。

AQS的CLH

同步队列

一个FIFO双向队列,队列中每个节点等待前驱节点释放共享状态(锁)被唤醒就可以了。

AQS如何使用它?

AQS依赖它来完成同步状态的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。

Node节点面貌?

static final class Node {
        // 节点分为两种模式: 共享式和独占式
        /** 共享式 */
        static final Node SHARED = new Node();
        /** 独占式 */
        static final Node EXCLUSIVE = null;

        /** 等待线程超时或者被中断、需要从同步队列中取消等待(也就是放弃资源的竞争),此状态不会在改变 */
        static final int CANCELLED =  1;
        /** 后继节点会处于等待状态,当前节点线程如果释放同步状态或者被取消则会通知后继节点线程,使后继节点线程的得以运行 */
        static final int SIGNAL    = -1;
        /** 节点在等待队列中,线程在等待在Condition 上,其他线程对Condition调用singnal()方法后,该节点加入到同步队列中。 */
        static final int CONDITION = -2;
        /**
         * 表示下一次共享式获取同步状态的时会被无条件的传播下去。
         */
        static final int PROPAGATE = -3;

        /**等待状态*/
        volatile int waitStatus;

        /**前驱节点 */
        volatile Node prev;

        /**后继节点*/
        volatile Node next;

        /**获取同步状态的线程 */
        volatile Thread thread;

        /**链接下一个等待状态 */
        Node nextWaiter;
        
        // 下面一些方法就不贴了
    }

CLH同步队列的结构图

这里是基于CAS(保证线程的安全)来设置尾节点的。

入列操作

如上图了解了同步队列的结构, 我们在分析其入列操作在简单不过。无非就是将tail(使用CAS保证原子操作)指向新节点,新节点的prev指向队列中最后一节点(旧的tail节点),原队列中最后一节点的next节点指向新节点以此来建立联系,来张图帮助大家理解。

源码
源码我们可以通过AQS中的以下几个方法来了解下
addWaiter方法

private Node addWaiter(Node mode) {
// 以给定的模式来构建节点, mode有两种模式 
//  共享式SHARED, 独占式EXCLUSIVE;
  Node node = new Node(Thread.currentThread(), mode);
    // 尝试快速将该节点加入到队列的尾部
    Node pred = tail;
     if (pred != null) {
        node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        // 如果快速加入失败,则通过 anq方式入列
        enq(node);
        return node;
    }

先通过addWaiter(Node node)方法尝试快速将该节点设置尾成尾节点,设置失败走enq(final Node node)方法

private Node enq(final Node node) {
// CAS自旋,直到加入队尾成功        
for (;;) {
    Node t = tail;
        if (t == null) { // 如果队列为空,则必须先初始化CLH队列,新建一个空节点标识作为Hader节点,并将tail 指向它
            if (compareAndSetHead(new Node()))
                tail = head;
            } else {// 正常流程,加入队列尾部
                node.prev = t;
                    if (compareAndSetTail(t, node)) {
                        t.next = node;
                        return t;
                }
            }
        }
    }

通过“自旋”也就是死循环的方式来保证该节点能顺利的加入到队列尾部,只有加入成功才会退出循环,否则会一直循序直到成功。

上述两个方法都是通过compareAndSetHead(new Node())方法来设置尾节点,以保证节点的添加的原子性(保证节点的添加的线程安全。)

acquireQueued(final Node, int arg)

final boolean acquireQueued(final Node node, int arg) {
    // 是否拿到资源
    boolean failed = true;
        try {
            // 标记等待过程中是否被中断过
            boolean interrupted = false;
            // 自旋
            for (;;) {
               // 获取当前节点的前驱节点
               final Node p = node.predecessor();
               // 如果其前驱节点为head 节点,说明此节点有资格去获取资源了。(可能是被前驱节点唤醒,也可能被interrupted了的)
               if (p == head && tryAcquire(arg)) {
               // 拿到资源后将自己设置为head节点,
                   setHead(node);
                  // 将前驱节点 p.next = nul 在setHead(node); 中已经将node.prev = null 设置为空了,方便GC回收前驱节点,也相当于出列。
                  p.next = null; // help GC
                  failed = false;
                  return interrupted;
              }
              // 如果不符合上述条件,说明自己可以休息了,进入waiting状态,直到被unpark()
              if (shouldParkAfterFailedAcquire(p, node) &&
              parkAndCheckInterrupt())
              interrupted = true;
            }
     } finally {
        if (failed)
            cancelAcquire(node);
     }
}

当前节点的线程在‘死循环’中尝试获取同步状态,前提是只有其前驱节点为head节点时才有尝试获取同步状态的资格,否则继续在同步队列中等待被唤醒。

Why?

  • 因为只有head是成功获取同步状态的节点,而head节点的线程在释放同步状态的同时,会唤醒后继节点,后继节点在被唤醒后检测自己的前驱节点是否是head节点,如果是则会通过自旋尝试获取同步状态。
  • 维护CLH的FIFO原则。该方法中节点自旋获取同步状态。

shouldParkAfterFailedAcquire(Node pred, Node node)

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // 拿到前驱的状态
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
           // 如果已经告诉过前驱节点,获取到资源后通知自己下,那就可以安心的去休息了。
            return true;
        if (ws > 0) {
           // 如果前驱节点放弃了,那就循环一直往前找,直到找到一个正常等待状态的节点,排在他后面
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
        // 如果前驱状态为0 或者 PROPAGATE 状态, 那就把前驱状态设置成SIGNAL,告诉它获取资源后通知下自己。
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

此方法检测自己前驱节点是否时head节点,如果是则尝试获取同步状态,不是则再次回到同步队列中找到一个舒适地方(也就是找到一个waitStatus > 0 的节点,排在他后面继续等待)休息,并告诉前驱节点释放同步状态或者被中断后通知自己下(compareAndSetWaitStatus(pred, ws, Node.SIGNAL))。

注意:在此查找一个舒适区域休息(waitStatus > 0 的节点)时那些不符合条件的节点会形成了一个无效链,等待GC回收。

private final boolean parkAndCheckInterrupt() {
        // 调用park方法是线程进入waiting 状态
        LockSupport.park(this);
        //如果被唤醒查看是不是被中断状态
        return Thread.interrupted();
    }

最后调用park方法使节点中线程进入wating状态,等待被unpark()唤醒。

出列操作

同步队列(CLH)遵循FIFO,首节点是获取同步状态的节点,首节点的线程释放同步状态后,将会唤醒它的后继节点(next),而后继节点将会在获取同步状态成功时将自己设置为首节点,这个过程非常简单。如下图

设置首节点是通过获取同步状态成功的线程来完成的(获取同步状态是通过CAS来完成),只能有一个线程能够获取到同步状态,因此设置头节点的操作并不需要CAS来保证,只需要将首节点设置为其原首节点的后继节点并断开原首节点的next(等待GC回收)应用即可。


CLH总结

同步队列就是一个FIFO双向对队列,其每个节点包含获取同步状态失败的线程应用、等待状态、前驱节点、后继节点、节点的属性类型以及名称描述。

其入列操作也就是利用CAS(保证线程安全)来设置尾节点,出列就很简单了直接将head指向新头节点并断开老头节点联系就可以了。

---------------------------------------------------------转载------------------------------------------------------------------

原文:

作者:阅历笔记
链接:https://www.jianshu.com/p/6fc0601ffe34
来源:简书

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值