Java基础-AbstractQueuedSynchronizer排他锁队列

1 state常量

AQS是实现各种锁的基础,提供对state(资源)的获取和阻塞等待,阻塞的线程会放入一个队列中,AQS通过一个链表来实现该队列,我们后面来看Node链表。通俗一点来说,就是维护一个资源(state),谁要使用,就去获取这个资源,获取不到就进队列等待。也就是锁咯。
其中对state的操作均为原子操作:

	//volatile关键字修饰
    private volatile int state;
	//只读取,为原子操作
    protected final int getState() {
        return state;
    }
    //只设置值,为原子操作
        protected final void setState(int newState) {
        state = newState;
    }
    //改变值,通过CAS来保证原子操作
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

2 Node对象

锁非为共享锁和排他锁,AQS对锁的获取分为两类:SHARED(共享锁)和EXCLUSIVE(排他锁)。我们看下Node的源码:

        static final Node SHARED = new Node();
        static final Node EXCLUSIVE = null;

有两种锁就有两种实现方式:

  1. 排他锁需要实现
    tryAcquire(int):独占方式。尝试获取资源,成功返回true,失败返回false。
    tryRelease(int):独占方式。尝试释放资源,成功返回true,失败返回false。
  2. 共享锁需要实现
    tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
    tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
    Node对象中等待状态取值:
    默认值为0,表示新加入的节点。负值表示结点处于有效等待状态,而正值表示结点已被取消。
      	//表示当前结点已取消调度。当timeout或被中断(响应中断的情况下)会触发变更为此状态,进入该状态后的结点将不会再变化。
        static final int CANCELLED =  1;
        //表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。
        static final int SIGNAL    = -1;
        //表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
        static final int CONDITION = -2;
        //共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
        static final int PROPAGATE = -3;

3 排他锁lock的过程

能不能获取到锁取决于AQS子类自己对state状态的判断,AQS维护的是同步队列,将获取不到锁的线程对象维护到一个队列中进行管理。先理解每个方法的含义,看不懂就多看几遍。然后我们分析如何操作入队列和出队列的。

3.1 acquire方法

AQS不实现具体的尝试获取锁的逻辑,获取锁将由其子类去实现。排他锁获取锁失败,就会将当前线程通过addWaiter方法加入到等待队列中,为什么会执行打断线程方法,我们后面看(只是设置中断标志为true,请见线程状态)。

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    static void selfInterrupt() {
        Thread.currentThread().interrupt();
    }

3.2 addWaiter方法

加入等待队列,addWaiter(Node.EXCLUSIVE),这里的Node.EXCLUSIVE标志这是一个排他锁。源码如下

    private Node addWaiter(Node mode) {
        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;
            }
        }
        enq(node);
        return node;
    }
  1. new Node(Thread.currentThread(), mode),执行Node的构造方法,其中第一个参数为当前获取锁的线程,第二个参数为**排他锁标志(Node.EXCLUSIVE)**创建一个节点:
        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }

节点信息如图:
![在这里插入图片描述](https://img-blog.csdnimg.cn/b4b1a1a0d9184f529dd21fe0c4ed8125.png在这里插入图片描述

  1. Node pred = tail;因为要将当前新创建的节点node加入到队列中,就像你去排队一样(队伍有人),先去寻找队尾那个人,他就相当于你的前一个人,你就相当于他的后一个人,所以这里把尾节点取出来赋值给pred,表示当前node的前置节点
  2. if (pred != null)相当于tail !=null 说明队列已经存在,node加入队列就行,注意这里CAS设置尾节点如果失败进入后续的enq方法,如果CAS设置尾节点成功就表示成功将node加入到了队列中,并且返回当前node,也相当于当前队列的尾节点。
       		node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }

在这里插入图片描述

3.3 enq方法

如果队列为空就要依赖当前方法初始化队列,如果队列不为空就会一直循环,直到把node加入到队列中为止。其中tail和head均被volitile修饰,cas操作保证,在并发情况下不会构建多个队列,也不会出现多个节点指向同一个前驱节点。

    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;
                }
            }
        }
    }

和addWaiter方法中入队列的逻辑基本相同,只是这里入CAS设置node为尾节点失败了会一直循环,我们看一下如果队列为空,初始化后,再将当前node加入队列,队列是怎样的。
初始化的队列如图:
最外框是抽象出来的链表对象,表示队列,headtail为该队列的头和尾,前后节点相互关联,这样就形成了一个完整的队列
在这里插入图片描述
node节点加入到队列中后入下图:
在这里插入图片描述
到此就相当于把获取不到锁的线程成功加入到队列中了,那是不是只要加入队列中就完事了呢?并不是的,此时当前线程还并没有阻塞等待,我们接着看acquireQueued方法。

3.4 acquireQueued方法

前面的步骤已经将当前线程成功的加入到了队列中,我们看接下来AQS的操作,还是先上源码:
这里参数node,由前面我们知道是当前线程的node,arg不是AQS关注的重点,是子类获取锁逻辑的参数。

    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);
        }
    }

if (p == head && tryAcquire(arg))获取当前node的前置节点,如果是头节点就再尝试获取一次锁,获取到了说明排队排到了,把前置节点踢出队列(都排到我了,你前面的人还在队里肯定没有意义,所以移出去)。这里的逻辑比较简单。
在这里插入图片描述

3.5 shouldParkAfterFailedAcquire方法

前置节点不是头节点,或者尝试获取锁失败,是不是就要阻塞当前线程呢?还不是的。

  1. if (ws == Node.SIGNAL)如果前置节点的状态为SIGNAL,说明前置节点都再等待被唤醒,那我肯定也要进入等待状态了
  2. if (ws > 0) 如果前置状态不是再等待即状态不为SIGNAL,再判断是不是已经取消了,如果取消了,就将取消的节点移出队列
    在这里插入图片描述
    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 {
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

在这里插入图片描述

3.6 parkAndCheckInterrupt方法

  1. **LockSupport.park(this)**中断当前node节点所在的线程
  2. **Thread.interrupted()**当被唤醒后,返回当前线程的打断状态,并重置为false。
    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

Thread.interrupted():为什么要获取线程的中断状态,并将其重置为false呢?
在这里插入图片描述
从上面的过程来看好像有点多此一举了,但是考虑一种情况,当前线程被唤醒去获取锁,此时又获取锁失败,是不是又得重新执行park方法,让它继续阻塞而不能for循环,消耗CPU。在park方法源码中:若是发现当前线程中断状态为true,则直接返回不再挂起线程。若是调用Thread.isInterrupted(),中断状态没有改为false,那么当调用LockSupport.park()方法时,线程是无法挂起的。
在这里插入图片描述

3.7 cancelAcquire方法

取消获取尝试获取锁,当acquire中出现异常后会进入到当前方法中。该方法得本质就是将当前异常得节点从队列移除出去。移出去的同时要考虑需不要唤醒后继节点,那为什么要考虑这个呢?你试想一下,如果刚好有别的线程唤醒你,你正常执行完要去唤醒你后面的节点,但是现在你异常了,你不能不管后面的节点了呀,唤醒动作要传播下去,不然整个队列的线程都会阻塞了。

private void cancelAcquire(Node node) {
        if (node == null)
            return;
            
        node.thread = null;
        Node pred = node.prev;
        // 如果当前节点得前置节点有状态为1得,就都从队列中移除
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;
        // 获取前置节点得next节点
        Node predNext = pred.next;
        // 将当前节点状态设置成1
        node.waitStatus = Node.CANCELLED;
        //如果当前节点是尾节点,那就将他得前置节点设置为尾节点
        if (node == tail && compareAndSetTail(node, pred)) {
        	// 新得尾节点得,下个节点肯定为null咯
            compareAndSetNext(pred, predNext, null);
        } else {
            int ws;
            // 如果pred不是头节点,说明他在队列中间
            // 如果它得状态不是1,或者可以设置成-1,并且他得线程还不能为空
            if (pred != head &&
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                pred.thread != null) {
                // 取出当前节点得next节点
                Node next = node.next;
                // 如果next节点存在且状态不是取消,那就将它得前置节点得next节点
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            } else {
            	// pred为头节点,或者状态不为-1,或者其thread为null,都需要唤醒后继节点
                unparkSuccessor(node);
            }

            node.next = node; // help GC
        }
    }
  1. **if (node == tail && compareAndSetTail(node, pred))**判断node是不是尾节点,并且能将前置节点设置为尾节点,如果设置失败,说明node后面此时又加入了新的节点了,就不是尾节点了。
  2. if (pred != head &&
    ((ws = pred.waitStatus) == Node.SIGNAL ||
    (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
    pred.thread != null) {

    乍一看,这个判断好复杂哦,难以理解,其实在判断三种情况,首先判断前置节点是不是头节点,不是的话,判断前置节点的状态是不是正常的(这里的正常指的是前置节点状态是或者能被设置为SIGNAL),最后判断节点线程是不是null,注意最后一个判断网上很多说是判断初始化的空节点(因为初始化的空节点其thread是null),但是这里肯定不是,空节点肯定是头节点,这里明显不是头节点,所以这种说法是错误的,这里的情况是判断前置节点刚好也异常了,进入了cancelAcquire方法执行了node.thread = null;但是还没有执行node.waitStatus = Node.CANCELLED;。总结就是node前面有个正常等待被唤醒节点。
  3. **if (next != null && next.waitStatus <= 0)**执行到这里说明队列前面有正常排队的,那我后面如果有正常排队的,需要将我前后的节点关联起来。
    在这里插入图片描述

3.8 unparkSuccessor方法

解锁一个后继节点。

private void unparkSuccessor(Node node) {
		// 获取当前节点的状态,如果状态小于0,就将当前节点的状态修改成0
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        // 获取当前节点的后继节点
        Node s = node.next;
        // 如果后继节点为空或者后继节点状态为被取消,则需要遍历队列
        if (s == null || s.waitStatus > 0) {
            s = null;
            // 从队列的尾部开始遍历,依次向前取,直到前置节点为null或者为当前节点结束
            // 这里注意,虽然是倒着遍历,但是取得是正向第一个不为null并且状态小于0得那个节点
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        //如果有符合条件的节点对象,则唤醒该节点关联的线程
        if (s != null)
            LockSupport.unpark(s.thread);
    }

虽然从尾部遍历,但是最终取得是node1,而不是node3
为什么要倒着遍历取正向第一个不为null得且状态小于0得值呢?直接正向遍历不更快嘛?
在这里插入图片描述
如图所示要取得是node1节点

  • 当前节点得后继节点为null时,无法通过正向遍历找到后继节点,但是个人觉得一般应该不会出现这种情况,node得后继节点为null应该只会出现在node恰好是尾节点得情况。
  • 另一个更重要的原因是因为入队列的方法,compareAndSetTail(pred, node)当此方法返回成功,尾节点的值已变更,但是这时尾节点的前一个节点的next不一定赋值了,逻辑可以看一下前面的enq方法逻辑的图。所以如果正向遍历的话,通过node.next取值,可能会丢失尾节点。

4 排他锁unlock过程

当前节点执行完了,我得通知队列中后续节点取获取锁呀。在AQS中执行以下方法通知后续队列获取锁。

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
  1. **if (tryRelease(arg))**如何释放锁,对state的操作由子类实现,如果释放锁成功就要通知队列中的后续节点取获取锁。
  2. if (h != null && h.waitStatus != 0)头节点不为空且头节点等待被唤醒,那就执行唤醒操作unparkSuccessor方法。

5 排他锁队列

什么时候入队列,什么时候出队列呢?经过上面的代码分析,我们来总结一下入队列和出队列的情况。

5.1 入队列

获取不到锁的线程会执行addWatier方法放入队列中,节点状态刚入进去的时候是0,在shouldParkAfterFailedAcquire方法中将其设置为-1(SIGNAL),注意设置的是前面一个节点而不是当前节点。。排他锁的队列只用到了三种状态:分别是0初始化,-1(SIGNAL)等待被唤醒,1(CANCELLED)异常被取消。
在这里插入图片描述

5.2 初始化的空节点出队列

当前有个A线程第一次获取到了锁,A线程没有入队列并一直持有锁没释放,

  • 此时node线程进来了,获取不到锁,就被阻塞住了
  • 当A线程执行完毕,会通过release方法中的**if (h != null && h.waitStatus != 0)去判断是不是需要唤醒后继线程,如下队列发现需要唤醒,那就执行unparkSuccessor(head)**方法。
  • unparkSuccessor方法会先将head状态设置成0,然后将head的next节点唤醒,此时node线程从阻塞中唤醒。
  • 此时node线程再次执行acquireQueued方法中的循环,**if (p == head && tryAcquire(arg))**正好node的前置节点是头节点,并且成功获取到了锁,那就将头节点从队列中剔除出去了。node节点此时变成头节点了。
    综上所述,释放锁后的唤醒操作始终是唤醒头节点的后继节点,而不是头节点。头节点是初始化的空节点,或者是已经运行完成并释放了锁的节点。
    在这里插入图片描述
    头节点从队列断开逻辑入下图。
    在这里插入图片描述

5.3 完成运行的节点出队列

继续上面的逻辑,node节点释放锁之前有新的线程来争抢锁,那后来的线程就会继续入到队列里面,后续node释放锁后,后来的节点获取锁成功就会像5.2的逻辑一样,把node节点也踢出队列。

5.4 节点异常出队列

在acquireQueued方法中有异常处理,异常后都会执行cancelAcquire方法,当异常后需要将当前节点从队列中移除出去,移除的同时需要考虑,是不是刚好唤醒当前异常的节点去获取锁了,如果这样的话需要处理唤醒逻辑,不然唤醒就无法传递下去,整体队列就永远阻塞了。

5.4.1 刚好是尾节点异常

尾节点异常,就只需要将尾节点从队列中移除就行,首先要保证CAS能成功操作node5成为尾节点,如果不成功的话,说明有其它线程CAS操作设置了新的尾节点,此时就变成了node6异常节点就变成了中间节点异常的情况了。我们这里只考虑CAS设置尾节点成功。最后将node5的后置节点设置为null。
**compareAndSetNext(pred, predNext, null)**这个也有可能失败,但是失败没关系,说明有新的节点入到队列中来了。

        if (node == tail && compareAndSetTail(node, pred)) {
            compareAndSetNext(pred, predNext, null);

这里有个疑问点,此时node6的前置节点还是还是node5,个人理解:从垃圾回收的角度来说,node6应该没有被任何对象引用了,是根不可达的,可以被垃圾回收。
在这里插入图片描述

5.4.2 异常节点的前置正常节点是头节点

node2异常了,执行到循环判断前置节点状态是不是也异常时,此时刚好node0和node1节点也异常了,并且已经设置了状态为1 node.waitStatus = Node.CANCELLED;。此时刚好node2节点的前置节点指向了头节点。此时进入unparkSuccessor(node2)方法中。
在这里插入图片描述
唤醒node3节点后,最后将node2的next节点指向自己,帮助垃圾回收。
在这里插入图片描述
此时会唤醒node3节点,node3节点进入acquireQueued方法中尝试获取锁:
-由于此时node3的前置节点还是node2,此时进入**shouldParkAfterFailedAcquire(node2,node3)**方法中。前node3前面的异常节点都移除,node3前置变成node,node的后继变成node3。
在这里插入图片描述

  • 能获取到锁,说明node线程释放了锁,并且调用unparkSuccessor(node)方法,发现第一个后继节点异常,从尾节点开始向前遍历,最终再次唤醒node3。
    最终
    node3
    获取到了锁,就会setHead(node3),该方法内会将node3前驱节点设置成null;队列就会变成如下状态:
    private void setHead(Node node) {
        head = node;
        node.thread = null;
        node.prev = null;
    }

至此异常的节点从队列中移除。
在这里插入图片描述
node0和node1也会执行上述步骤,最终如下:这里有个疑问,那就是异常得节点前置节点都没有置空,梳理了很多次逻辑都是这样得结果,希望有大佬指点迷津。
在这里插入图片描述

5.4.3 异常节点是中间节点

  1. 在cancelAcquire方法中,队列得前置节点在正常等待锁得时候,异常需要将自己移出队列。这个方法执行完,并没有完全得移除。
			if (pred != head &&
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                pred.thread != null) {
                Node next = node.next;
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            }

在这里插入图片描述

  1. node2完成执行,调用unparkSuccessor(node4)唤醒后。node4再执行acquireQueued方法。
    在这里插入图片描述
    此时node3异常节点才从队列中真正的踢出去。
    在这里插入图片描述

5.4.4 总结一下出队列

最终出队列都是在acquireQueued方法中获取到锁后,把当前节点的前置节点置空,并且将之前把当前节点当作后置节点的节点的后置节点也置空,此时才把处理完成和它前面异常的节点一并踢出队列。
unparkSuccessor方法只是唤醒后面正常等待被唤醒的节点,不会改变队列。
shouldParkAfterFailedAcquire是将当前节点的前驱节点指向前置正常的节点,会改变队列的。
cancelAcquire是将当前节点的前驱节点指向前置正常的节点,会改变队列的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值