【Java 并发】【九】【AQS】【二】基于AQS的互斥锁机制、底层源码深度剖析

1  前言

上一节我们从整体上分析了什么是AQS以及AQS内部的数据结构,那么这节我们就从acquire和release入手,分析一下AQS为独占锁提供的机制:到底是怎么在获取资源失败进入等待队列的?以及释放资源的时候怎么唤醒后继节点的线程竞争锁的?

2  acquire 方法源码解析

首先我们看一下AQS获取资源的入口acquire方法的源码:

public final void acquire(int arg) {
    // 1.调用子类的tryAcquire方法,去获取锁
    if (!tryAcquire(arg) &&
        // 2.获取资源失败调用addWaiter方法插入等待队列
        // 3.然后调用acquireQueued方法在队列实现阻塞或者再去获取锁
        acquireQueued(
            addWaiter(Node.EXCLUSIVE),
            arg)
        )
        selfInterrupt();
}

我们看一下上述的acquire这个模板方法,上面规定了几个模板流程:
(1)流程1:第一个是调用子类的tryAcquire方法去获取锁,如果获取成功则不用走下面的逻辑了,直接返回
(2)流程2:如果获取失败,则调用addWaiter,封装互斥锁模式的Node节点进入等待队列
(3)流程3:进入等待队列之后,调用acquireQueued方法,是否需要阻塞等待,什么时候再去获取锁等,这些及具体的逻辑封装在这个方法里面。

接下来我们一个一个看哈,首先看一下addWaiter方法是怎么将节点插入到等待队列的。

2.1  addWaiter方法源码解析

调用addWaiter方法,将节点加入等待队列尾部:

// node模式为互斥锁模式
addWaiter(Node.EXCLUSIVE), arg)
private Node addWaiter(Node mode) {
    // 创建一个新的Node节点,封装当线程线程
    Node node = new Node(Thread.currentThread(), mode);
    // 获取等待队列的尾部节点
    Node pred = tail;
    // 如果此时pred!=null,说明等待队列不是空,可以尝试插入新的尾结点
    if (pred != null) {
        node.prev = pred;
        // CAS操作插入新的尾部节点,CAS操作让tail指针指向node节点
        if (compareAndSetTail(pred, node)) {
            // 插入成功,修改一下node的prev指针
            pred.next = node;
            return node;
        }
    }
    // 如果等待队列没初始化,或者CAS插入失败则走到这里
    // enq方法保证绝对将node节点插入为尾部节点
    enq(node);
    return node;
}

上面的流程大概可以归纳为如下几点:

(1)如果pre!=null,说明等待队列不是空,可以尝试cas把 tail 指向 node 节点,即cas插入一个新节点

(2)如果compareAndSetTail(prev, node)即cas将tail指针指向node节点成功,需要修改一下prev指针,保持等待队列是双向链表,然后就返回了

(3)如果等待队列为空,即tail == null;或者cas操作失败了,则进入enq方法,保证一定会将节点插入到等待队列

enq(Node node)方法源码:

private Node enq(final Node node) {
    for (;;) {
        // 获取tail节点
        Node t = tail;
        // 如果t == null说明等待队列是空的
        if (t == null) { // Must initialize
            // 等待队列是空的,必须要初始化一个空线程的Node节点
            // 然后将head和tail都指向这个没有线程的节点
            // 初始化好之后,在进入下一个循环,尝试将tail节点指向node
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            // 如果等待队列不是空的
            // 则CAS尝试将tail指针指向这个新的node节点,
            // 如果CAS成功了则说明插入队列尾部成功了,直接返回
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

这里不知道你们有没有这样的疑问,为什么尝试插入节点到尾部节点都要执行CAS操作,就是上面的那compareAndSetTail?

AQS同步工具,肯定是会存在多线程并发操作的情况啊,如果不执行CAS操作保证原子性,同时tail、head使用volatile修饰,保证可见性和有序性。这几点都保证了才能保证是线程安全的。

为什么如果等待队列是空的就需要先初始化一下,搞个空的节点作为头结点,搞一个没有线程对象的节点作为头结点?直接使用我们插入的那个节点作为头结点不行吗?

其实,这个跟AQS获取锁机制的设计有关。AQS规定:头结点必须是已经获取锁的节点或者头结点必须是一个空节点,即头结点是一个不再需要等待锁的节点!!!。

然后等待队列中的第二个节点是等待队列中即将能够获取锁的节点,就比如下图:

也就是说AQS规定了第二节点是等待队列中下一个能获取锁的节点。如果插入发现等待队列是空的,于是就初始化一个空的节点,然后在插入,这样保证自己是第二。当别的线程释放锁的时候就轮到它了。

好了,我们将addWait()方法的源码就到这里,addWait其实就是将线程插入到等待队列中,我们接着上面的那个大流程继续讲解。

2.2  acquireQueue方法源码

接下来就是acquire方法中的最后的一个模板流程,也就是acquireQueue方法就讲解acquireQueued方法的源码,我们接着看。

final boolean acquireQueued(final Node node, int arg) {
    // 获取锁是否失败的标识,fail=true表示失败了,fail=false表示获取成功
    boolean failed = true;
    try {
        // 中断标志
        boolean interrupted = false;
        for (;;) {
            // 获取当前节点的前一个节点
            final Node p = node.predecessor();
            // 如果前一个节点p是head,则说明自己是第二节点
            // 自己是第二节点则调用子类的tryAcquire再次竞争资源
            if (p == head && tryAcquire(arg)) {
                // 获取锁成功,将自己设置为头节点
                setHead(node);
                // 旧的头结点p可以被丢弃了,设置next=null,方便被GC
                p.next = null; // help GC
                // 获取锁成功了,所以fail自然就是false
                failed = false;
                // 返回被中断标识为false,表示获取锁成功了,没有被中断
                return interrupted;
            }
            // 如果上面的操作没有成功;说明自己可能不是第二节点
            // 或者自己是第二节点,但是tryAcquire方法获取锁失败了
            // 这个时候就调用shouldParkAfterFailedAcquire方法判断自己是否需要被阻塞挂起
            // 如果需要被挂起,则调用parkAndCheckInterrupted方法将自己挂起
            // 挂起后如果被别的线程唤醒,然后继续执行,尝试去获取锁
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

我们来画个图来理解一下:

上面就是acquireQueued方法源码的核心流程图,其实就是说自己插入等待队列之后:

(1)会一直判断自己是不是老二节点,如果自己是老二节点则调用子类的tryAcquire方法争抢锁,如果争抢成功了,则将自己设置成头节点,原来的头结点则可以出队了。
(2)如果争抢失败了,则调用shouldParkAfterFailAcquire方法判断自己需不需要被挂起
(3)如果自己需要被挂起,则调用parkAndCheckInterruptd方法将自己线程挂起
(4)当别的线程释放锁,将自己唤醒之后,自己又重复上面的(1)、(2)、(3)步骤了

2.2.1  shouldParkAfterFailAcquire方法源码

我们接下来继续看,shouldParkAfterFailAcquire内部的源码是怎么样判断的:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // pred为当前线程节点node的前一个节点
    int ws = pred.waitStatus;
    // 如果前一个节点的waitStatus = -1 ,即SINGAL的时候
    // 自己就需要被挂起了,当pred节点释放锁的时候发现waitStatus为SINGAL
    // 说明后面还有人等着我唤醒,则将自己的下一个节点唤醒
    if (ws == Node.SIGNAL)
        return true;
    // 如果ws>0,说明是无效状态,pred节点已经被timeout超时或者中断了
    if (ws > 0) {
        do {
            // 然后继续往前找,找到一个ws <=0 的有效节点
            // 中间的那些无效节点,全部删除
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 设置node前面的一个节点为SINGAL
        // 相当于告诉它,老哥我把你的节点状态设置成SINGAL了
        // 这个信号,说明后面有人等着你唤醒呢,你释放锁的时候记得唤醒一下
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

上面的源码我们可以再画图理解一下:

(1)线程发现如果自己的前一个节点状态是 -1,说明已经告诉前一个节点一个信号了,这个信号就是等他释放锁的信号将我唤醒(也就是说前一个几点状态是 -1 的时候,它释放锁的时候会叫醒你,你放心睡觉好了)

(2)如果发现前一个节点是无效节点,如下图所示,就删除这个无效节点,然后继续往前找,直到找到waitStatus <= 0 的有效节点

   

  也就是第三节点会被剔除掉

(3)找到前一个有效节点之后,将前一个有效节点的waitStatus 设置成-1,相当于给它一个信号(相当于告诉你前一个哥们,让它释放锁的时候叫醒你,然后你就可以安心睡大觉去了)。当前一个节点释放锁的时候需要将我唤醒啊,就如下图所示:
  

整体的总结下来其实就是:

(1)判断一下自己前面是不是存在waitStatus <= 0 的节点。如果前一个节点是waitStatus > 0的无效节点,则继续往前找,找的过程中删除遇到的无效节点。
(2)找到前一个有效节点之后,给前一个有效节点一个信号waitStatus = -1,告诉它,兄弟,我在后面等着你呢,等你释放锁的时候记得叫醒我,我先去睡觉去了。

2.2.2  parkAndCheckInterrupt方法源码

睡眠的代码就比较简单了我们来看下:

private final boolean parkAndCheckInterrupt() {
    // 到这里直接调用LockSupport的park方法将线程挂起,线程就被卡在这里了
    LockSupport.park(this);
    // 走到这里了,说明线程被唤醒了,判断一下线程是否被中断了,返回线程的中断状态
    return Thread.interrupted();
}

(1)直接调用LockSupport的park方法将线程挂起来了
(2)当线程被唤醒的时候,继续执行走到Thread.interrupted,就是返回一下自己的中断状态。如果自己被中断了,则不能继续获取锁了

我们继续回到最开始的acquire方法源码:

public final void acquire(int arg) {
    // 1. 调用子类的tryAcquire去尝试获取锁
    if (!tryAcquire(arg) &&
        // 2.获取失败调用addWaiter进入等待队列尾部
        // 3.调用acquireQueue是否需要再尝试获取锁,还是在等待队列里面沉睡
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

当tryAcquire返回false的时候会调用acquireQueued方法。acquireQueued方法又是返回线程的中断标志。所以啊,如果被中断了就会进行selfinterrupted里面,就打断当前线程的运行了。

static void selfInterrupt() {
  Thread.currentThread().interrupt();
}

2.2.3  cancelAcquire方法源码

我们可以看到方法中最后有个finally,会执行cancelAcquire方法,也就是方法返回前都会执行的,这里会判断如果获取锁失败了,就移除等待队列里面的这个节点了。我们进入cancelAcquire方法内部源码看一下:

private void cancelAcquire(Node node) {
    // 如果node节点为null,说明已经被移除掉了,直接返回
    if (node == null)
        return;
    // 设置node的thread为null,已经该节点已经无效了
    node.thread = null;
    
    // 这里不断修改prev指针,就是删除中途一样是无效的节点
    Node pred = node.prev;
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;
        
    Node predNext = pred.next;
    // 设置节点的状态为CANCELLED,即为1,为无效状态
    node.waitStatus = Node.CANCELLED;
    // 如果node节点是tail尾结点,直接设置tail为null,说明队列里面没有元素了
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    } else {
        int ws;
        // 如果节点pred不是头结点
        if (pred != head &&
            // 并且能pred节点是singal或者能将pred节点设置为singal
            // 这样让pred释放锁的时候唤醒后续线程
            ((ws = pred.waitStatus) == Node.SIGNAL ||
             (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
            // 并且pred节点是有效的,即它的thread不是空
            pred.thread != null) {
            Node next = node.next;
            if (next != null && next.waitStatus <= 0)
                compareAndSetNext(pred, predNext, next);
        } else {
            // 走到这里说明node节点前面没人在等待锁了,可能pred是头结点或者node前面的都是无效节点
            // 这个时候直接唤醒node节点的下一个节点,让他去竞争锁了
            // 嘿,兄弟,前面没人等了,你别睡了,该你去获取锁了
            unparkSuccessor(node);
        }

        node.next = node; // help GC
    }
}

我们还是画个图理解一下:

如果此时要移除图中黄色的节点:

找到无效节点删除后,然后寻找前一个有效节点,修改指针,修改有效节点的waitStatus = -1,告诉它后面还有人等着:

大致就是如果要移除node节点,大概的操作就是找到node节点的前一个有效pred的节点,修改一下指针,将pred的next节点指向node节点的下一个节点。然后设置一下pred节点的状态是-1,告诉pred节点,你释放锁的时候要唤醒后面的节点。

2.3  acquire方法小结

上面的流程大概就是acquire方法内部的全部流程了,讲到这里acquire源码的全部流程就讲解完毕了,这里我们再总结一下:

(1)acquire首先直接调用子类tryAcquyire方法去获取独占锁,如果获取成功了就直接返回了
(2)然后获取失败了会调用addWaiter方法将自己封装成一个Node节点插入到等待队列里面,这个上面我们已经进行了源码深度剖析了
(3)插入等待队里之后呢,在调用acquireQueued方法,需不需要沉睡,如果不需要会在一个for循环里面一直尝试去获取锁,这个地方我们上面也画图分析了,源码也分析了
(4)最后会进入一个finally代码块里面,判断如果获取锁失败了,要从等待队里里面移除了

3  release方法源码解析

我们接下来继续,讲解一下AQS释放锁release方法的源码是怎么样的:

public final boolean release(int arg) {
    // 1. 首先进来直接调用子类的tryRelease方法去释放锁
    if (tryRelease(arg)) {
        // 2. 如果释放锁成功,去到head头结点,然后去
        // 唤醒head节点的下一个节点
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 这里就是唤醒h节点的下一个节点的实际方法
            unparkSuccessor(h);
        return true;
    }
    return false;
}

首先,release方法作为一个模板方法,里面定义了几个模板的流程:

(1)流程1:第一步还是去调用子类的tryRelease方法去释放锁
(2)如果释放锁成功,直接找到head节点,唤醒head节点的后续节点,也就是唤醒第二节点。因为head节点是一个已经获取到锁节点、或者是一个空节点,是不再需要锁的,所以下一个等待锁的肯定是第二节点。

我们继续看unparkSuccessor方法的源码:
private void unparkSuccessor(Node node) {
    // 如果node节点是状态是 < 0,这是一下node节点状态为0
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
   // 这里是直接找到node节点的下一个节点s
    Node s = node.next;
    // 如果s是无效的节点,waitStatus > 0 或者s为null,继续往后找到
    if (s == null || s.waitStatus > 0) {
        s = null;
        // 这里是从tail往前面找,找到一个有效的节点将它唤醒
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        // 这里就是直接调用LockSupport.unpark方法将s节点的所在线程唤醒了
        LockSupport.unpark(s.thread);
}

我们再画个图来理解一下:

(1)首先就是调用子类的tryRelease方法去释放锁
(2)释放锁成功之后,调用unparkSuccessor(head)去唤醒head节点的下一个节点,由于head是头节点,head的下一个就是老二节点,所以就唤醒老二节点,让老二节点来获取锁。
(3)如果老二节点是无效节点,那就从tail 节点往前找 ,找到一个有效的节点来唤醒。

这里为什么会从tail结尾往前找呢,我的理解是我既然要唤醒某一个,尾部的肯定是新鲜的,有效节点更多,所以直接从尾部找快一些。

4  acquire、release方法汇总

我们最后画个图从整体上梳理一下acquire获取锁以及release释放锁的全流程:

5  小结

到这里,AQS获取独占锁和释放独占锁的底层源码、核心流程全部分析完毕了,有理解不对的地方欢迎指正哈。

posted @ 2023-04-05 21:40  酷酷-  阅读(170)  评论(0编辑  收藏  举报