爆肝万字,AQS 源码分析!

文章内容已经收录在《高级技术专家成长笔记》,欢迎订阅专栏!

从原理出发,直击面试难点,实现更高维度的降维打击!

目录

  • [AQS 快速了解]
    • [AQS 的作用是什么?]
    • [AQS 为什么使用 CLH 锁队列的变体?]
    • [AQS 的性能比较好,原因是什么?]
    • [AQS 中为什么 Node 节点需要不同的状态?]
  • [Node 节点 waitStatus 状态含义]
  • [AQS 资源获取源码分析(独占模式)]
    • [tryAcquire() 分析]
    • [addWaiter() 分析]
    • [acquireQueued() 分析]
  • [AQS 资源释放源码分析(独占模式)]
  • [图解 AQS 工作原理(独占模式)]
  • [AQS 资源获取源码分析(共享模式)]
    • [tryAcquireShared() 分析]
    • [doAcquireShared() 分析]
    • [为什么需要 PROPAGATE 状态?]
  • [AQS 资源释放源码分析(共享模式)]

前言

最近一周在写 AQS 源码分析,AQS 属于并发的核心内容,很多同步器都基于 AQS 实现。

可能很多同学并不太了解 AQS 学习到什么程度好,以及 AQS 中的 Node 节点状态是在是太复杂,看的眼花缭乱!

通过这一周看 AQS 源码的经验,我觉得学习 AQS 只要把它内部的执行流程捋顺就可以了。

AQS 主要是管理线程的入队、出队,以及队列中线程等待获取资源这一过程,所以自己在学习的过程中也有必要画画图,来看一下在入队、出队的时候,节点的状态是如何变化。通过画图,可以很好地理清思路。在文章的 「图解 AQS 工作原理」 这一部分,我也画了几张流程图,帮助理解 AQS 内部的工作机制。

注:本文内容已经提交 PR 到开源 Java 面试文档「JavaGuide」

对应文档地址:https://javaguide.cn/java/concurrent/aqs.html

AQS 快速了解

在真正讲解 AQS 源码之前,需要对 AQS 有一个整体层面的认识。这里会先通过几个问题,从整体层面上认识 AQS,了解 AQS 在整个 Java 并发中所位于的层面,之后在学习 AQS 源码的过程中,才能更加了解同步器和 AQS 之间的关系。

AQS 的作用是什么?

AQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 可重入锁ReentrantLock)、信号量Semaphore)和 倒计时器CountDownLatch)。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。

简单来说,AQS 是一个抽象类,为同步器提供了通用的 执行框架。它定义了 资源获取和释放的通用流程,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的 基础“底座”,而同步器则是基于 AQS 实现的 具体“应用”

AQS 为什么使用 CLH 锁队列的变体?

CLH 锁是一种基于 自旋锁 的优化实现。

先说一下自旋锁存在的问题:自旋锁通过线程不断对一个原子变量执行 compareAndSet(简称 CAS)操作来尝试获取锁。在高并发场景下,多个线程会同时竞争同一个原子变量,容易造成某个线程的 CAS 操作长时间失败,从而导致 “饥饿”问题(某些线程可能永远无法获取锁)。

CLH 锁通过引入一个队列来组织并发竞争的线程,对自旋锁进行了改进:

  • 每个线程会作为一个节点加入到队列中,并通过自旋监控前一个线程节点的状态,而不是直接竞争共享变量。
  • 线程按顺序排队,确保公平性,从而避免了 “饥饿” 问题。

AQS(AbstractQueuedSynchronizer)在 CLH 锁的基础上进一步优化,形成了其内部的 CLH 队列变体。主要改进点有以下两方面:

  1. 自旋 + 阻塞: CLH 锁使用纯自旋方式等待锁的释放,但大量的自旋操作会占用过多的 CPU 资源。AQS 引入了 自旋 + 阻塞 的混合机制:
    • 如果线程获取锁失败,会先短暂自旋尝试获取锁;
    • 如果仍然失败,则线程会进入阻塞状态,等待被唤醒,从而减少 CPU 的浪费。
  2. 单向队列改为双向队列:CLH 锁使用单向队列,节点只知道前驱节点的状态,而当某个节点释放锁时,需要通过队列唤醒后续节点。AQS 将队列改为 双向队列,新增了 next 指针,使得节点不仅知道前驱节点,也可以直接唤醒后继节点,从而简化了队列操作,提高了唤醒效率。
AQS 的性能比较好,原因是什么?

因为 AQS 内部大量使用了 CAS 操作。

AQS 内部通过队列来存储等待的线程节点。由于队列是共享资源,在多线程场景下,需要保证队列的同步访问。

AQS 内部通过 CAS 操作来控制队列的同步访问,CAS 操作主要用于控制 队列初始化线程节点入队 两个操作的并发安全。虽然利用 CAS 控制并发安全可以保证比较好的性能,但同时会带来比较高的 编码复杂度

AQS 中为什么 Node 节点需要不同的状态?

AQS 中的 waitStatus 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。

  • 状态 0 :新节点加入队列之后,初始状态为 0

  • 状态 SIGNAL :当有新的节点加入队列,此时新节点的前继节点状态就会由 0 更新为 SIGNAL ,表示前继节点释放锁之后,需要对新节点进行唤醒操作。如果唤醒 SIGNAL 状态节点的后续节点,就会将 SIGNAL 状态更新为 0 。即通过清除 SIGNAL 状态,表示已经执行了唤醒操作。

  • 状态 CANCELLED :如果一个节点在队列中等待获取锁锁时,因为某种原因失败了,该节点的状态就会变为 CANCELLED ,表明取消获取锁,这种状态的节点是异常的,无法被唤醒,也无法唤醒后继节点。

Node 节点 waitStatus 状态含义

AQS 中的 waitStatus 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。

Node 节点状态含义
CANCELLED1表示线程已经取消获取锁。线程在等待获取资源时被中断、等待资源超时会更新为该状态。
SIGNAL-1表示后继节点需要当前节点唤醒。在当前线程节点释放锁之后,需要对后继节点进行唤醒。
CONDITION-2表示节点在等待 Condition。当其他线程调用了 Condition 的 signal() 方法后,节点会从等待队列转移到同步队列中等待获取资源。
PROPAGATE-3用于共享模式。在共享模式下,可能会出现线程在队列中无法被唤醒的情况,因此引入了 PROPAGATE 状态来解决这个问题。
0加入队列的新节点的初始状态。

在 AQS 的源码中,经常使用 > 0< 0 来对 waitStatus 进行判断。

如果 waitStatus > 0 ,表明节点的状态已经取消等待获取资源。

如果 waitStatus < 0 ,表明节点的状态处于正常的状态,即没有取消等待。

其中 SIGNAL 状态是最重要的,节点状态流转以及对应操作如下:

状态流转对应操作
0新节点入队时,初始状态为 0
0 -> SIGNAL新节点入队时,它的前继节点状态会由 0 更新为 SIGNALSIGNAL 状态表明该节点的后续节点需要被唤醒。
SIGNAL -> 0在唤醒后继节点时,需要清除当前节点的状态。通常发生在 head 节点,比如 head 节点的状态由 SIGNAL 更新为 0 ,表示已经对 head 节点的后继节点唤醒了。
0 -> PROPAGATEAQS 内部引入了 PROPAGATE 状态,为了解决并发场景下,可能造成的线程节点无法唤醒的情况。(在 AQS 共享模式获取资源的源码分析会讲到)

AQS 资源获取源码分析(独占模式)

AQS 中以独占模式获取资源的入口方法是 acquire() ,如下:

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

acquire() 中,线程会先尝试获取共享资源;如果获取失败,会将线程封装为 Node 节点加入到 AQS 的等待队列中;加入队列之后,会让等待队列中的线程尝试获取资源,并且会对线程进行阻塞操作。分别对应以下三个方法:

  • tryAcquire() :尝试获取锁(模板方法),AQS 不提供具体实现,由子类实现。
  • addWaiter() :如果获取锁失败,会将当前线程封装为 Node 节点加入到 AQS 的 CLH 变体队列中等待获取锁。
  • acquireQueued() :对线程进行阻塞,并调用 tryAcquire() 方法让队列中的线程尝试获取锁。
tryAcquire() 分析

AQS 中对应的 tryAcquire() 模板方法如下:

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

tryAcquire() 方法是 AQS 提供的模板方法,不提供默认实现。

因此,这里分析 tryAcquire() 方法时,以 ReentrantLock 的非公平锁(独占锁)为例进行分析,ReentrantLock 内部实现的 tryAcquire() 会调用到下边的 nonfairTryAcquire()

// ReentrantLock
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    // 1、获取 AQS 中的 state 状态
    int c = getState();
    // 2、如果 state 为 0,证明锁没有被其他线程占用
    if (c == 0) {
        // 2.1、通过 CAS 对 state 进行更新
        if (compareAndSetState(0, acquires)) {
            // 2.2、如果 CAS 更新成功,就将锁的持有者设置为当前线程
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 3、如果当前线程和锁的持有线程相同,说明发生了「锁的重入」
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        // 3.1、将锁的重入次数加 1
        setState(nextc);
        return true;
    }
    // 4、如果锁被其他线程占用,就返回 false,表示获取锁失败
    return false;
}

nonfairTryAcquire() 方法内部,主要通过两个核心操作去完成资源的获取:

  • 通过 CAS 更新 state 变量。state == 0 表示资源没有被占用。state > 0 表示资源被占用,此时 state 表示重入次数。
  • 通过 setExclusiveOwnerThread() 设置持有资源的线程。

如果线程更新 state 变量成功,就表明获取到了资源, 因此将持有资源的线程设置为当前线程即可。

addWaiter() 分析

在通过 tryAcquire() 方法尝试获取资源失败之后,会调用 addWaiter() 方法将当前线程封装为 Node 节点加入 AQS 内部的队列中。addWaite() 代码如下:

// AQS
private Node addWaiter(Node mode) {
    // 1、将当前线程封装为 Node 节点。
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    // 2、如果 pred != null,则证明 tail 节点已经被初始化,直接将 Node 节点加入队列即可。
    if (pred != null) {
        node.prev = pred;
        // 2.1、通过 CAS 控制并发安全。
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 3、初始化队列,并将新创建的 Node 节点加入队列。
    enq(node);
    return node;
}

节点入队的并发安全:

addWaiter() 方法中,需要执行 Node 节点 入队 的操作。由于是在多线程环境下,因此需要通过 CAS 操作保证并发安全。

通过 CAS 操作去更新 tail 指针指向新入队的 Node 节点,CAS 可以保证只有一个线程会成功修改 tail 指针,以此来保证 Node 节点入队时的并发安全。

AQS 内部队列的初始化:

在执行 addWaiter() 时,如果发现 pred == null ,即 tail 指针为 null,则证明队列没有初始化,需要调用 enq() 方法初始化队列,并将 Node 节点加入到初始化后的队列中,代码如下:

// AQS
private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) {
            // 1、通过 CAS 操作保证队列初始化的并发安全
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            // 2、与 addWaiter() 方法中节点入队的操作相同
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

enq() 方法中初始化队列,在初始化过程中,也需要通过 CAS 来保证并发安全。

初始化队列总共包含两个步骤:初始化 head 节点、tail 指向 head 节点。

初始化后的队列如下图所示:

acquireQueued() 分析

为了方便阅读,这里再贴一下 AQSacquire() 获取资源的代码:

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

acquire() 方法中,通过 addWaiter() 方法将 Node 节点加入队列之后,就会调用 acquireQueued() 方法。代码如下:

// AQS:令队列中的节点尝试获取锁,并且对线程进行阻塞。
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            // 1、尝试获取锁。
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            // 2、判断线程是否可以阻塞,如果可以,则阻塞当前线程。
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        // 3、如果获取锁失败,就会取消获取锁,将节点状态更新为 CANCELLED。
        if (failed)
            cancelAcquire(node);
    }
}

acquireQueued() 方法中,主要做两件事情:

  • 尝试获取资源: 当前线程加入队列之后,如果发现前继节点是 head 节点,说明当前线程是队列中第一个等待的节点,于是调用 tryAcquire() 尝试获取资源。

  • 阻塞当前线程 :如果尝试获取资源失败,就需要阻塞当前线程,等待被唤醒之后获取资源。

1、尝试获取资源

acquireQueued() 方法中,尝试获取资源总共有 2 个步骤:

  • p == head :表明当前节点的前继节点为 head 节点。此时当前节点为 AQS 队列中的第一个等待节点。
  • tryAcquire(arg) == true :表明当前线程尝试获取资源成功。

在成功获取资源之后,就需要将当前线程的节点 从等待队列中移除 。移除操作为:将当前等待的线程节点设置为 head 节点(head 节点是虚拟节点,并不参与排队获取资源)。

2、阻塞当前线程

AQS 中,当前节点的唤醒需要依赖于上一个节点。如果上一个节点取消获取锁,它的状态就会变为 CANCELLEDCANCELLED 状态的节点没有获取到锁,也就无法执行解锁操作对当前节点进行唤醒。因此在阻塞当前线程之前,需要跳过 CANCELLED 状态的节点。

通过 shouldParkAfterFailedAcquire() 方法来判断当前线程节点是否可以阻塞,如下:

// AQS:判断当前线程节点是否可以阻塞。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    // 1、前继节点状态正常,直接返回 true 即可。
    if (ws == Node.SIGNAL)
        return true;
    // 2、ws > 0 表示前继节点的状态异常,即为 CANCELLED 状态,需要跳过异常状态的节点。
    if (ws > 0) {
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 3、如果前继节点的状态不是 SIGNAL,也不是 CANCELLED,就将状态设置为 SIGNAL。
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

shouldParkAfterFailedAcquire() 方法中的判断逻辑:

  • 如果发现前继节点的状态是 SIGNAL ,则可以阻塞当前线程。
  • 如果发现前继节点的状态是 CANCELLED ,则需要跳过 CANCELLED 状态的节点。
  • 如果发现前继节点的状态不是 SIGNALCANCELLED ,表明前继节点的状态处于正常等待资源的状态,因此将前继节点的状态设置为 SIGNAL ,表明该前继节点需要对后续节点进行唤醒。

当判断当前线程可以阻塞之后,通过调用 parkAndCheckInterrupt() 方法来阻塞当前线程。内部使用了 LockSupport 来实现阻塞。LockSupoprt 底层是基于 Unsafe 类来阻塞线程,代码如下:

// AQS
private final boolean parkAndCheckInterrupt() {
    // 1、线程阻塞到这里
    LockSupport.park(this);
    // 2、线程被唤醒之后,返回线程中断状态
    return Thread.interrupted();
}

为什么在线程被唤醒之后,要返回线程的中断状态呢?

parkAndCheckInterrupt() 方法中,当执行完 LockSupport.park(this) ,线程会被阻塞,代码如下:

// AQS
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    // 线程被唤醒之后,需要返回线程中断状态
    return Thread.interrupted();
}

当线程被唤醒之后,需要执行 Thread.interrupted() 来返回线程的中断状态,这是为什么呢?

这个和线程的中断协作机制有关系,线程被唤醒之后,并不确定是被中断唤醒,还是被 LockSupport.unpark() 唤醒,因此需要通过线程的中断状态来判断。

acquire() 方法中,为什么需要调用 selfInterrupt()

acquire() 方法代码如下:

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

acquire() 方法中,当 if 语句的条件返回 true 后,就会调用 selfInterrupt() ,该方法会中断当前线程,为什么需要中断当前线程呢?

if 判断为 true 时,需要 tryAcquire() 返回 false ,并且 acquireQueued() 返回 true

其中 acquireQueued() 方法返回的是线程被唤醒之后的 中断状态 ,通过执行 Thread.interrupted() 来返回。该方法在返回中断状态的同时,会清除线程的中断状态。

因此如果 if 判断为 true ,表明线程的中断状态为 true ,但是调用 Thread.interrupted() 之后,线程的中断状态被清除为 false ,因此需要重新执行 selfInterrupt() 来重新设置线程的中断状态。

AQS 资源释放源码分析(独占模式)

AQS 中以独占模式释放资源的入口方法是 release() ,代码如下:

// AQS
public final boolean release(int arg) {
    // 1、尝试释放锁
    if (tryRelease(arg)) {
        Node h = head;
        // 2、唤醒后继节点
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

release() 方法中,主要做两件事:尝试释放锁和唤醒后继节点。对应方法如下:

1、尝试释放锁

通过 tryRelease() 方法尝试释放锁,该方法为模板方法,由自定义同步器实现,因此这里仍然以 ReentrantLock 为例来讲解。

ReentrantLock 中实现的 tryRelease() 方法如下:

// ReentrantLock
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    // 1、判断持有锁的线程是否为当前线程
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    // 2、如果 state 为 0,则表明当前线程已经没有重入次数。因此将 free 更新为 true,表明该线程会释放锁。
    if (c == 0) {
        free = true;
        // 3、更新持有资源的线程为 null
        setExclusiveOwnerThread(null);
    }
    // 4、更新 state 值
    setState(c);
    return free;
}

tryRelease() 方法中,会先计算释放锁之后的 state 值,判断 state 值是否为 0。

  • 如果 state == 0 ,表明该线程没有重入次数了,更新 free = true ,并修改持有资源的线程为 null,表明该线程完全释放这把锁。
  • 如果 state != 0 ,表明该线程还存在重入次数,因此不更新 free 值,free 值为 false 表明该线程没有完全释放这把锁。

之后更新 state 值,并返回 free 值,free 值表明线程是否完全释放锁。

2、唤醒后继节点

如果 tryRelease() 返回 true ,表明线程已经没有重入次数了,锁已经被完全释放,因此需要唤醒后继节点。

在唤醒后继节点之前,需要判断是否可以唤醒后继节点,判断条件为: h != null && h.waitStatus != 0 。这里解释一下为什么要这样判断:

  • h == null :表明 head 节点还没有被初始化,也就是 AQS 中的队列没有被初始化,因此无法唤醒队列中的线程节点。
  • h != null && h.waitStatus == 0 :表明头节点刚刚初始化完毕(节点的初始化状态为 0),后继节点线程还没有成功入队,因此不需要对后续节点进行唤醒。(当后继节点入队之后,会将前继节点的状态修改为 SIGNAL ,表明需要对后继节点进行唤醒)
  • h != null && h.waitStatus != 0 :其中 waitStatus 有可能大于 0,也有可能小于 0。其中 > 0 表明节点已经取消等待获取资源,< 0 表明节点处于正常等待状态。

接下来进入 unparkSuccessor() 方法查看如何唤醒后继节点:

// AQS:这里的入参 node 为队列的头节点(虚拟头节点)
private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    // 1、将头节点的状态进行清除,为后续的唤醒做准备。
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;
    // 2、如果后继节点异常,则需要从 tail 向前遍历,找到正常状态的节点进行唤醒。
    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)
        // 3、唤醒后继节点
        LockSupport.unpark(s.thread);
}

unparkSuccessor() 中,如果头节点的状态 < 0 (在正常情况下,只要有后继节点,头节点的状态应该为 SIGNAL ,即 -1),表示需要对后继节点进行唤醒,因此这里提前清除头节点的状态标识,将状态修改为 0,表示已经执行了对后续节点唤醒的操作。

如果 s == null 或者 s.waitStatus > 0 ,表明后继节点异常,此时不能唤醒异常节点,而是要找到正常状态的节点进行唤醒。

因此需要从 tail 指针向前遍历,来找到第一个状态正常(waitStatus <= 0)的节点进行唤醒。

为什么要从 tail 指针向前遍历,而不是从 head 指针向后遍历,寻找正常状态的节点呢?

遍历的方向和 节点的入队操作 有关。入队方法如下:

// AQS:节点入队方法
private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null) {
        // 1、先修改 prev 指针。
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            // 2、再修改 next 指针。
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

addWaiter() 方法中,node 节点入队需要修改 node.prevpred.next 两个指针,但是这两个操作并不是 原子操作 ,先修改了 node.prev 指针,之后才修改 pred.next 指针。

在极端情况下,可能会出现 head 节点的下一个节点状态为 CANCELLED ,此时新入队的节点仅更新了 node.prev 指针,还未更新 pred.next 指针,如下图:

这样如果从 head 指针向后遍历,无法找到新入队的节点,因此需要从 tail 指针向前遍历找到新入队的节点。

图解 AQS 工作原理(独占模式)

至此,AQS 中以独占模式获取资源、释放资源的源码就讲完了。为了对 AQS 的工作原理、节点状态变化有一个更加清晰的认识,接下来会通过画图的方式来了解整个 AQS 的工作原理。

由于 AQS 是底层同步工具,获取和释放资源的方法并没有提供具体实现,因此这里基于 ReentrantLock 来画图进行讲解。

假设总共有 3 个线程尝试获取锁,线程分别为 T1T2T3

此时,假设线程 T1 先获取到锁,线程 T2 排队等待获取锁。在线程 T2 进入队列之前,需要对 AQS 内部队列进行初始化。head 节点在初始化后状态为 0 。AQS 内部初始化后的队列如下图:

此时,线程 T2 尝试获取锁。由于线程 T1 持有锁,因此线程 T2 会进入队列中等待获取锁。同时会将前继节点( head 节点)的状态由 0 更新为 SIGNAL ,表示需要对 head 节点的后继节点进行唤醒。此时,AQS 内部队列如下图所示:

此时,线程 T3 尝试获取锁。由于线程 T1 持有锁,因此线程 T3 会进入队列中等待获取锁。同时会将前继节点(线程 T2 节点)的状态由 0 更新为 SIGNAL ,表示线程 T2 节点需要对后继节点进行唤醒。此时,AQS 内部队列如下图所示:

此时,假设线程 T1 释放锁,会唤醒后继节点 T2 。线程 T2 被唤醒后获取到锁,并且会从等待队列中退出。

这里线程 T2 节点退出等待队列并不是直接从队列移除,而是令线程 T2 节点成为新的 head 节点,以此来退出资源获取的等待。此时 AQS 内部队列如下所示:

此时,假设线程 T2 释放锁,会唤醒后继节点 T3 。线程 T3 获取到锁之后,同样也退出等待队列,即将线程 T3 节点变为 head 节点来退出资源获取的等待。此时 AQS 内部队列如下所示:

AQS 资源获取源码分析(共享模式)

AQS 中以独占模式获取资源的入口方法是 acquireShared() ,如下:

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

acquireShared() 方法中,会先尝试获取共享锁,如果获取失败,则将当前线程加入到队列中阻塞,等待唤醒后尝试获取共享锁,分别对应一下两个方法:tryAcquireShared()doAcquireShared()

其中 tryAcquireShared() 方法是 AQS 提供的模板方法,由同步器来实现具体逻辑。因此这里以 Semaphore 为例,来分析共享模式下,如何获取资源。

tryAcquireShared() 分析

Semaphore 中实现了公平锁和非公平锁,接下来以非公平锁为例来分析 tryAcquireShared() 源码。

Semaphore 中重写的 tryAcquireShared() 方法会调用下边的 nonfairTryAcquireShared() 方法:

// Semaphore 重写 AQS 的模板方法
protected int tryAcquireShared(int acquires) {
    return nonfairTryAcquireShared(acquires);
}

// Semaphore
final int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        // 1、获取可用资源数量。
        int available = getState();
        // 2、计算剩余资源数量。
        int remaining = available - acquires;
        // 3、如果剩余资源数量 < 0,则说明资源不足,直接返回;如果 CAS 更新 state 成功,则说明当前线程获取到了共享资源,直接返回。
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}

在共享模式下,AQS 中的 state 值表示共享资源的数量。

nonfairTryAcquireShared() 方法中,会在死循环中不断尝试获取资源,如果 「剩余资源数不足」 或者 「当前线程成功获取资源」 ,就退出死循环。方法返回 剩余的资源数量 ,根据返回值的不同,分为 3 种情况:

  • 剩余资源数量 > 0 :表示成功获取资源,并且后续的线程也可以成功获取资源。
  • 剩余资源数量 = 0 :表示成功获取资源,但是后续的线程无法成功获取资源。
  • 剩余资源数量 < 0 :表示获取资源失败。
doAcquireShared() 分析

为了方便阅读,这里再贴一下获取资源的入口方法 acquireShared()

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

acquireShared() 方法中,会先通过 tryAcquireShared() 尝试获取资源。

如果发现方法的返回值 < 0 ,即剩余的资源数小于 0,则表明当前线程获取资源失败。因此会进入 doAcquireShared() 方法,将当前线程加入到 AQS 队列进行等待。如下:

// AQS
private void doAcquireShared(int arg) {
    // 1、将当前线程加入到队列中等待。
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                // 2、如果当前线程是等待队列的第一个节点,则尝试获取资源。
                int r = tryAcquireShared(arg);
                if (r >= 0) {
					// 3、将当前线程节点移出等待队列,并唤醒后续线程节点。
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        // 3、如果获取资源失败,就会取消获取资源,将节点状态更新为 CANCELLED。
        if (failed)
            cancelAcquire(node);
    }
}

由于当前线程已经尝试获取资源失败了,因此在 doAcquireShared() 方法中,需要将当前线程封装为 Node 节点,加入到队列中进行等待。

共享模式 获取资源和 独占模式 获取资源最大的不同之处在于:共享模式下,资源的数量可能会大于 1,即可以多个线程同时持有资源。

因此在共享模式下,当线程线程被唤醒之后,获取到了资源,如果发现还存在剩余资源,就会尝试唤醒后边的线程去尝试获取资源。对应的 setHeadAndPropagate() 方法如下:

// AQS
private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head;
    // 1、将当前线程节点移出等待队列。
    setHead(node);
	// 2、唤醒后续等待节点。
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

setHeadAndPropagate() 方法中,唤醒后续节点需要满足一定的条件,主要需要满足 2 个条件:

  • propagate > 0propagate 代表获取资源之后剩余的资源数量,如果 > 0 ,则可以唤醒后续线程去获取资源。
  • h.waitStatus < 0 :这里的 h 节点是执行 setHead() 之前的 head 节点。判断 head.waitStatus 时使用 < 0 ,主要为了确定 head 节点的状态为 SIGNALPROPAGATE 。如果 head 节点为 SIGNAL ,则可以唤醒后续节点;如果 head 节点状态为 PROPAGATE ,也可以唤醒后续节点(这是为了解决并发场景下出现的问题,后续会细讲)。

代码中关于 唤醒后续等待节点if 判断稍微复杂一些,这里来讲一下为什么这样写:

if (propagate > 0 || h == null || h.waitStatus < 0 ||
    (h = head) == null || h.waitStatus < 0)
  • h == null || h.waitStatus < 0h == null 用于防止空指针异常。正常情况下 h 不会为 null ,因为执行到这里之前,当前节点已经加入到队列中了,队列不可能还没有初始化。

    h.waitStatus < 0 主要判断 head 节点的状态是否为 SIGNAL 或者 PROPAGATE ,直接使用 < 0 来判断比较方便。

  • (h = head) == null || h.waitStatus < 0 :如果到这里说明之前判断的 h.waitStatus < 0 ,说明存在并发。

    同时存在其他线程在唤醒后续节点,已经将 head 节点的值由 SIGNAL 修改为 0 了。因此,这里重新获取新的 head 节点,这次获取的 head 节点为通过 setHead() 设置的当前线程节点,之后再次判断 waitStatus 状态。

如果 if 条件判断通过,就会走到 doReleaseShared() 方法唤醒后续等待节点,如下:

private void doReleaseShared() {
    for (;;) {
        Node h = head;
        // 1、队列中至少需要一个等待的线程节点。
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            // 2、如果 head 节点的状态为 SIGNAL,则可以唤醒后继节点。
            if (ws == Node.SIGNAL) {
                // 2.1 清除 head 节点的 SIGNAL 状态,更新为 0。表示已经唤醒该节点的后继节点了。
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;
                // 2.2 唤醒后继节点
                unparkSuccessor(h);
            }
            // 3、如果 head 节点的状态为 0,则更新为 PROPAGATE。这是为了解决并发场景下存在的问题,接下来会细讲。
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;
        }
        if (h == head)
            break;
    }
}

doReleaseShared() 方法中,会判断 head 节点的 waitStatus 状态来决定接下来的操作,有两种情况:

  • head 节点的状态为 SIGNAL :表明 head 节点存在后继节点需要唤醒,因此通过 CAS 操作将 head 节点的 SIGNAL 状态更新为 0 。通过清除 SIGNAL 状态来表示已经对 head 节点的后继节点进行唤醒操作了。
  • head 节点的状态为 0 :表明存在并发情况,需要将 0 修改为 PROPAGATE 来保证在并发场景下可以正常唤醒线程。
为什么需要 PROPAGATE 状态?

doReleaseShared() 释放资源时,第 3 步不太容易理解,即如果发现 head 节点的状态是 0 ,就将 head 节点的状态由 0 更新为 PROPAGATE

AQS 中,Node 节点的 PROPAGATE 就是为了处理并发场景下可能出现的无法唤醒线程节点的问题。PROPAGATE 只在 doReleaseShared() 方法中用到一次。

接下来通过案例分析,为什么需要 PROPAGATE 状态?

在共享模式下,线程获取和释放资源的方法调用链如下:

  • 线程获取资源的方法调用链为: acquireShared() -> tryAcquireShared() -> 线程阻塞等待唤醒 -> tryAcquireShared() -> setHeadAndPropagate() -> if (剩余资源数 > 0) || (head.waitStatus < 0) 则唤醒后续节点

  • 线程释放资源的方法调用链为: releaseShared() -> tryReleaseShared() -> doReleaseShared()

如果在释放资源时,没有将 head 节点的状态由 0 改为 PROPAGATE

假设总共有 4 个线程尝试以共享模式获取资源,总共有 2 个资源。初始 T3T4 线程获取到了资源,T1T2 线程没有获取到,因此在队列中排队等候。

  • 在时刻 1 时,线程 T1T2 在等待队列中,T3T4 持有资源。此时等待队列内节点以及对应状态为(括号内为节点的 waitStatus 状态):

    head(-1) -> T1(-1) -> T2(0)

  • 在时刻 2 时,线程 T3 释放资源,通过 doReleaseShared() 方法将 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T1 ,之后线程 T3 退出。

    线程 T1 被唤醒之后,通过 tryAcquireShared() 获取到资源,但是此时还未来得及执行 setHeadAndPropagate() 将自己设置为 head 节点。此时等待队列内节点状态为:

    head(0) -> T1(-1) -> T2(0)

  • 在时刻 3 时,线程 T4 释放资源, 由于此时 head 节点的状态为 0 ,因此在 doReleaseShared() 方法中无法唤醒 head 的后继节点, 之后线程 T4 退出。

  • 在时刻 4 时,线程 T1 继续执行 setHeadAndPropagate() 方法将自己设置为 head 节点。

    但是此时由于线程 T1 执行 tryAcquireShared() 方法返回的剩余资源数为 0 ,并且 head 节点的状态为 0 ,因此线程 T1 并不会在 setHeadAndPropagate() 方法中唤醒后续节点。此时等待队列内节点状态为:

    head(-1,线程 T1 节点) -> T2(0)

此时,就导致线程 T2 节点在等待队列中,无法被唤醒。对应时刻表如下:

时刻线程 T1线程 T2线程 T3线程 T4等待队列
时刻 1等待队列等待队列持有资源持有资源head(-1) -> T1(-1) -> T2(0)
时刻 2(执行)被唤醒后,获取资源,但未来得及将自己设置为 head 节点等待队列(执行)释放资源持有资源head(0) -> T1(-1) -> T2(0)
时刻 3等待队列已退出(执行)释放资源。但 head 节点状态为 0 ,无法唤醒后继节点head(0) -> T1(-1) -> T2(0)
时刻 4(执行)将自己设置为 head 节点等待队列已退出已退出head(-1,线程 T1 节点) -> T2(0)

如果在线程释放资源时,将 head 节点的状态由 0 改为 PROPAGATE ,则可以解决上边出现的并发问题,如下:

  • 在时刻 1 时,线程 T1T2 在等待队列中,T3T4 持有资源。此时等待队列内节点以及对应状态为:

    head(-1) -> T1(-1) -> T2(0)

  • 在时刻 2 时,线程 T3 释放资源,通过 doReleaseShared() 方法将 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T1 ,之后线程 T3 退出。

    线程 T1 被唤醒之后,通过 tryAcquireShared() 获取到资源,但是此时还未来得及执行 setHeadAndPropagate() 将自己设置为 head 节点。此时等待队列内节点状态为:

    head(0) -> T1(-1) -> T2(0)

  • 在时刻 3 时,线程 T4 释放资源, 由于此时 head 节点的状态为 0 ,因此在 doReleaseShared() 方法中会将 head 节点的状态由 0 更新为 PROPAGATE , 之后线程 T4 退出。此时等待队列内节点状态为:

    head(PROPAGATE) -> T1(-1) -> T2(0)

  • 在时刻 4 时,线程 T1 继续执行 setHeadAndPropagate() 方法将自己设置为 head 节点。此时等待队列内节点状态为:

    head(-1,线程 T1 节点) -> T2(0)

  • 在时刻 5 时,虽然此时由于线程 T1 执行 tryAcquireShared() 方法返回的剩余资源数为 0 ,但是 head 节点状态为 PROPAGATE < 0 (这里的 head 节点是老的 head 节点,而不是刚成为 head 节点的线程 T1 节点)。

    因此线程 T1 会在 setHeadAndPropagate() 方法中唤醒后续 T2 节点,并将 head 节点的状态由 SIGNAL 更新为 0。此时等待队列内节点状态为:

    head(0,线程 T1 节点) -> T2(0)

  • 在时刻 6 时,线程 T2 被唤醒后,获取到资源,并将自己设置为 head 节点。此时等待队列内节点状态为:

    head(0,线程 T2 节点)

有了 PROPAGATE 状态,就可以避免线程 T2 无法被唤醒的情况。对应时刻表如下:

时刻线程 T1线程 T2线程 T3线程 T4等待队列
时刻 1等待队列等待队列持有资源持有资源head(-1) -> T1(-1) -> T2(0)
时刻 2(执行)被唤醒后,获取资源,但未来得及将自己设置为 head 节点等待队列(执行)释放资源持有资源head(0) -> T1(-1) -> T2(0)
时刻 3未继续向下执行等待队列已退出(执行)释放资源。此时会将 head 节点状态由 0 更新为 PROPAGATEhead(PROPAGATE) -> T1(-1) -> T2(0)
时刻 4(执行)将自己设置为 head 节点等待队列已退出已退出head(-1,线程 T1 节点) -> T2(0)
时刻 5(执行)由于 head 节点状态为 PROPAGATE < 0 ,因此会在 setHeadAndPropagate() 方法中唤醒后续节点,此时将新的 head 节点的状态由 SIGNAL 更新为 0 ,并唤醒线程 T2等待队列已退出已退出head(0,线程 T1 节点) -> T2(0)
时刻 6已退出(执行)线程 T2 被唤醒后,获取到资源,并将自己设置为 head 节点已退出已退出head(0,线程 T2 节点)

AQS 资源释放源码分析(共享模式)

AQS 中以共享模式释放资源的入口方法是 releaseShared() ,代码如下:

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

其中 tryReleaseShared() 方法是 AQS 提供的模板方法,这里同样以 Semaphore 来讲解,如下:

// Semaphore
protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;
        if (next < current) // overflow
            throw new Error("Maximum permit count exceeded");
        if (compareAndSetState(current, next))
            return true;
    }
}

Semaphore 实现的 tryReleaseShared() 方法中,会在死循环内不断尝试释放资源,即通过 CAS 操作来更新 state 值。

如果更新成功,则证明资源释放成功,会进入到 doReleaseShared() 方法。

doReleaseShared() 方法在前文获取资源(共享模式)的部分已进行了详细的源码分析,此处不再重复。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

11来了

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

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

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

打赏作者

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

抵扣说明:

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

余额充值