深入理解AQS的CLH队列

前言

我们知道,AQS定义了两种队列,同步等待队列(CLH队列)和条件等待队列(CONDITION队列),在学习AQS的过程中对这两个队列总是有种雾蒙蒙的感觉,到底是怎么入队、阻塞、唤醒、出队的?到底是怎么保证并发安全的?本文以ReentrantLock来对CLH队列进行深度剖析,详细介绍CLH的结构,入队、出队过程,阻塞和唤醒过程,以及其中的并发安全问题

本文属于进阶分析,需要熟悉AQS的基本属性和方法

一、CLH队列的结构和初始化

1.结构

如下图所示,是一个双向链表,由node组成,head和tail分别指向头节点和尾节点。node有pre、next、thread、waitStatus这几个属性,队列初始时新建一个node节点,其pre、next、thread为null,waitStatus为0。每个节点的waitStatus是后续节点是否能被唤醒的信号(后面会详细介绍)
在这里插入图片描述
CLH队列用到的3种waitStatus:0:初始,1:取消,-1:需要唤醒

//表示线程已取消:由于在同步队列中等待的线程等待超时或中断
//需要从同步队列中取消等待,节点进入该状态将不会变化(即要移除/跳过的节点)
static final int CANCELLED =  1;
//表示后继节点处于park,需要唤醒:后继节点的线程处于park,而当前节点
//的线程如果进行释放或者被取消,将会通知(signal)后继节点。
static final int SIGNAL = -1;
//节点的等待状态,初始值为0
volatile int waitStatus;

2.初始化

在入队的时候,如果tail为null,说明队列还未初始化,需要初始化。新建一个node,node中的thread、pre和next均为null,cas将head指向该node,初始化就完成了。

 private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

二、阻塞和唤醒时,CLH队列入队和出队过程

先说一般情况,我们以如下代码为例,t0线程先获取到了锁,0.5s后t1进入CLH队列进行等待,又0.5s后t2进入CLH队列进行等待,2s后t0线程执行完释放锁,t1获取锁,又2s后t1线程执行完释放锁,t2获取锁,再2s后t2执行完释放锁

private static ReentrantLock lock = new ReentrantLock(true);
    public static void reentrantLock() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        lock.lock();
        log.info("Thread:{},加锁成功!",threadName);
        Thread.sleep(2000);
        lock.unlock();
        log.info("Thread:{},锁退出同步块",threadName);
    }

    public static void main(String[] args) {
        Thread t0 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    reentrantLock();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "t0");
        t0.start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    reentrantLock();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "t1");
        t1.start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    reentrantLock();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "t2");
        t2.start();
    }

执行结果如下:

00:47:16.259 [t0] INFO com.yg.edu.lock.TestInterrupt - Thread:t0,加锁成功!
00:47:18.280 [t0] INFO com.yg.edu.lock.TestInterrupt - Thread:t0,锁退出同步块
00:47:18.280 [t1] INFO com.yg.edu.lock.TestInterrupt - Thread:t1,加锁成功!
00:47:20.291 [t1] INFO com.yg.edu.lock.TestInterrupt - Thread:t1,锁退出同步块
00:47:20.291 [t2] INFO com.yg.edu.lock.TestInterrupt - Thread:t2,加锁成功!
00:47:22.310 [t2] INFO com.yg.edu.lock.TestInterrupt - Thread:t2,锁退出同步块

t1入队阻塞过程:
1)如果队列为空,先初始化队列
2)新建node1节点,加入队列
3)再将前置节点的ws从0改为-1,然后park阻塞
在这里插入图片描述
t2入队阻塞过程:
1)先新建node2节点,加入队列
2)再将前置节点的ws从0改为-1,然后park阻塞
在这里插入图片描述
t1唤醒过程:
1)t0线程执行完释放锁时,唤醒node1节点,即将前置节点的ws从0改为-1,然后unpark t1(先不考虑并发问题,后面会详细说)
2)如果t1唤醒后仍然未获取到锁(非公平锁会出现),那么将前置节点的ws从0改为-1,然后park,这就又与入队一样了
3)如果t1唤醒后获取到锁,那么将head指向node1,将node1的pre指向null,thread改为null。这时原头节点的next指向null,会被gc回收。node1成为了新的头节点。
在这里插入图片描述

t2唤醒过程:
与t1唤醒过程一样,只不过node2节点不仅成为了新的头节点,还是尾节点

三、唤醒后续节点时的特殊情况

上面说了唤醒时的一般情况,那肯定还有特殊情况

1. 并发情况

先把唤醒后续节点和阻塞的相关代码帖出来,这里面几个方法,除了cas操作,其他的每一步都有并发问题

/**释放锁**/
public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
/**唤醒后续节点**/ 
private void unparkSuccessor(Node node) {
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
		
        Node s = node.next;
        
        /**这一段是要唤醒的节点的waitStatus为取消,那么就找到下一个不是取消的节点**/
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        
        if (s != null)
            LockSupport.unpark(s.thread);
    }
/**入队后获取锁**/    
final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

下面是三种因并发导致的情况
1)线程2在park前,由线程1将waitStatus从-1变为0(线程1释放锁了),所以不再park,继续去获取锁成功
2)线程1在调用unpark前,线程2获取锁成功而出队,不需要unpark了
3)park前,线程1对线程2进行了unpark(提前唤醒),所以线程2此时park不会阻塞
在这里插入图片描述

2. 要唤醒的后续节点取消了

上面代码提到过,如果要唤醒的节点的waitStatus为取消,那么就找到下一个不是取消的节点进行唤醒,注意这里是从尾节点开始从后往前遍历的(为什么不能从前往后遍历呢,当然是因为并发问题,这个后面再说)

/**这一段是要唤醒的节点的waitStatus为取消,那么就找到下一个不是取消的节点**/
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }

但是,此时被唤醒的节点的前置节点的ws为取消状态啊,而获取锁的条件是前置节点为head且前置节点的ws为-1,所以即使被唤醒也不能去获取锁啊,那后续该怎么走呢?只能用图分析了,上图前先介绍一下相关代码:
前面acquireQueued方法中有个for循环获取锁的逻辑,其中的shouldParkAfterFailedAcquire方法如下:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        /**这里,如果ws为取消,则将node的pre设置为前置节点中第一个ws不为取消的节点**/
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

1)由于node1和node2的ws都是cancel状态,所以t0释放锁时会将t3唤醒
2)唤醒后,第一次for循环时,因为前置节点不为头结点,所以将node3的pre设置为前置节点中第一个ws不为取消的节点,同时将这个节点的next指向node3。也就是将node3前面的已取消的节点都出队了
3)唤醒后第二次for循环,此时前置节点为头节点,且ws为-1,可以去获取锁了(如果前置节点不为头结点,那么就直接park阻塞,就不展开分析了),获取锁成功则出队,失败则park阻塞

在这里插入图片描述

四、节点什么时候取消,怎么取消

前面说了唤醒后续节点时会出现后续节点被取消了的情况,那什么时候会出现节点被取消的情况呢?
节点被取消是cancelAcquire方法完成的,我们看有哪些地方调用了cancelAcquire方法:

//在等待队列中获取锁出现异常
final boolean acquireQueued(final Node node, int arg) {...}
//在等待队列中获取锁出现异常、或者阻塞后被中断唤醒
private void doAcquireInterruptibly(int arg){...}
//在等待队列中获取锁出现异常、或者阻塞后被中断唤醒、或者阻塞超时时间结束
private boolean doAcquireNanos(int arg, long nanosTimeout){...}
//在等待队列中获取锁出现异常
private void doAcquireShared(int arg) {...}
//在等待队列中获取锁出现异常、或者阻塞后被中断唤醒
private void doAcquireSharedInterruptibly(int arg){...}
//在等待队列中获取锁出现异常、或者阻塞后被中断唤醒、或者阻塞超时时间结束
private boolean doAcquireSharedNanos(int arg, long nanosTimeout){...}

其实就是三种情况:
1)获取锁期间出现异常
2)阻塞后被中断唤醒抛异常
3)阻塞超时时间结束
那么,我们看看cancelAcquire方法做了啥
代码分为两个部分
第一部分:
在这里插入图片描述
1)将node的thread置为null
2)如果node的前置节点ws为取消,从后往前遍历,直到有不为取消的节点,将node的pre指向该节点
3)将node的ws设为取消

第二部分:
在这里插入图片描述
1)如果node是中间节点(假设为下图node2),且node的next节点(也就是node3)不为取消,将不为取消的前置节点(也就是node1)的next指向node3
在这里插入图片描述
这里注意,node3的pre指针没有与node2断开,并未出队,那什么时候出队呢?前面介绍过要唤醒的后续节点取消了这一节内容时,说过shouldParkAfterFailedAcquire方法会将前面的已取消的节点都出队,这是第一种情况,第二种情况是后置节点是尾节点且被取消时,下面会介绍到。
2)如果node是尾节点(假设为下图node3),将tail指向node1,同时将node1的next置为null。(执行第一部分代码后,node2就可以被gc回收了,执行第二部分代码后,node3也可以被gc回收了)
在这里插入图片描述
3)如果node是第一个等待被唤醒的节点,唤醒node节点。前面说过唤醒的节点如果被取消了,那么就会唤醒后面的第一个等待被唤醒的节点,就不再画图介绍了

小结:
1)节点在获取锁期间出现异常、阻塞后被中断唤醒抛异常、阻塞超时时间结束时被取消
2)节点取消后不会马上出队,而是在唤醒后置节点或者取消尾节点时才出队

参考文章:https://blog.csdn.net/weixin_45433817/article/details/134538055
这篇文章把cancelAcquire方法讲的很透彻,感兴趣的可以看看链接的这篇文章

五、并发问题分析

前面留了一个问题,要唤醒的后续节点取消了时,为什么从后往前遍历寻找下一个等待被唤醒的节点?
这样是与入队时的逻辑有关,我们看下入队的代码:

private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                /**假设线程1执行到这里被挂起,此时 next 指针还没有关联到,后新来的线程 n 可能已
                经被排列到后面去了,所以当 t 被需要时,它的 next 指针还没有设置或者重置;故需要
                从后到前寻找,而如果找寻下一个,而这个可能被遗漏了*/
                    t.next = node;
                    return t;
                }
            }
        }
    }

前面说过,除了cas操作,其他所有操作都不是原子的,所以入队时,cas把tail指向新的节点后,新节点的next指向原尾节点之前,其他的节点可能入队进来,这样,队列就不能从前往后将节点遍历全,但是从后往前却可以遍历全,如下图:
在这里插入图片描述

参考文章:https://blog.csdn.net/weixin_38038479/article/details/111915280

总结

至此,CLH的入队、阻塞、唤醒、出队过程都介绍完,重点内容总结一下:
1.入队阻塞:入队后,如果获取不到锁会阻塞,阻塞前将前置节点的ws置为-1,所以一般来说,尾节点的ws为0,其他节点的ws为-1
2.唤醒出队:被唤醒后如果获取到锁,头节点出队,自己变为新的头节点。释放锁时又会唤醒下一个等待被唤醒的节点
3.唤醒下一个等待被唤醒的节点时,是从后往前遍历的,因为入队的并发问题,只有从后往前遍历才能将节点遍历全
4.取消出队:节点在获取锁期间出现异常、阻塞后被中断唤醒抛异常、阻塞超时时间结束时被取消,节点取消后不会马上出队,而是在唤醒后置节点或者取消尾节点时才出队

  • 8
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值