手撕AQS之共享锁模式

写在前面

AQS 也是在来来回回看了源码好多遍,才有所理解。原本打算就这一篇写完,点到即止,但不知觉的又深入了,无法抗拒的代码魅力啊!所以分为两篇,一篇共享式,一篇独占式,美滋滋呢。


什么是 AQS

AQS 全称 AbstractQueuedSynchronizer ,从类名可以知道,它是一个抽象类,并且可能维护了队列,最主要的作用是作为同步器。

JUC 包下,很多同步工具类使用了它,使用的方式并不是直接继承该类,而是使用内部类的方式;

有关同步工具类,可以参考后续的推荐博文。

这样看来,还是“半知半解”,且看下文。


如何理解 AQS

我所理解同步的本质持有锁,访问共享资源,释放锁;共享资源的访问是由调用方决定的,所以,只有在持有锁和释放锁上面做文章。这里先抛出问题,然后 AQS 来解决问题。
同步本质

从面向对象的角度来看,AQS 是针对同步问题的一种抽象,它并不代表某种具体的同步工具,但将同步工具中某些共性给抽取了出来,以方便编写同步工具类。

这里的共性可以理解为一些实现细节,列如:

  1. 当前线程到底如何才能挂起?

    LockSupport.park 方法;

  2. 如何表示持有锁?

    AQS 的 state 属性值,通过该值可以判断是否持有锁;

  3. 如何表示释放锁

    仍然是通过 state 值判断;

这些实现细节是比较复杂的,但我觉得聪明之处在于,把持有锁或者释放锁的请求,交由子类去实现,典型的模板方法模式。

共享锁的锁获取

先写下共享锁获取,如下面的:

	public final void acquireSharedInterruptibly(int arg)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
        if (tryAcquireShared(arg) < 0)
            doAcquireSharedInterruptibly(arg);
    }

该方法是用于共享模式下的锁获取,其中 tryAcquireShared 方法由子类实现,如果锁获取失败,线程将排队,则由 doAcquireSharedInterruptibly 方法实现,子类无需关心;子类无需关心线程如何排队,它仅仅需要关心锁是否获取成功,而对于锁的获取成功与否,是和 AQSstate 属性有关,针对该属性,仅仅提供了以下几种方法查看或者修改其值:

	protected final int getState() {
        return state;
    }
	
	protected final void setState(int newState) {
        state = newState;
    }

	protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

用流程图描述大概如下:

交互流程

上面这种流程不完全准确,不准确的原因仅仅在于真实情况更加复杂,但仍然具有参考意义;比如上面的几个点:

  1. CLH 队列:这是需要搞懂的几个点,这个队列是链表实现的,存放的数据元素是线程,以及一个 waitStatus,根据该值可以决定后继线程的状态,所以,会有一个哨兵节点,该节点并未持有线程,但拥有等 waitStatus 值。
  2. park 和 unpark:节点持有了线程,那么在合适的情况下,就可以通过 LockSupport.parkLockSupport.unpark 方法控制线程的执行了。

上面的两个知识点比较重要的,CLH 队列是一种理论的实现,还有 CAS ,这是需要底层的支持的,它解决的是原子问题,即比较值和设置值是一个原子操作,而 CAS自旋能够在不加锁的情况下,安全地对共享变量进行写操作,它的本质:比较和交换,也就是说 a 线程准备为变量设置值时,针对变量,它会有一个预期值,比如说 a 线程认为变量此刻值是 1,那么在 CAS 执行时,如果未有线程修改过该变量值,那么 CAS 执行成功;如果中途 b 线程修改了该变量值,那么 CAS 执行失败,此时,开始重新循环,利用新的值参与运算,重复以上过程,这就是自旋的意思;CAS 自旋有一个 ABA 问题,也就是说先修改了变量为 2,又重新改为 1,这时候对于 a 线程是无法感知的,这是一种特殊情况,如果处理逻辑能够容忍这种情况,那么也是没有问题的;


共享锁的释放

共享锁的释放代码如下:

    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

这也是模板方法模式,tryReleaseShared 表示锁的释放成与否,如果锁成功被释放,那么需要唤醒队列中的其它线程,所以 doReleaseShared 方法做的就是这件事;


交互

关于释放了共享锁如何换醒其它线程,我就放在这里了,这应该才是重点吧!

两个最主要的方法:doReleaseShareddoAcquireSharedInterruptibly 方法;

doReleaseShared 能够唤醒头结点的后继节点, 而 doAcquireSharedInterruptibly 方法中,后继节点被唤醒后,会重新进入循环,那时候又会调用 doReleaseShared 方法,直到唤醒完所有节点,这就是共享锁的交互过程;

	private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
        /** 
        * 创建节点,放置队列末尾,如果头节点为 null,会初始化话一个空的 node 节点,作为头节点,然后该		 * 节点将作为头结点的后继节点
        **/
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            // 注意这里的循环!!!
            for (;;) {
                // 找到上个节点
                final Node p = node.predecessor();
                if (p == head) {
                    // 尝试获取锁
                    int r = tryAcquireShared(arg);
                    // 如果成功了的话,就可以不用 park 了
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        failed = false;
                        return;
                    }
                }
                /**
                * 第一个方法判断在锁请求失败之后,是否应该 park,
                * 第二个方法则 park 当前线程;
                **/
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

共享锁请求失败后,进入上面这个方法,创建节点,入队列;在节点创建成功,入队列之后,线程被 park 之前,需要判断是否能够成功获取锁,这样的话,就不用 park 了;为什么要用 p == head 作为获取锁的 if 条件呢?想想看,队列是有序的,如果上一个节点都还在 park 状态,那么当前节点是不是不能抢在它之前 ,提前结束掉!这也是用于当前线程在 park 结束后,重新唤醒其后继节点的线程的;

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

修改上一节点的 waitStatusNode.SIGNAL ,所以当前线程在进入doAcquireSharedInterruptibly 中的 for 循环时,会在这里执行失败,无法 park,直到下一次循环,判断 waitStatus 的 值为 Node.SIGNAL 才返回 true ,才会执行使当前线程 park 的 parkAndCheckInterrupt 方法。

现在队列是什么状态?两个节点,一个空的头节点,waitStatusNode.SIGNAL,一个线程被 park 的节点,waitStatus 为 初始值 0,如果再进来一个线程,新建了一个节点,那么队列变为 “ 头节点不变,前一个线程被 park 的节点的 waitStatus 值变为 1,新来的线程这个节点链接在其后,waitStatus 为 0 ”;

下面看看释放操作 doReleaseShared 方法:

	private void doReleaseShared() {
        /*
         * Ensure that a release propagates, even if there are other
         * in-progress acquires/releases.  This proceeds in the usual
         * way of trying to unparkSuccessor of head if it needs
         * signal. But if it does not, status is set to PROPAGATE to
         * ensure that upon release, propagation continues.
         * Additionally, we must loop in case a new node is added
         * while we are doing this. Also, unlike other uses of
         * unparkSuccessor, we need to know if CAS to reset status
         * fails, if so rechecking.
         */
        // 注意循环
        for (;;) {
            Node h = head;
            // 从头节点开始
            if (h != null && h != tail) {
                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;
        }
    }

从头节点开始,唤醒后继节点,这里主要看看 unparkSuccessor 方法:

	private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        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 s = node.next;
        // 如果后继节点不存在,或者等待状态值大于0,则倒过来开始从尾节点开始找
        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;
        }
        // 节点线程被 unpark
        if (s != null)
            LockSupport.unpark(s.thread);
    }

节点线程被 unparkdoAcquireSharedInterruptibly 方法中的 for 循环会重新进入:

			// 省略了其它代码
			// 注意这里的循环!!!
            for (;;) {
                // 找到上个节点
                final Node p = node.predecessor();
                if (p == head) {
                    // 尝试获取锁
                    int r = tryAcquireShared(arg);
                    // 如果成功了的话,就可以不用 park 了
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        failed = false;
                        return;
                    }
                }
                /**
                * 第一个方法判断在锁请求失败之后,是否应该 park,
                * 第二个方法则 park 当前线程;
                **/
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();

这个时候,tryAcquireShared 会返回 1,一个大于 0 的数,所以重要的 setHeadAndPropagate 方法来了,先看下现在的队列情况:空的头节点,ws=0,第一个线程节点,ws = Node.SIGNAL,线程已经 unpark,第二个线程节点,ws=0 ,线程还处在 park;

private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; // Record old head for check below
        setHead(node);
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }

这个时候将第一个线程节点设置为 头节点,thread 为 null,propagate 值 会大于1,下一个线程将会被唤醒,通过重新调用 doReleaseShared(),因为这也会将上面整个流程重新走一遍。

还记得 doReleaseShared 方法中的 for 循环结束条件吧!

		for (;;) {
            // 省略其它代码
            if (h == head)                   // loop if head changed
                break;
        }

在执行到第一个线程被修改为 头节点时,这里的循环就有可能结束不掉了,所以 doReleaseShared 方法会接着执行,这和 setHeadAndPropagate 方法中的该方法执行一样!真的好巧妙啊!


总结

写了两个多小时,流程真的很复杂,算是有交互了,不过也算理清楚了,搞明白几个基础理论,几个关键方法,也能略知一二啦!

推荐博文


AQS之共享锁模式


参考博文



我与风来


认认真真学习,做思想的产出者,而不是文字的搬运工
错误之处,还望指出

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值