【JUC并发编程】深入浅出Java并发基石——AQS

【JUC并发编程】深入浅出Java并发基石——AQS

参考资料:

RedSpider社区——第十一章 AQS

深入剖析并发之AQS独占锁

1.5w字,30图带你彻底掌握 AQS!

深入浅出AbstractQueuedSynchronizer

我画了35张图就是为了让你深入 AQS

动画演示AQS的核心原理

文章目录

  • 【JUC并发编程】深入浅出Java并发基石——AQS
    • 一:AQS简介
    • 二:AQS的数据结构
    • 三:AQS资源共享模式
    • 四:AQS的主要方法源码解析
      • 以 ReentrantLock 举例
      • 获取资源
      • tryAcquire 剖析
      • acquireQueued 剖析
      • 释放资源
    • 五:ReetrantLock实现总结
    • 六:AQS总结

【JUC并发编程】深入浅出Java并发基石——AQS_第1张图片

一:AQS简介

AQSAbstractQueuedSynchronizer的简称,即抽象队列同步器,从字面意思上理解:

  • 抽象:抽象类,只实现一些主要逻辑,有些方法由子类实现;
  • 队列:使用先进先出(FIFO)队列存储数据;
  • 同步:实现了同步的功能。

那AQS有什么用呢?AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的同步器,比如我们提到的ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。

当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器,只要子类实现它的几个protected方法就可以了,在下文会有详细的介绍。

我们先看下AQS相关的UML图:

【JUC并发编程】深入浅出Java并发基石——AQS_第2张图片

二:AQS的数据结构

AQS内部使用了一个volatile的变量state来作为资源的标识。同时定义了几个获取和改变state的protected方法,子类可以覆盖这些方法来实现自己的逻辑:

getState()
setState()
compareAndSetState()

【JUC并发编程】深入浅出Java并发基石——AQS_第3张图片

这三种操作均是原子操作,其中compareAndSetState的实现依赖于Unsafe的compareAndSwapInt()方法。

而AQS类本身实现的是一些排队和阻塞的机制,比如具体线程等待队列的维护(如获取资源失败入队/唤醒出队等)。它内部使用了一个先进先出(FIFO)的双端队列,并使用了两个指针head和tail用于标识队列的头部和尾部。其数据结构如图:

【JUC并发编程】深入浅出Java并发基石——AQS_第4张图片

另外 AQS 中实现的 FIFO 队列(CLH 队列)其实是双向链表实现的,由 head, tail 节点表示,head 结点代表当前占用的线程,其他节点由于暂时获取不到锁所以依次排队等待锁释放。

所以我们不难明白 AQS 的如下定义:

public abstract class AbstractQueuedSynchronizer
  extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
    // 以下为双向链表的首尾结点,代表入口等待队列
    private transient volatile Node head;
    private transient volatile Node tail;
    // 共享变量 state
    private volatile int state;
    // cas 获取 / 释放 state,保证线程安全地获取锁
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
    // ...
 }

三:AQS资源共享模式

资源有两种共享模式,或者说两种同步方式:

  • 独占模式(Exclusive):资源是独占的,一次只能一个线程获取。如ReentrantLock。

    独占锁:即其他线程只有在占有锁的线程释放后才能竞争锁,有且只有一个线程能竞争成功(医生只有一个,一次只能看一个病人)。

    【JUC并发编程】深入浅出Java并发基石——AQS_第5张图片

  • 共享模式(Share):同时可以被多个线程获取,具体的资源个数可以通过参数指定。如Semaphore/CountDownLatch。

    共享锁:即共享资源可以被多个线程同时占有,直到共享资源被占用完毕(多个医生,可以看多个病人),常见的有读写锁 ReadWriteLock, CountdownLatch。

    【JUC并发编程】深入浅出Java并发基石——AQS_第6张图片

一般情况下,子类只需要根据需求实现其中一种模式,当然也有同时实现两种模式的同步类,如ReadWriteLock

AQS中关于这两种资源共享模式的定义源码(均在内部类Node中)。我们来看看Node的结构:

static final class Node {
    // 标记一个结点(对应的线程)在共享模式下等待
    static final Node SHARED = new Node();
    // 标记一个结点(对应的线程)在独占模式下等待
    static final Node EXCLUSIVE = null; 

    // waitStatus的值,表示该结点(对应的线程)已被取消
    static final int CANCELLED = 1; 
    // waitStatus的值,表示后继结点(对应的线程)需要被唤醒
    static final int SIGNAL = -1;
    // waitStatus的值,表示该结点(对应的线程)在等待某一条件
    static final int CONDITION = -2;
    /*waitStatus的值,表示有资源可用,新head结点需要继续唤醒后继结点(共享模式下,多线程并发释放资源,而head唤醒其后继结点后,需要把多出来的资源留给后面的结点;设置新的head结点时,会继续唤醒其后继结点)*/
    static final int PROPAGATE = -3;

    // 等待状态,取值范围,-3,-2,-1,0,1
    volatile int waitStatus;
    volatile Node prev; // 前驱结点
    volatile Node next; // 后继结点
    volatile Thread thread; // 结点对应的线程
    Node nextWaiter; // 等待队列里下一个等待条件的结点


    // 判断共享模式的方法
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

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

    // 其它方法忽略,可以参考具体的源码
}

// AQS里面的addWaiter私有方法
private Node addWaiter(Node mode) {
    // 使用了Node的这个构造函数
    Node node = new Node(Thread.currentThread(), mode);
    // 其它代码省略
}

注意:通过Node我们可以实现两个队列,一是通过prev和next实现CLH队列(线程同步队列,双向队列),二是nextWaiter实现Condition条件上的等待线程队列(单向队列),这个Condition主要用在ReentrantLock类中。

四:AQS的主要方法源码解析

以 ReentrantLock 举例

首先我们先来看下 ReentrantLock 的使用方法

        ReentrantLock lock = new ReentrantLock(false);
        lock.lock();
        //...
        lock.unlock();

我们来分析下非公平锁是如何加锁的

【JUC并发编程】深入浅出Java并发基石——AQS_第7张图片

【JUC并发编程】深入浅出Java并发基石——AQS_第8张图片

【JUC并发编程】深入浅出Java并发基石——AQS_第9张图片

可以看到 lock 方法主要有两步

  1. 使用 CAS 来获取 state 资源,如果成功设置 1,代表 state 资源获取锁成功,此时记录下当前占用 state 的线程 setExclusiveOwnerThread(Thread.currentThread());
  2. 如果 CAS 设置 state 为 1 失败(代表获取锁失败),则执行 acquire(1) 方法,这个方法是 AQS 提供的方法,如下
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

获取资源

获取资源的入口是acquire(int arg)方法。arg是要获取的资源的个数,在独占模式下始终为1。我们先来看看这个方法的逻辑:

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

tryAcquire 剖析

首先调用tryAcquire(arg)尝试去获取资源。前面提到了这个方法是在子类具体实现的。

【JUC并发编程】深入浅出Java并发基石——AQS_第10张图片

先来看下 tryAcquire 方法,这个方法是 AQS 提供的一个模板方法,最终由其 AQS 具体的实现类(Sync)实现,由于执行的是非公平锁逻辑,执行的代码如下:

【JUC并发编程】深入浅出Java并发基石——AQS_第11张图片

此段代码可知锁的获取主要分两种情况

  1. state 为 0 时,代表锁已经被释放,可以去获取,于是使用 CAS 去重新获取锁资源,如果获取成功,则代表竞争锁成功,使用 setExclusiveOwnerThread(current) 记录下此时占有锁的线程,看到这里的 CAS,大家应该不难理解为啥当前实现是非公平锁了,因为队列中的线程与新线程都可以 CAS 获取锁啊,新来的线程不需要排队
  2. 如果 state 不为 0,代表之前已有线程占有了锁,如果此时的线程依然是之前占有锁的线程(current == getExclusiveOwnerThread() 为 true),代表此线程再一次占有了锁(可重入锁),此时更新 state,记录下锁被占有的次数(锁的重入次数),这里的 setState 方法不需要使用 CAS 更新,因为此时的锁就是当前线程占有的,其他线程没有机会进入这段代码执行。所以此时更新 state 是线程安全的。

假设当前 state = 0,即锁不被占用,现在有 T1, T2, T3 这三个线程要去竞争锁

【JUC并发编程】深入浅出Java并发基石——AQS_第12张图片

假设现在 T1 获取锁成功,则两种情况分别为 1、 T1 首次获取锁成功

【JUC并发编程】深入浅出Java并发基石——AQS_第13张图片

2、 T1 再次获取锁成功,state 再加 1,表示锁被重入了两次,当前如果 T1一直申请占用锁成功,state 会一直累加

【JUC并发编程】深入浅出Java并发基石——AQS_第14张图片

如果 tryAcquire(arg) 执行失败,代表获取锁失败,则执行 acquireQueued 方法,将线程加入 FIFO 等待队列

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

所以接下来我们来看看 acquireQueued 的执行逻辑,首先会调用 addWaiter(Node.EXCLUSIVE) 将包含有当前线程的 Node 节点入队, Node.EXCLUSIVE 代表此结点为独占模式

acquireQueued 剖析

如果获取资源失败,就通过addWaiter(Node.EXCLUSIVE)方法把这个线程插入到等待队列中。其中传入的参数代表要插入的Node是独占式的。

所以接下来我们来看看 acquireQueued 的执行逻辑,首先会调用 addWaiter(Node.EXCLUSIVE) 将包含有当前线程的 Node 节点入队, Node.EXCLUSIVE 代表此结点为独占模式。

这个方法的具体实现:

private Node addWaiter(Node mode) {
    // 生成该线程对应的Node节点
    Node node = new Node(Thread.currentThread(), mode);
    // 将Node插入队列中
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        // 使用CAS尝试,如果成功就返回
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 如果等待队列为空或者上述CAS失败,再自旋CAS插入
    enq(node);
    return node;
}

这段逻辑比较清楚,首先是获取 FIFO 队列的尾结点,如果尾结点存在,则采用 CAS 的方式将等待线程入队,如果尾结点为空则执行 enq 方法

// 自旋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;
            }
        }
    }
}

首先判断 tail 是否为空,如果为空说明 FIFO 队列的 head,tail 还未构建,此时先构建头结点,构建之后再用 CAS 的方式将此线程结点入队

使用 CAS 创建 head 节点的时候只是简单调用了 new Node() 方法,并不像其他节点那样记录 thread,这是为啥

因为 head 结点为虚结点,它只代表当前有线程占用了 state,至于占用 state 的是哪个线程,其实是调用了上文的 setExclusiveOwnerThread(current) ,即记录在 exclusiveOwnerThread 属性里。

执行完 addWaiter 后,线程入队成功,现在就要看最后一个最关键的方法 acquireQueued 了,这个方法有点难以理解,先不急,我们先用三个线程来模拟一下之前的代码对应的步骤

1、假设 T1 获取锁成功,由于此时 FIFO 未初始化,所以先创建 head 结点

【JUC并发编程】深入浅出Java并发基石——AQS_第15张图片

2、此时 T2 或 T3 再去竞争 state 失败,入队,如下图:

【JUC并发编程】深入浅出Java并发基石——AQS_第16张图片

好了,现在问题来了, T2,T3 入队后怎么处理,是马上阻塞吗,马上阻塞意味着线程从运行态转为阻塞态 ,涉及到用户态向内核态的切换,而且唤醒后也要从内核态转为用户态,开销相对比较大,所以 AQS 对这种入队的线程采用的方式是让它们自旋来竞争锁,如下图示

【JUC并发编程】深入浅出Java并发基石——AQS_第17张图片

不过聪明的你可能发现了一个问题,如果当前锁是独占锁,如果锁一直被被 T1 占有, T2,T3 一直自旋没太大意义,反而会占用 CPU,影响性能,所以更合适的方式是它们自旋一两次竞争不到锁后识趣地阻塞以等待前置节点释放锁后再来唤醒它。

另外如果锁在自旋过程中被中断了,或者自旋超时了,应该处于「取消」状态。

基于每个 Node 可能所处的状态,AQS 为其定义了一个变量 waitStatus,根据这个变量值对相应节点进行相关的操作,我们一起来看这看这个变量都有哪些值,是时候看一个 Node 结点的属性定义了

基于每个 Node 可能所处的状态,AQS 为其定义了一个变量 waitStatus,根据这个变量值对相应节点进行相关的操作,我们一起来看这看这个变量都有哪些值,是时候看一个 Node 结点的属性定义了

static final class Node {
    static final Node SHARED = new Node();//标识等待节点处于共享模式
    static final Node EXCLUSIVE = null;//标识等待节点处于独占模式

    static final int CANCELLED = 1; //由于超时或中断,节点已被取消
    static final int SIGNAL = -1;  // 节点阻塞(park)必须在其前驱结点为 SIGNAL 的状态下才能进行,如果结点为 SIGNAL,则其释放锁或取消后,可以通过 unpark 唤醒下一个节点,
    static final int CONDITION = -2;//表示线程在等待条件变量(先获取锁,加入到条件等待队列,然后释放锁,等待条件变量满足条件;只有重新获取锁之后才能返回)
    static final int PROPAGATE = -3;//表示后续结点会传播唤醒的操作,共享模式下起作用

    //等待状态:对于condition节点,初始化为CONDITION;其它情况,默认为0,通过CAS操作原子更新
    volatile int waitStatus;

通过状态的定义,我们可以猜测一下 AQS 对线程自旋的处理:如果当前节点的上一个节点不为 head,且它的状态为 SIGNAL,则结点进入阻塞状态。来看下代码以映证我们的猜测:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            // 如果前一个节点是 head,则尝试自旋获取锁
            if (p == head && tryAcquire(arg)) {
                //  将 head 结点指向当前节点,原 head 结点出队
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 如果前一个节点不是 head 或者竞争锁失败,则进入阻塞状态
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            // 如果线程自旋中因为异常等原因获取锁最终失败,则调用此方法
            cancelAcquire(node);
    }
}

先来看第一种情况,如果当前结点的前一个节点是 head 结点,且获取锁(tryAcquire)成功的处理

【JUC并发编程】深入浅出Java并发基石——AQS_第18张图片

可以看到主要的处理就是把 head 指向当前节点,并且让原 head 结点出队,这样由于原 head 不可达, 会被垃圾回收。

注意其中 setHead 的处理

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

将 head 设置成当前结点后,要把节点的 thread, pre 设置成 null,因为之前分析过了,head 是虚节点,不保存除 waitStatus(结点状态)的其他信息,所以这里把 thread ,pre 置为空,因为占有锁的线程由 exclusiveThread 记录了,如果 head 再记录 thread 不仅多此一举,反而在释放锁的时候要多操作一遍 head 的 thread 释放。

如果前一个节点不是 head 或者竞争锁失败,则首先调用 shouldParkAfterFailedAcquire 方法判断锁是否应该停止自旋进入阻塞状态:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
        
    if (ws == Node.SIGNAL)
       // 1. 如果前置顶点的状态为 SIGNAL,表示当前节点可以阻塞了
        return true;
    if (ws > 0) {
        // 2. 移除取消状态的结点
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 3. 如果前置节点的 ws 不为 0,则其设置为 SIGNAL,
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

这一段代码有点绕,需要稍微动点脑子,按以上步骤一步步来看

1、 首先我们要明白,根据之前 Node 类的注释,如果前驱节点为 SIGNAL,则当前节点可以进入阻塞状态。

【JUC并发编程】深入浅出Java并发基石——AQS_第19张图片

如图示:T2,T3 的前驱节点的 waitStatus 都为 SIGNAL,所以 T2,T3 此时都可以阻塞。

2、如果前驱节点为取消状态,则前驱节点需要移除,这些采用了一个更巧妙的方法,把所有当前节点之前所有 waitStatus 为取消状态的节点全部移除,假设有四个线程如下,T2,T3 为取消状态,则执行逻辑后如下图所示,T2, T3 节点会被 GC。

【JUC并发编程】深入浅出Java并发基石——AQS_第20张图片

3、如果前驱节点小于等于 0,则需要首先将其前驱节点置为 SIGNAL,因为前文我们分析过,当前节点进入阻塞的一个条件是前驱节点必须为 SIGNAL,这样下一次自旋后发现前驱节点为 SIGNAL,就会返回 true(即步骤 1)

shouldParkAfterFailedAcquire 返回 true 代表线程可以进入阻塞中断,那么下一步 parkAndCheckInterrupt 就该让线程阻塞了

private final boolean parkAndCheckInterrupt() {
    // 阻塞线程
    LockSupport.park(this);
    // 返回线程是否中断过,并且清除中断状态(在获得锁后会补一次中断)
    return Thread.interrupted();
}

这里的阻塞线程很容易理解,但为啥要判断线程是否中断过呢,因为如果线程在阻塞期间收到了中断,唤醒(转为运行态)获取锁后(acquireQueued 为 true)需要补一个中断,如下所示:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // 如果是因为中断唤醒的线程,获取锁后需要补一下中断
        selfInterrupt();
}

至此,获取锁的流程已经分析完毕。

上面的两个函数比较好理解,就是在队列的尾部插入新的Node节点,但是需要注意的是由于AQS中会存在多个线程同时争夺资源的情况,因此肯定会出现多个线程同时插入节点的操作,在这里是通过CAS自旋的方式保证了操作的线程安全性。

OK,现在回到最开始的aquire(int arg)方法。现在通过addWaiter方法,已经把一个Node放到等待队列尾部了。而处于等待队列的结点是从头结点一个一个去获取资源的。具体的实现我们来看看acquireQueued方法

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        // 自旋
        for (;;) {
            final Node p = node.predecessor();
            // 如果node的前驱结点p是head,表示node是第二个结点,就可以尝试去获取资源了
            if (p == head && tryAcquire(arg)) {
                // 拿到资源后,将head指向该结点。
                // 所以head所指的结点,就是当前获取到资源的那个结点或null。
                setHead(node); 
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 如果自己可以休息了,就进入waiting状态,直到被unpark()
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

这里parkAndCheckInterrupt方法内部使用到了LockSupport.park(this),顺便简单介绍一下park。

LockSupport类是Java 6 引入的一个类,提供了基本的线程同步原语。LockSupport实际上是调用了Unsafe类里的函数,归结到Unsafe里,只有两个函数:

  • park(boolean isAbsolute, long time):阻塞当前线程
  • unpark(Thread jthread):使给定的线程停止阻塞

所以结点进入等待队列后,是调用park使它进入阻塞状态的。只有头结点的线程是处于活跃状态的

当然,获取资源的方法除了acquire外,还有以下三个:

  • acquireInterruptibly:申请可中断的资源(独占模式)
  • acquireShared:申请共享模式的资源
  • acquireSharedInterruptibly:申请可中断的资源(共享模式)

可中断的意思是,在线程中断时可能会抛出InterruptedException

总结起来的一个流程图:

【JUC并发编程】深入浅出Java并发基石——AQS_第21张图片

释放资源

释放资源相比于获取资源来说,会简单许多。不管是公平锁还是非公平锁,最终都是调的 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;
}

private void unparkSuccessor(Node node) {
    // 如果状态是负数,尝试把它设置为0
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // 得到头结点的后继结点head.next
    Node s = node.next;
    // 如果这个后继结点为空或者状态大于0
    // 通过前面的定义我们知道,大于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;
    }
    // 如果后继结点不为空,
    if (s != null)
        LockSupport.unpark(s.thread);
}

五:ReetrantLock实现总结

基础组件:

  • 同步状态标识:对外显示锁资源的占有状态
  • 同步队列:存放获取锁失败的线程
  • 等待队列:用于实现多条件唤醒
  • Node节点:队列的每个节点,线程封装体

基础动作:

  • cas修改同步状态标识
  • 获取锁失败加入同步队列阻塞
  • 释放锁时唤醒同步队列第一个节点线程

加锁动作:

  • 调用tryAcquire()修改标识state,成功返回true执行,失败加入队列等待
  • 加入队列后判断节点是否为signal状态,是就直接阻塞挂起当前线程
  • 如果不是则判断是否为cancel状态,是则往前遍历删除队列中所有cancel状态节点
  • 如果节点为0或者propagate状态则将其修改为signal状态
  • 阻塞被唤醒后如果为head则获取锁,成功返回true,失败则继续阻塞

解锁动作:

  • 调用tryRelease()释放锁修改标识state,成功则返回true,失败返回false
  • 释放锁成功后唤醒同步队列后继阻塞的线程节点
  • 被唤醒的节点会自动替换当前节点成为head节点

六:AQS总结

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。

AbstractQueuedSynchronizer 是一个比较复杂的实现,要完全理解其中的细节还需要慢慢琢磨。

这篇文章也只能起到一个抛砖引玉的作用,将AbstractQueuedSynchronizer的设计思想,核心数据结构已经核心实现代码展示给大家。希望对大家理解AbstractQueuedSynchronizer的实现,以及理解重入锁,信号量,读写锁有一定帮助。

你可能感兴趣的:(JUC,java,开发语言,面试,后端,架构)