Java并发--AQS源码分析

  • 一、AQS整体设计思路
    • 1.1 CAS操作+volatile关键字+改造的CLH队列
  • 二、独占模式
    • 2.1何为独占模式
    • 2.2 独占模式下获取共享资源
      • 2.2.1 直接获取了共享资源的操作权
      • 2.2.2 没有获取了共享资源的操作权
    • 2.3 独占模式下释放共享资源
      • 2.3.1 释放资源
  • 三、共享模式
    • 3.1共享模式获得资源

如果说CAS操作,是J.U.C包的灵魂,那么AbstractQueuedSynchronizer(抽象队列同步器,简称AQS),就是J.U.C包的骨架,基于AQS,J.U.C包得以实现了经典的重入锁、读写锁、CountDownLatch(计数锁)、Semaphore(信号量)和FutureTask这种实现了异步回调机制的类。

如果文章中由任何不妥或者谬误之处,请批评指正。

一、AQS整体设计思路

1.1 CAS操作+volatile关键字+改造的CLH队列

Java并发--AQS源码分析_第1张图片

首先AQS内部维护了一个变形的CLH队列,一个基于AQS实现的同步器,这个同步器管理的所有线程,都会被包装成一个结点,进入队列中,所有所有线程结点,共享AQS中的state(同步状态码)。

AQS中的state状态码是一个volatile变量,而对状态码的修改操作,全部都是CAS操作,这样就保证了多线程间对状态码的同步性,这种方式也是我们之前所说的CAS常用的操作模式。

二、独占模式

2.1何为独占模式

一个时间段内,只能有一个线程可以操作共享资源,这就是独占模式。我们常见的同步锁就是一种独占模式。

 AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

AQS中有四个核心的顶层入口方法:
acquire(int)release(int)acquireShared(int)releaseShared(int)
以AQS为基础实现的同步器类,只需要合理使用这些方法,就可以实现需要的功能。

显而易见:acquire(int)release(int)是独占模式中的方法。

acquireShared(int)releaseShared(int)是共享模式中的方法。

2.2 独占模式下获取共享资源

2.2.1 直接获取了共享资源的操作权

首先来看acquire(int)这个入口方法

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

首先,这个方法的根本意思是尝试获得共享资源的操作权

分析代码逻辑,如果tryAcquire(int)方法返回了true,那么后续的代码就不会执行,也就是说直接回跳转到整个方法结束,那么tryAcquire(int)又是什么方法呢?

    protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

可以看到tryAcquire(int)方法只抛出了一个异常,实际上,这个方法AQS并不会实现,而是它的具体实现类来完成这个方法,也就是说,不同子类对于实现这个方法可以有不同的逻辑,但是需要注意的是,无论什么逻辑去实现这个方法,如果成功获得了共享资源的操作权,那么一定要返回true,否则返回false

2.2.2 没有获取了共享资源的操作权

再回头来看acquire(int)这个方法

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

显然,如果tryAcquire(arg)返回了false,也就是说当前线程没有直接获取共享资源的操作权,那么根据逻辑,就会执行addWaiter(Node.EXCLUSIVE)方法。

    private Node addWaiter(Node mode) {
        // 新建了一个含有当前线程对象的改造CLH队列节点
        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)方法入队
        enq(node);
        return node;
    }

再来看一下enq(Node)方法

    private Node enq(final Node node) {
        for (;;) { // CAS操作自旋,基本操作
            Node t = tail;
            if (t == null) { // 队尾必须初始化
                if (compareAndSetHead(new Node()))
                    // 如果队列还没有初始化,就采用CAS的方式构建队列
                    // CAS保证了多线程间数据一致性(不会同时创建多个队列)
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    // 如果队列已经初始化,就采用CAS的方式添加结点到队尾
                    // CAS在这保证了不会出现两个结点同时连接到同一个结点后面
                    t.next = node;
                    return t;
                }
            }
        }
    }

综上所述,可以看出addWaiter(Node.EXCLUSIVE)方法是用来把一个竞争资源失败的线程,包装成一个独占模式的结点,然后添加到CLH队列中,同时其中的CAS操作,避免了多线程并发操作带来的数据不一致问题

入队后的结点,会作为参数,传给acquireQueued(final Node node, int arg)方法。

这个方法的实现十分的精妙,可以说是整个获取资源操作的核心。

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false; // 中断标志位,如果被中断唤醒则为true
            for (;;) {
                // 首先获取传入结点的前一个结点
                final Node p = node.predecessor();
                // 如果前一个结点是头结点,那么就说明,这次节点有机会竞争到共享资源
                // 所以尝试竞争共享资源,如果竞争失败,则说明头结点还没有释放资源
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false; // 成功获取资源
                    return interrupted;
                }
                // 如果当前线程的节点处于队列中,会有两种情况
                // 1.前一个结点不是头结点,则说明自己在等待队列中,则判断是否可以休眠
                // 2.如果前一个结点是头结点,但竞争资源失败,也判断是否可以休眠
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

到这里,可能你会发现一些问题,AQS确实提供了线程等待队列,线程获取共享资源操作权,以及线程包装成CLH节点入队等操作,但是作为一个同步器,最核心的功能就是让没有获得共享资源操作权的线程进入等待状态(阻塞,挂起都可,只不过AQS中是使用了JVM中的线程等待状态)。

shouldParkAfterFailedAcquire方法就会做到我们期望的去睡眠线程。

 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // 首先获取前一个节点的状态码
        int ws = pred.waitStatus;
        // 如果前一个节点处于SIGNAL状态,则说明可以安全的休眠该节点包含的线程
        if (ws == Node.SIGNAL)
            return true;
        if (ws > 0) { // 如果状态码大于0,则说明前面的节点已经处于无效状态
            do { // 这个循环会把当前节点不断前移,直到它前面的节点处于有效状态
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            // 使用CAS操作把这个节点的状态码置为SIGNAL
            // 这样以来,后面的节点就能继续连接到该节点
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

再回头来看自旋里的这段代码,我们已经知道,shouldParkAfterFailedAcquire是用来确保某个节点内的线程可以安全的休眠,同时起到了一个整理CLH队列的作用。

      if (shouldParkAfterFailedAcquire(p, node) &&
          parkAndCheckInterrupt())

如果shouldParkAfterFailedAcquire返回了false,则不会进入parkAndCheckInterrupt方法,因为此时并不能休眠线程,但是如果返回true,则会直接休眠这个线程。

    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this); // 直接让当前线程进入等待状态
        return Thread.interrupted(); // 返回是否被中断唤醒
    }

至此,我们对线程竞争资源,以及竞争资源失败以后的入队,乃至入队以后线程的休眠,已经有了一个了解。

2.3 独占模式下释放共享资源

2.3.1 释放资源

还是从顶层接口开始分析

    public final boolean release(int arg) {
        if (tryRelease(arg)) { // 尝试直接释放资源
            Node h = head; // 因为占用资源的一定是队列中头节点
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h); // 进行实际意义上的解锁操作
            return true;
        }
        return false;
    }

释放资源的过程相比起获得资源,逻辑上要简单很多,而且代码也便于理解。

我们来看一下唤醒线程的关键方法,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; // 获取头节点后的下一个节点
        if (s == null || s.waitStatus > 0) {// 如果节点为null或者已经失效(取消
            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);
    }

这时候,我们已经唤醒了下一个有效的节点的线程对象,这个线程等待时,是阻塞在acquireQueued方法内的自旋for循环,在回到acquireQueued方法后,此时该线程发现,自己已经是头节点后面的节点了。于是又去tryAcquire尝试获取资源,这次它就可以顺利获取共享资源了(因为头节点所含的线程释放了资源的使用权)

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

以上就是独占模式下的释放资源过程,可以看出,释放资源以后,后面被阻塞进入等待状态的线程,又要回到获取资源的方法中,这种设计完美的保证了多线程间不会出现共享数据的访问问题,而事实上ReentrantLock就是完全使用了这种独占模式的AQS设计,只不过自己依据AQS状态码实现了可重入。

三、共享模式

3.1共享模式获得资源

所谓共享模式,就是多个线程可以同时对一个资源进行操作,你可能说这样会出现数据不一致的问题,但是往往涉及到数据读的操作,才会使用共享模式,但是涉及到写数据,就需要独占模式来实现了。

了解了独占模式下的操作,共享模式下的操作就变得简答了许多。

首先还是从顶层入口方法看起。acquireShared方法用来在共享模式下尝试获取资源。

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

这个方法很容易理解,tryAcquireShared也是一个由子类重写的方法,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);
        }
    }

独占锁模式获取成功以后设置头结点然后返回中断状态,结束流程。而共享锁模式获取成功以后,调用了setHeadAndPropagate方法,从方法名就可以看出除了设置新的头结点以外还有一个传递动作,一起看下代码:

    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) {
            // 这里体现出了共享模式的概念,如果propagate > 0 ,说明后继节点也要唤醒
            // h.waitStatus < 0 则头节点的后继节点需要被唤醒
            Node s = node.next;
            // 如果后继节点是共享类型节点,进行唤醒操作
            // 如果没有后继节点,也进行唤醒
            if (s == null || s.isShared())
                doReleaseShared(); // 这个唤醒操作不是仅仅一个节点,我们看后面代码
        }
    }
private void doReleaseShared() {
        for (;;) {
            //唤醒操作由头结点开始,注意这里的头节点已经是上面新设置的头结点了
            //其实就是唤醒上面新获取到共享锁的节点的后继节点
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                //表示后继节点需要被唤醒
                if (ws == Node.SIGNAL) {
                    //这里需要控制并发,因为入口有setHeadAndPropagate跟release两个,避免两次unpark
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;      
                    //执行唤醒操作      
                    unparkSuccessor(h);
                }
                //如果后继节点暂时不需要唤醒,则把当前节点状态设置为PROPAGATE确保以后可以传递下去
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                
            }
            //如果头结点没有发生变化,表示设置完成,退出循环
            //如果头结点发生变化,比如说其他线程获取到了锁,为了使自己的唤醒动作可以传递,必须进行重试
            if (h == head)                   
                break;
        }
    }

也就是说setHeadAndPropagate方法会重新设置一个头,然后doReleaseShared会从头向后遍历,如果是处于共享模式的节点,都会唤醒。

了解了这个以后,再回头看releaseShared就很简单了。

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

上面的setHeadAndPropagate()方法表示等待队列中的线程成功获取到共享锁,这时候它需要唤醒它后面的共享节点(如果有),但是当通过releaseShared()方法去释放一个共享锁的时候,接下来等待独占锁跟共享锁的线程都可以被唤醒进行尝试获取。


  1. 深入浅出AQS之共享锁模式
  2. Java并发之AQS详解

你可能感兴趣的:(java多线程)