AbstractQueuedSynchronizer(AQS) 之共享锁源码浅读

什么是共享锁?

阅读共享锁源码前,请先阅读独占锁源码分析,点击跳转到 AbstractQueuedSynchronizer(AQS)之 ReentrantLock 源码浅读

  • 和独占锁不同,独占锁只能允许一个线程持有,但是共享锁可以让多个线程共同去获取。
  • 锁工具对应的 tryAcquire() 实现方法不一样,tryAcquire() 的返回值如果大于等于 0 代表抢锁成功,大于 0 代表还有剩余资源供其他线程抢夺。

AQS 共享锁主要 API

  • tryAcquireShared() 获取共享锁
  • tryAcquireSharedNanos() 获取超时可中断共享锁
  • releaseShared() 释放共享锁

AQS 共享锁源码分析

首先看到 AQS 中的获取锁方法(模版方法),代码如下:

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }

方法 tryAcquireShared() 是个抽象方法,需要子类自己去实现,但是对于 tryAcquireShared() 方法的返回值,Doug Lea 大佬有规定,如下:

  • 返回值为负数,表示加锁失败
  • 返回值为正数,表示加锁成功,并且唤醒在 CLH 队列中的线程去获取锁(往后传播)
  • 返回值等于 0,表示当前线程加锁成功,但是其他线程就不能去获取锁了,除非等他释放锁之后才可以获取了
    在这里插入图片描述

针对这行代码 tryAcquireShared(arg) < 0 可以看出,就是锁已经被别的线程持有了,现在过来加锁的线程加锁失败了才会小于 0,那么这个判断就是 true,执行里面的逻辑,我们来看看失败获取共享锁的线程 AQS 会怎么样处理?

先进入方法 doAcquireShared() 内部,代码如下:

    private void doAcquireShared(int arg) {
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

看到上述代码,我们可以和独占锁的代码对比下,其实非常相近,无非就有两个地方不一样,如下图示:
在这里插入图片描述
从图中可以看出,共享锁模式下载 Node 节点的状态为共享模式,独占锁下 Node 封装为独占模式;独占锁获取加锁成功之后只调用 setHead()结束了,而共享锁模式下调用的是 setHeadAndPropagate() ,这个方法后面会分析到。这个方法主要是会去唤醒其他 CLH 中排队的线程。

现在先来分析这句代码 final Node node = addWaiter(Node.SHARED); 进入该方法内部,代码如下:

    private Node addWaiter(Node mode) {
        // 将进来获取锁的线程封装成共享模式的 Node 节点
        Node node = new Node(Thread.currentThread(), mode);
        // 把 pred 指向 AQS 尾结点(第一次是 null,第二次执行才会有值,并且是最后一个 Node 节点)
        Node pred = tail;
        if (pred != null) {
            // 把 Node 前驱指向上一个节点
            node.prev = pred;
            // 然后把 AQS tail 指针往后移,指向这个进来的 Node 节点
            if (compareAndSetTail(pred, node)) {
            	// 然后前面节点的后驱指针指向进来的 Node 节点,至此新进来的 Node 节点就成功入 CLH 队列了。
                pred.next = node;
                // 然后返回现在过来的 Node 节点
                return node;
            }
        }
        enq(node);
        // 然后返回现在过来的 Node 节点
        return node;
    }
    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { 
                // 第一个 Node 节点过来,必须先创建一个空的哨兵节点(Node 里面的属性没赋值)
                // 并发 AQS 头节点指针指向了新创建的哨兵节点
                if (compareAndSetHead(new Node()))
                    // 把 AQS 尾结点指针向了 head,head 此时指向的是哨兵节点,所以 tail 指向的也是哨兵节点
                    tail = head;
            } else {
                // 现在过来的节点的前驱指针指向了 t,t 指向的是 tail,tail 只想的是哨兵节点,所以 t 就是哨兵节点
                // 也就是把新节点的前驱指向了哨兵节点
                node.prev = t;
                // 然后再把 AQS 的 tail 指向了新过来的 Node 节点,也就是每次有新节点过来的时候,tail 都要指向它,也就是指向尾节点,这就是队列结构
                if (compareAndSetTail(t, node)) {
                    // t 保存着原来的 tail,也就是原来 t 指向的是哨兵节点
                    // 所以这里就是把哨兵节点的后驱指向了新过来的节点
                    t.next = node;
                    // 这里的返回值没啥用,直接忽略不计
                    return t;
                }
            }
        }
    }

总结下这两个方法作用,首先 enq() 方法:

  • 创建空节点(哨兵节点)
  • 维护 Node 和哨兵节点之间的关系

从上面代码看,这个方法只有在 AQS 的 tail=null 的情况下,才会进入后面过来的 Node 都会被上面的逻辑拦截,这个注意下,这个方法运行完会得到以下示意图:
在这里插入图片描述
然后再看看 addWaiter() 方法的作用(包含了 enq() 方法的功能):

  • 将加锁失败的线程封装成 Node 节点,并标识是共享还是独占锁
  • 新建 Node 哨兵节点
  • 维护 Node 之间的引用关系

总结起来一句话就是调用 addWaiter() 就是创建 Node ,入 CHL 队列,并维护Node 之间的引用关系就这样,假设又有个新节点过来,那么就入下图示入队,维护引用关系即可。
在这里插入图片描述
分析完 addWaiter() 方法,回到这个 doAcquireShared() 方法,如下:

    private void doAcquireShared(int arg) {
        // 这里已经将获取锁失败的线程入 CHL 队列了。
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
            	// 拿出节点的前驱节点
                final Node p = node.predecessor();
                // 如果 p 是头节点那么开始尝试去加锁
                if (p == head) {
                    // 尝试加锁,假设成功了,肯定是返回0或者是大于0的正数
                    // 如果是 0 没啥说的,加锁成功,该线程出队,因为线程已经加锁成功了,没必要再 CLH 队列待着了
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                    	// 这个方法我们提到代码外部解释,看下面,看完之后再回来
                    	// 假设你看完了这个方法内部的实现,现在回到这边,当前线程继续往下走
                        setHeadAndPropagate(node, r);
                        // 把 p 的后驱指向赋值为 null ,方便 gc,至此这个线程就是标识加锁成功了
                        // 然后经过 setHeadAndPropagate() 方法里面又把当前节点的后驱节点唤醒了,它也就开始去尝试加锁了,后面的逻辑就是重复这个过程了,看完这里我们在去看释放的代码,往后面看,找到释放的逻辑。
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                // 但是如果上述不是头结点,或者加锁失败了,就会过来执行方法 shouldParkAfterFailedAcquire()
                // 这个方法前面分析过了,就是在线程被挂起之前需要把线程的 waitStatus修改成 -1 状态(线程挂起之前都会有这个操作)
                if (shouldParkAfterFailedAcquire(p, node) &&
                    // 假设成功设置为 -1 ,表示这个线程要被挂起了,就会调用 LockSupport 方法直接挂起线程
                    // 此时注意被挂起的线程都是停止在这里,蓄势待发状态。
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

看到方法 setHeadAndPropagate(node, r); , 代码如下:

    private void setHeadAndPropagate(Node node, int propagate) {
    	// 将 AQS 的 head 赋值给 h 临时变量放着,后面要使用
        Node h = head; 
        // 主要是把 AQS 的 head 头指针往后移动,表示有 Node 节点出队列。
        setHead(node);
		
		// 前面提到的 Doug Lea 大佬对 方法 tryAcquireShared() 规定了返回值,负数,和 0 、大于0的正数
		// propagate 就是这个方法的返回值,如果你在子类实现这个 tryAcquireShared() 方法返回大于 0 的正数
		// 这下面的 if 逻辑就直接进入,后面的判断都不会走了。
		// 返回大于0的正数,表示当前线程可以加锁成功,而且还会通知其他线程也过来尝试加这把锁,下面就是干了这个事情
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            // s == null 在并发场景下,还没有来得及给 node 后驱指向赋值,不就是 null 嘛
            // 然后要判断该节点是否为共享模式还是独占模式,在 addWaiter() 方法已经封装了这个属性,这里就用到了
            if (s == null || s.isShared())
            	// 看后面代码分析,摘到外面去分析了,看下面,看完之后回来
                doReleaseShared();
        }
    }
    // 主要是把 AQS 的 head 头指针往后移动,表示有 Node 节点出队列。
    private void setHead(Node node) {
    	// 将 AQS 的 head 指向了当前线程运行的 Node 节点
        head = node;
        // 因为 Node 封装的线程已经加锁成功了,自然而然要把 thread 置成 null 值
        node.thread = null;
        // 同时也要把 Node 的前驱指向断开,方便上一个完成使命的哨兵 GC 回收
        node.prev = null;
    }

进入到方法 doReleaseShared() 内部,代码如下:

    private void doReleaseShared() {
		// 自旋模式,肯定有在某个地方可以跳出
        for (;;) {
            // 注意这句代码,每次都是先拿到 AQS 的 head,也就是从 CLH 队列的头开始往后找
            Node h = head;
            // 判断是不是尾节点,如果是 tail 节点那就没必要往后面通知了,因为后面已经没节点了,通知个毛
            // 这个判断也就是说明该节点再后面还有很多节点再排队
            if (h != null && h != tail) {
                // 取出 waitStatus,正常运行的被阻塞挂起的线程的 waitStatus 都是 -1
                int ws = h.waitStatus;
                // 所以这个条件成立
                if (ws == Node.SIGNAL) {
                	// 还原阻塞线程的 waitStatus,从 -1 修改会 0,直到修改成功,否则一直尝试修改。
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    // 修改成功,那就要开始去唤醒下一个线程,准备过来尝试加锁了,
                    // 这个方法内部实现,摘到外面去了,看下面,看完再回来,因为这是个 for 循环,后面还有过来执行上面的逻辑。
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            if (h == head)                   // loop if head changed
                break;
        }
    }
    private void unparkSuccessor(Node node) {
        // 上面已经讲 waitStatus 修改成 0 
        int ws = node.waitStatus;
        // 这里会在此判断 waitStatus 是否修改成功了没?如果没有修改成功,这里还会把这个状态修改成 0
        // 这就是 dobule check,保证了代码的健壮性。
        if (ws < 0)	
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        // node 注意是外面穿过的,是 head 指向的哨兵节点哦
        // 所以 node 的后驱节点不就是第一个真正封装了线程但是加锁失败后被挂起的线程么
        Node s = node.next;
        // 下面这段逻辑主要是用来维护,当你在唤醒的时候,发现后驱节点有取消的,也就是说 CLH 队列中阻塞的线程它
        // 被取消了,那么取消的 Node 肯定不能待在 CLH 队列,必须踢出去,那么如果踢出去了,那不就会把原来的
        // CLH 队列断开么?所以这里就是去维护后驱节点被取消然后 CLH 队列完整性实现
        // 注意这里是从 CLH 队列尾部开始死命往前找,一直找到第一个不被取消的节点,看下面的示意图方便理解
        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);
    }

后驱节点被取消了,维护 CLH 队列示意图如下:
在这里插入图片描述
这里就会从 Node6 开始找,然后一直往前扫描,知道找到 Node3,注意不是 Node5,是 Node3,然后再执行 Node1 的时候,他就要唤醒 Node3,本来是要唤醒 Node2 的,但是因为 Node2 取消了,所以要唤醒 Node3。

上述代码都是共享锁加锁逻辑,先我们看看共享锁释放逻辑干了啥事?代码如下:

    public final boolean releaseShared(int arg) {
        // 假设这里释放共享锁成功
        if (tryReleaseShared(arg)) {
        	// 释放成功,后面肯定是要去看看唤醒阻塞挂起的线程呗,叫他们醒来过来加锁了
            doReleaseShared();
            return true;
        }
        return false;
    }
    private void doReleaseShared() {
		// 又是一个自旋,肯定有个地方退出
        for (;;) {
            // 释放都是从 head 开始找
            Node h = head;
            // 该节点肯定不是哨兵节点,也不是尾节点,从中间开始
            if (h != null && h != tail) {
                // 正常情况,被阻塞挂起的线程waitStatus 都是-1
                int ws = h.waitStatus;
                // 所以这里成立
                if (ws == Node.SIGNAL) {
                	// 死命的去把 waitStatus 恢复成线程初始状态 0,直到成功,否则一直在这里转转
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    // 发现和加锁成功之后调用的方法竟然是一样的,也就是去唤醒后面的线程,这里不过多阐述了
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            if (h == head)                   // loop if head changed
                break;
        }
    }

最后再来看看 tryAcquireSharedNanos() 方法,其实就多了点时间的判断,如下:

    private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (nanosTimeout <= 0L)
            return false;
        // 系统时间加上超时时间就是时间临界点
        final long deadline = System.nanoTime() + nanosTimeout;
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return true;
                }
                // nanosTimeout 表示剩余时间够不够超时时间
                nanosTimeout = deadline - System.nanoTime();
                if (nanosTimeout <= 0L)
                    return false;
                // 这里注意下,如果你设置的超时时间很多,都已经大于了系统默认 1000 ns 时间
                // 那系统肯定不会傻乎乎的在这里用你的超时时间疯狂自旋,会用自己的默认时间作为超时时间
                // 然后超过了,就去挂起不会去空转。这是个优化的地方,超时时间不要比系统默认的 1000ns 长
                if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold)
                    LockSupport.parkNanos(this, nanosTimeout);
                // 如果修改了线程中断标识,那么会被阻塞线程会响应你的请求,直接给你一个意外异常,然后退出for循环
                // 但是会执行 finally 逻辑哦,注意
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
            	// 主要是去维护
                cancelAcquire(node);
        }
    }

cancelAcquire() 代码如下:

    private void cancelAcquire(Node node) {
        // 首先判断被中断的线程 Node 是否为 null
        if (node == null)
            return;
		// 因为该 Node 是被中断然后结束退出了,所以 Node 的 Thread 属性自然而然就是 null
        node.thread = null;

        // 下面这个 while 循环是维护 CLH 队列完整性,把取消的队列踢出队列
        Node pred = node.prev;
        // 循环去找,知道找到有一个前驱节点不被取消的
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;

        // predNext is the apparent node to unsplice. CASes below will
        // fail if not, in which case, we lost race vs another cancel
        // or signal, so no further action is necessary.
        Node predNext = pred.next;

        // 因为该 Node 被中断退出了,所以 waitStatus 设置成取消状态
        node.waitStatus = Node.CANCELLED;

        // 如果改 Node 就是尾节点,那就直接踢出即可
        // 把 AQS 的 tail 指向该 Node 的前驱节点即可,该 Node 的前驱节点通过上面的 while
        // 循环已经保证了这个 pred 是正常状态的
        if (node == tail && compareAndSetTail(node, pred)) {
            // 然后 pred 的后驱指针赋值为 null,因为 pred 作为了新的尾节点,后面没有节点了
            compareAndSetNext(pred, predNext, null);
        } else {
            // 假设不是尾节点,那就说明该 Node 为中间节点然后被中断退出了
            int ws;
            // 首先排除哨兵节点,假设不是 head 节点,继续做后面的判断
            if (pred != head &&
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                pred.thread != null) {
                // 假设进来了,该 Node 节点就要被踢出队列了,然后重新维护 CLH 队列完整性
                // 保存该 Node 节点的后驱指针
                Node next = node.next;
                // 如果后驱节点不为 null,说明该 Node 存在后节点而且 waitStatus 是正常的,正常值 0或者-1 
                if (next != null && next.waitStatus <= 0)
                    // 把 pred 该 Node 的前驱节点,predNext 为该 Node 的后驱节点
                    // 然后这里直接把 pred 的后驱指针指向了 predNext,把 Node 直接踢出 CLH 队列了。
                    compareAndSetNext(pred, predNext, next);
            } else {
                unparkSuccessor(node);
            }
            // 把该 Node 的后驱节点指向自己
            node.next = node; // help GC
        }
    }

分析完以上方法,可以总结为以下3点:

  • 正常情况下,独占锁只有持有锁的线程运行完了,释放锁了,独占锁才会出队列
  • 共享锁是唤醒下一个节点,并且,下一个节点成功设置成新的 head 头节点,自己就出队列
  • 独占锁是只有在释放锁的时候,才会去看看要不要唤醒下一个节点,而共享锁在两个地方去看看要不要唤醒下一个节点,一个是在获取锁成功时,去调用 setHeadAdnPropagate() 方法尝试是否唤醒下一个线程,另一个是在释放共享锁成功之后会去唤醒线程。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

魔道不误砍柴功

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值