java并发编程(6)-AQS原理剖析

183 阅读13分钟

java并发编程系列前文

  1. java并发编程(1)-并发编程基础(上)
  2. java并发编程(2)-并发编程基础(下)
  3. java并发编程(3)-ThreadLocal原理剖析
  4. java并发编程(4)-Random以及ThreadLocalRandom原理剖析
  5. java并发编程(5)-CAS以及原子性操作类原理剖析

本章是我们java并发编程系列的第六章,本章主要内容是关于AQS的原理剖析,希望大家能好好理解本章内容,这对我们理解后续章节内容非常重要。

另外本章内容是默认大家已经具备一定基础,最起码要对CAS已经有了一定的了解,如果你是新同学,那可以先去看看我们java并发编程系列的第五章,有了CAS基础再来看本章。

LockSupport

在正式进入AQS之前,我们先来认识一下LocalSupport。它是AQS的基石,所以在讲AQS之前,认识一下它还是很有必要的。

LockSupport主要的功能就是让我们在阻塞或者唤醒线程时更加灵活。一般我们要阻塞或者唤醒线程分别使用对象的wait方法以及notify或notifyAll方法,它们的限制颇多。

如果我们要通过调用某个对象的wait方法来阻塞当前线程,前提就得是我们已经获取到了这个对象的synchronized锁。

如果我们要通过调用某个对象的notify或notifyAll方法来唤醒线程,那限制就更多了,不仅需要我们获得了这个对象的锁,并且只支持随机唤醒或者唤醒全部阻塞线程,做不到指定其中一个线程唤醒。

我们通过LockSupport的几个主要方法,看看如何使用它来让我做到更加灵活的阻塞或者唤醒一个线程。

public class LockSupport {
    private LockSupport() {}

    public static void unpark(Thread thread) {
        if (thread != null)
            UNSAFE.unpark(thread);
    }

    public static void park(Object blocker) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        UNSAFE.park(false, 0L);
        setBlocker(t, null);
    }

    public static void parkNanos(Object blocker, long nanos) {
        if (nanos > 0) {
            Thread t = Thread.currentThread();
            setBlocker(t, blocker);
            UNSAFE.park(false, nanos);
            setBlocker(t, null);
        }
    }

    public static void parkUntil(Object blocker, long deadline) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        UNSAFE.park(true, deadline);
        setBlocker(t, null);
    }

    public static Object getBlocker(Thread t) {
        if (t == null)
            throw new NullPointerException();
        return UNSAFE.getObjectVolatile(t, parkBlockerOffset);
    }

    public static void park() {
        UNSAFE.park(false, 0L);
    }

    public static void parkNanos(long nanos) {
        if (nanos > 0)
            UNSAFE.park(false, nanos);
    }

    public static void parkUntil(long deadline) {
        UNSAFE.park(true, deadline);
    }
    
}

可以看到LockSupport在调用unpark方法唤醒线程时需要我们传入某个线程对象来指定具体唤醒哪个线程。

如果要阻塞线程也很方便,只要调用park方法就行了。parkNanos是阻塞当前线程并且在nanos毫秒后唤醒线程。parkUntil和parkNanos的区别就是deadline是绝对时间。

另外还可以看到在几个park方法中还支持传入blocker参数,这个参数起始是为了获取当前先前线程是因为什么原因阻塞的。比如说如果我们在ObjectA中调用的park方法,我们就可以把当前对象传过去,后面就可以通过getBlocker方法知道是因为什么原因被阻塞。

AQS

AbstractQueuedSynchronizer简称AQS,JUC包中的锁底层是用AQS实现的。

AbstractQueuedSynchronizer.png 上面的就是AQS的类图,从类图中可以比较明显的看出,AQS是一个双向队列,并且队列中的元素是Node对象。在AQS的父类AbstractOwnableSynchronizer中保存的则是当前持有锁的线程

类中的state在不同锁中代表意思不同,例如在ReentrantLock中这个字段代表独占锁可重入次数,在ReentrantReadWriteLock中这个字段高16位代表读锁获取次数,低16位代表写锁可重入次数等等。

现在我们进入Node类,分析一下这个类的结构,以及各个字段的用途。

从next以及prev字段可以看出,我们说的AQS是双向队列的结论是没有问题的。另外,我们还可以在Node类中看到比较重要的一点,Node中有个Thread对象,那就说明了Node从本质上来讲,其实就是对一个线程的封装,它记录了线程的一些状态比如说线程的等待状态(waitStatus)。

推断运行逻辑

现在让我们梳理一下类图上得到的已有信息,尝试着通过已有信息推断出AQS的运行逻辑。

首先AQS是一个双向队列,队列中的元素是Node对象,而Node对象则是对线程的一个封装。如果线程顺利的竞争到了锁,那么就会在AbstractOwnableSynchronizer中记录下来。如果竞争失败了那么就会将线程封装成一个Node添加到队列中,等待被唤醒。在Node中我们记录了一个线程当前的等待状态(waitStatus),waitStatus的值有CANCELLED(线程被取消了)、SIGNAL(线程需要被唤醒)、CONDITION(线程在条件队列里面等待)、PROPAGATE(释放共享资源时需要通知其他节点)。

因为AQS是一个队列,如果我们唤醒了一个Node并且这个Node抢到了锁,那么自然而然的就需要将这个Node出队,并且它的后驱节点往前移。如果要将一个Node入队,那么就需要将Node的前驱节点设置为当前队列的尾部节点。

现在我们已经根据已有的信息推断出AQS理论的工作方式,下面我们进入源码中,看一下AQS是否如我们所设想的这般运行。(ConditionObject相关内容留到后续篇章再说)

核心方法

AQS将资源(锁)的获取方式分为独占方式和共享方式。独占方式是指同一时间只有一个线程可以持有资源,资源呈现出互斥性,排他性。共享方式是指可以有多个线程同时持有资源,资源呈现出共享性。

根据获取方式不同,AQS中具体的方法也是不同的。
在独占方式下获取资源使用acquire以及acquireInterruptibly
在独占方式下释放资源使用release
在共享方式下获取资源使用acquireShared以及acquireSharedInterruptibly
在共享方式下释放资源使用releaseShared

acquire

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

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

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

Node(Thread thread, Node mode) {     // Used by addWaiter
    this.nextWaiter = mode;
    this.thread = thread;
}

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

上面是AQS独占方式获取资源的源码,我们以此为切入点分析它的底层机制。

首先让我们无视acquire方法的形参arg以及tryAcquire方法,因为acquire方法大多都是由AQS的子类发起调用,所以arg在不同的子类中代表的意义以及范围也不径相同,而tryAcquire实际也是需要子类去重写,所以调用的都是子类重写的tryAcquire方法。目前我就认为tryAcquire方法就是去尝试获取锁,成功获取时返回true否则返回false。

感受代码设计

大家感受一下这段代码,父类只负责底层的公共的业务(Node入队),子类只负责高层的只和自身相关的业务,彼此几乎感知不到对方的存在。如果你对设计原则有一定了解,就会发现这段简简单单的代码符合单一职责原则(每个模块有且仅有一个被修改的原因)以及开闭原则(对新增开放,对修改关闭)。

对于子类而言只会因为对锁的获取逻辑调整而去修改它,对于父类而言只会因为队列的相关逻辑调整而去修改它。如果需要更个性化的锁获取逻辑,只需要新建个子类就完事了,不需要修改已有代码。如果你想让你自己写的原本基于AQS实现的锁变成基于AAS(AbstractArraySynchronizer)实现,只需要修改继承的父类为AAS就可以了,几乎不用调整原有代码。当然,AAS是我为了举例瞎写的,并没有这个类。

对于不少人而言,其实并没有完全发挥出java中的继承的威力,父类在大多数的写法下只是将公共的特性或者较为通用的方法抽离出来以便子类使用,使得父类的角色更多的时候像是一个“工具类“。而AQS中父类的部分逻辑依靠子类的实现,子类的部分逻辑依靠父类的提供,这种写法就大大的提升了灵活性和扩展性。

闲聊时间结束,让我们回归到主题,继续深入AQS源码去分析它的底层运转逻辑。

addWaiter

当tryAcquire获取锁失败时,AQS就会调用addWaiter,将当前线程封装成一个Node对象,并且将当前的tail节点设置为前驱节点,使用CAS操作修改将新创建的Node对象设置为新的tail节点,成功后再去修改原tail节点的后驱节点为我们创建的Node节点,最后将Node返回出去。

上面所说的是一切都顺利运行的逻辑,我们来看看如果发生意外时会怎么处理。当AQS队列为空或者CAS更新tail节点失败时,就会进入enq方法。

enq的方法很简单,逻辑也很清晰。如果AQS为空,则创建一个空的Node对象作为head节点和tail节点,如果不为空就会执行入队操作。这块流程被一个死循环包裹,相当于是一直自旋尝试直到成功入队 addWaiter.png 上图蓝色背景的为head节点,灰色背景的为tail节点。刚开始head节点和tail节点都是空的,在第一次循环时,我们将先对head节点和tail节点初始化为空Node,在第二次循环时才将当前Node入队,通过将tail节点设置为当前节点的前驱节点,并且CAS操作使自己成为新的tail节点(因为第一次CAS入队时的tail节点和head节点指向的是同一个对象,所以第一次入队实际上是将head节点设置为当前节点的前驱节点)。

acquireQueued

现在我们已经完成了addWaiter的调用,当前节点也顺利入队,接着我们就会进入到acquireQueued开始去竞争锁了。

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;
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                    // 阻塞当前线程并且在被结束阻塞以后获取中断状态,判断是否因中断操作而结束阻塞
                    parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

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

首先我们会去判断当前节点的前驱节点节点是否等于head节点,如果是那就会尝试获取锁,如果获取到了则将当前节点通过CAS操作设置为head节点。

如果前驱节点不是head节点或者竞争锁失败了。那么就会进入shouldParkAfterFailedAcquire方法。

如果前驱节点的waitStatus是等待被唤醒,那么这个这个方法就会返回true,紧接着就会调用到15行的parkAndCheckInterrupt方法阻塞当前线程,这个方法没啥贴的必要,大家看我的注释就可以,感兴趣也可以进源码里面看看。

如果前驱节点的waitStatus不属于等待被唤醒就会紧接着去判断前驱节点的waitStatus是否大于0,如果小于等于0就将前驱节点的状态通过CAS操作设置为0。大于0的情况就是前驱节点的线程被Cance了,这时候就会从前驱节点往前遍历,直到遍历到一个waitStatus<=0的节点才退出do while循环,并且将这个节点的后驱节点更新为当前节点。(对于修改当前节点的前驱节点在36行的时候一并修改了,因为head节点的waitStatus必定小于等于0,所以极端情况下就是一直遍历到将head节点变成当前节点的前驱节点)

小结

我们将acquireQueued的整体逻辑串一下,这个方法的主要干的事情就是在一个死循环中进行判断,如果当前节点的前驱节点是head节点,则尝试获取锁资源,获取成功将当前节点设置为head节点。

如果前驱节点不是head节点或者获取锁失败,那么就会在另外的方法中去判断前驱节点的状态是否等待被唤醒,如果前驱节点是等待被唤醒,则会将当前线程阻塞,等待前驱节点来唤醒自己。

如果前驱节点的状态不属于等待被唤醒,那么会进一步判断前驱节点的waitStatus,如果大于0,那就说明前驱节点被Cance了,这时候就会从前驱节点开始往前遍历,一直遍历到小于等于0的节点做为自己的前驱节点。

如果前驱节点没被Cance那么就把前驱节点的waitStatus设置为等待被唤醒。

再说简单点,acquireQueued方法就是判断如果前驱节点为head节点那就尝试获取锁,如果前驱节点不是head节点或者获取锁失败的话就阻塞当前线程。因为不管如何,都会因为第34行以下的清洗队列中Node之间的关系以及修改前驱节点状态的原因导致在某一次循环中符合第32行的条件。

release

相较于获取资源的逻辑,释放资源就没那么多弯弯绕绕了。

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

首先是尝试释放资源,因为可重入锁的原因,在某些情况下可能需要经历多次释放,所以这里是完全释放以后才会进去后续的逻辑。

当完成释放资源的操作以后就会接着去判断head节点的waitStatus不等于0,只有符合条件才会进去唤醒线程的逻辑。因为只有发生线程阻塞(shouldParkAfterFailedAcquire方法返回true),才有唤醒线程的必要。

唤醒后驱节点的逻辑在unparkSuccessor中,这段逻辑非常简单,就是将head节点的状态用CAS操作设置为0,然后以head节点的后驱节点为结束点位,从tail节点往前遍历,找到离head节点最近的一个waitStatus小于0的节点(没被Cance的),唤醒它。

总结

本篇参照《java并发编程之美》。AQS是JUC锁的基础,希望本篇博客能使各位对AQS的理解能更深一步。