网上的 AQS 文章让我很失望

2,815 阅读15分钟

一、AQS 很多人都没有讲明白

🤔 翻看了网上的 AQS(AbstractQueuedSynchronizer)文章,质量参差不齐,大多数都是在关键处跳过、含糊其词,美其名曰 “传播知识” 。

大多数都是进行大段的源码粘贴和注释,或者叫源码翻译! 有必要写上一篇文章,将 AQS 的一些基础原理搞清楚,搞正确。

本文尽量用图文的形式阐释过程,同时结合断点调试,将 AQS 在多线程运行下的状态,尽可能呈现出来!

二、准备和前提

注意:本文所指的 AQS 均为 Java 并发包中的 AbstractQueuedSynchronizer 类。

2.1 环境说明

  • InterlliJ IDEA 2024.2 (免费使用 30 d)
  • JDK1.8

2.2 线程知识储备

知识点一、 LockSupport:

深入 AQS 的源码,需要提前理解 LockSupport 接口:

LockSupport.park() : 当前线程会进入阻塞状态,直到它被 unpark 唤醒或者线程被中断

LockSupport.unpark(Thread thread): 唤醒其他线程,参数是 Thread

LockSupport 功能简单强大,对线程的挂起和唤醒非常方便。

知识点二、 ReentrantLock:

  • ReentrantLock 依赖 Sync
  • Sync 继承 AQS
  • FaireSync 是公平锁; NonFaireSync 是非公平锁, 也是 RentrantLock 默认的锁类型

模板代码如下

// 获取锁
lock.lock();
try {
  // 代码块
} finally {
    // 释放锁
    lock.unlock();
}

知识点三、CAS

  • 常用的原子操作,用于在多线程编程中实现无锁的线程安全操作
  • CAS 操作包含三个主要的参数:内存位置(V)、预期原值(A)和新值(B)
  • 只有当内存位置的值与预期原值相匹配时,CAS 操作才会将该位置值更新为新值 ,并且这种检查和替换是作为一个不可分割的原子操作完成的。

知识点四:线程模式

模式含义
SHARED线程以共享的模式等待锁
EXCLUSIVE线程正在以独占的方式等待锁

2.3 代码准备

需要断点调试,本文准备了一段代码,可以按照文章步骤逐一调试。

代码内容:三个线程,分别为 ABC,进行锁资源的抢夺。本文使用 ReentrantLock 中的非公平锁实现。

代码如下:

  • ABC 三个线程,共同争夺一把锁
  • 获得锁后执行 count++
  • 完成后释放锁

为了有更好的阅读体验,建议先搞明白下面代码 ☺️

import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
    // ABC 三个线程抢夺一把锁。显示指明使用非公平锁
    private static final ReentrantLock lock = new ReentrantLock(false);
    // 获取锁后对 count 进行++ 操作
    private static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        // 线程 A 
        Thread a = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                // 获取锁
                lock.lock();
                try {
                    count++;
                    System.out.println(Thread.currentThread().getName() + " incremented count to " + count);
                } finally {
                    // 释放锁
                    lock.unlock();
                }
            }
        }, "A");
        // 线程 B 
        Thread b = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                // 抢占锁
                lock.lock();
                try {
                    count++;
                    System.out.println(Thread.currentThread().getName() + " incremented count to " + count);
                } finally {
                    lock.unlock();
                }
            }
        }, "B");
        // 线程 B 
        Thread c = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                // 抢占锁
                lock.lock();
                try {
                    count++;
                    System.out.println(Thread.currentThread().getName() + " incremented count to " + count);
                } finally {
                    lock.unlock();
                }
            }
        }, "C");

        a.start();
        
        // 先让 B 线程晚一点执行
        System.out.println("---------");
        Thread.sleep(20000);
        b.start();

        // C 线程最后执行
        System.out.println("---------");
        Thread.sleep(20000);
        c.start();

        a.join();
        b.join();
        c.join();
    }
}

可以拷贝到自己的 IDEA 中进行调试

2.4 如何进行多线程的 debug

很多同学没有多线程的调试经验,当然多线程的调试是有难度。 希望通过本文,能够有一些帮助。

本文中的多线程调试需要掌握两个关键要的:

要点一:查看运行栈帧 && 切换线程

在 Threads & Variables 这个窗口,保障线程之间切换。

要点二:断点暂停方式,选择 Thread

这个是最为重要的。

建议本次调试:选择 Make Default, 点击图中 Make Default,后续所有断点都是 Thread如果不选择 Thread,则无法进行断点追踪!

接下来通过断点追踪的,演示 AQS 内部执行过程和原理。

将整个过程分为两个大阶段:抢锁过程和释放锁过程

  • ABC 三个线程抢锁过程,分别是 A 先抢到锁,然后 B、 C 再进入抢锁
  • A 获得锁执行代码后,再释放锁, 然后将 B 线程唤醒; 最后再唤醒 C

三、抢占锁过程演示

3.1 线程 A 获取锁演示

场景一: 模拟先让 A 线程获取到锁

注意在 B、C 开始的位置设置断点,这样能够控制 B、C 线程的启动时间。

先让断点执行到线程 A,并停留在线程 lock.lock() 位置。接下来进入此方法

注意:只有切换到线程 A , 才能进入到线程 A 的 lock 的源码。

点击进入源码,注意断点位置, 可以参考下图:

一些非关键的代码理解会直接跳过!比如 sync.lock();

关键代码如下:

final void lock() {
    // CAS 方式的设置 state
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

通过 CAS 将 state 设置为 1,设置成功这个线程则获得锁成功。

深入看一下 compareAndSetState 代码, 是通过 unsafe 的 compareAndSwapInt 实现的。

unsafe 类是一个功能较为底层。但并不复杂,使用上可以模仿!

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

stateOffset 是对象 state 字段的偏移值。

stateOffset = unsafe.objectFieldOffset
                (AbstractQueuedSynchronizer.class.getDeclaredField("state"));

总结一下: 使用 unsafe.compareAndSwapInt(this, stateOffset, expect, update) 对 state 字段进行值的更新,如果成功则获取锁成功,否则进入其他分支。

BC 线程还未开始,只有 A 线程处于 RUNNING 状态,即 1 个线程,因此 state 可以给更新成 1。

通过断点运行,compareAndSwapInt 返回 true, 接下来再调用 setExclusiveOwnerThread(), 完成线程 A 独占访问。

设置线程独占

运行到现在,AQS 中的情况如下图所示:

A 获得独占锁,BC 未开始:

因为只有一个线程A,所以没有争抢,不需要创建队列。

接下来暂停线程 A 的调试,模拟线程B 抢锁过程。

3.2 线程 B 抢占锁演示

执行过程:

先切换到 main 线程,启动线程 B; 当线程 B 启动后,切换到线程 B, 断点调试执行线程 B 的抢占过程

compareAndSetState(0, 1) 返回为 false,执行 acquire(1)代码

步骤一:切换线程 main 线程,让线程 B 运行。


步骤二:当线程 B 运行成功后,切换线程 B,进行断点追踪。

线程 B 进入了 acqurie(1) 分支

分析 acquire 代码逻辑:

流程:tryAcquire 尝试再一次获取锁,如果失败,进入队列

进入 tryAcquire 源码进行查看:

final boolean nonfairTryAcquire(int acquires) {
    // 获取当前线程
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 当为 0 的时候,表示没有线程在使用锁,尝试 CAS 抢锁
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 是否当前线程,实现线程的重入  
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

如果 state == 0,表示还没有独占的线程。于是尝试将当前线程设置成独占,这段代码与刚刚分析的逻辑是一样的!(做一次尝试

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

有两个点可以提出来关注一下:

  • 能够设置 state 值成功的线程即抢占锁成功,后续不用进入队列等待
  • 如果获得锁的线程执行完毕,即 state = 0; 于是同时,新来一个线程,这个线程将尝试获得锁,而不是将其加入到队尾。这一点体现了不公平锁的特性!不是按照FIFO

特别说明:如果 state = 0 并不表示队列中没有正在等待的线程。

如果当前线程与获得独占锁的线程是同一个线程,允许 state 修改。

else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires;
    if (nextc < 0) // overflow
        throw new Error("Maximum lock count exceeded");
    setState(nextc);
    return true;
}

因为是相同的线程,所以这段代码也是线程安全的。

注意:这也是 ReentrantLock 能够实现重入的关键代码。 如下面,进行两次 lock。 那么 state 会变成 2。

public void performAction() {
    lock.lock(); // 第一次获取锁
    try {
        System.out.println("First lock acquired.");
        performAnotherAction(); // 调用另一个需要锁的方法
    } finally {
        lock.unlock(); // 释放锁
        System.out.println("First lock released.");
    }
}

public void performAnotherAction() {
    lock.lock(); // 第二次获取同一个锁
    try {
        System.out.println("Second lock acquired on the same thread.");
        // 执行一些需要锁的操作
    } finally {
        lock.unlock(); // 释放锁
        System.out.println("Second lock released.");
    }
}

当线程不能获取到锁的时候,则进入 addWaiter 环节:

if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
  • 先调用 addWaiter()
  • 再调用 acquireQueued

3.2.1 添加等待节点

addWaiter() 和 acquireQueued() 是实现线程等待的关键

现在继续分析线程 B 的执行过程:

通过代码分析, pred 为 null,线程 B 进入 enq(node)

注意:现在的 Node 为线程 B

3.2.2 enq() 创建队列

进入 enq 方法的线程,依然存在多个线程竞争的关系。

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

通过 for(;;) 保证所有进来的线程最终都能够被合理的添加到相应的节点上。

分析线程 B 在这段代码中的过程:

  • 线程 B 第一次进入 ,t == null 为 ture,将创建了一个没有任何线程绑定的节点 Node (暂且称为空信息节点)
  • 第二次,设置线程B 节点为尾部节点,并将头节点的 next 设置为 线程 B 节点,

上面是理想情况,进入 enq 依然是存在多线程的,所以需要通过 CAS 保证线程的运行安全。

第一次执行:enq()

第一次执行,完成头部节点地创建。当前 AQS 的状况:

完成后,将 head、tail 地址引用的方式指向刚刚 New Node() 的节点。head、tail 都是地址引用

注意:这个头节点没有任何线程信息(这一点在其他 blog 中说法错误的,要警惕!)

由于使用的是for(;;) 逻辑; 在进入第二次的时候,线程 B 这个节点将会插到队尾。

hashCode=699 :线程 B 节点

hashCode=702 :空信息头节点

在循环完成第二次后,完成对线程 B 节点的插入。如下所示:

  • tail 设置成线程 B 节点
  • 线程 B 节点的的 prev 指向了 head(即刚刚创建出来的空信息节点)

完成了队列的创建后,看一下接下来线程 B 到底是立刻等待,还是需要执行一些特殊逻辑后再进入等待。

3.2.3 acquireQueued

注意关键代码:for(;;)

 if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
  • 先执行 shouldParkAfterFailedAcquire()
  • 如果 shouldParkAfterFailedAcquire() 返回为 false,则 parkAndCheckInterrupt() 不会再执行

shouldParkAfterFailedAcquire 判断是否需要将线程暂停。注意传入参数:

补充知识点:

枚举含义
0Node 初始化的时候的默认值
CANCELLED为1,表示线程获取锁的请求已经取消了
CONDITION为-2,表示节点在等待队列中,节点线程等待唤醒
PROPAGATE为-3,当前线程处在 SHARED 情况下,该字段才会使用
SIGNAL为-1,表示线程已经准备好了,就等资源释放了

具体逻辑情况如下:

特别注意:waitStatus > 0 只有取消状态这种状态

/** waitStatus value to indicate thread has cancelled */
static final int CANCELLED =  1;

第一次执行 acquireQueued ,shouldParkAfterFailedAcquire() 返回 false。

AQS 中的状态信息:完成第一次运行后, 线程 B 节点的前驱节点的 waitStatus = -1 。 如下图所示:

第二次执行 acquireQueued, shouldParkAfterFailedAcquire() 返回 true。

原因是因为前驱节点的 waitStatus = -1

第一次shouldParkAfterFailedAcquire(p, node) 执行 false,方法 parkAndCheckInterrupt()将跳过

第二次: shouldParkAfterFailedAcquire(p, node) 执行 true ,则执行 parkAndCheckInterrupt(),对线程 B 进行暂停处理。

很显然:在线程进入阻塞等待之前,线程节点多做了一次循环,算是一种优化

由于线程 B 一直获取不到锁,执行 park ,对线程阻塞挂起!

完成线程 B 的抢占演示后,再演示线程 C

3.3 线程 C 抢占锁演示

线程 C 的逻辑和线程 B 的逻辑是相似的:

A 正在获取锁,线程 C 只能执行 acquire() 分支代码

线程 C 和线程 B 一些差异点:

3.3.1 addWaiter 方法

由于 tail 已经存在,则直接将节点添加到队尾

执行完成后, AQS 的情况如下:

注意:head、tail 只是地址引用。分别指向队列的首尾。而 head 并不是一个实际的线程节点,没有线程相关信息, 这个要特别注意!

通过栈帧情况,线程 A 处于运行状态,线程 B、线程 C 都处于挂起等待状态。

3.4 抢占过程总结

  1. 设置 state > 0 成功的线程,AQS 的 exclusiveOwnerThread 值将被设置成该线程。即这个线程获得锁
  2. ReentrantLock 可以重入,通过 state 来控制重入次数
  3. ReentrantLock 的非公平锁的原理:当新的线程进入,调用 tryAcquire()多次尝试对 state 修改,即尝试获得独占,这个时候不管是否存在阻塞线程;如果多次尝试没有获取独占机会,会将这个线程加入双向队列
  4. 注意:双向队列的头节点是一个不带线程信息节点
  5. 在线程进入阻塞状态之前,依然会判断是否能够获取到锁。如果再次失败,最终会线程进行阻塞挂起

线程 ABC 的锁抢占过程就演示完成,那么阻塞挂起的线程又如何被唤醒呢?

四、锁释放过程

4.1 释放逻辑

接下来,执行线程 A 中的释放逻辑:


调用情况如下:

java.util.concurrent.locks.ReentrantLock#unlock

public void unlock() {
    sync.release(1);
}

代码逻辑:

  • 释放锁,exclusiveOwnerThread 设置为 null
  • state 释放对应数值
  • 唤醒等待的线程
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 唤醒等待的线程
            unparkSuccessor(h);
        return true;
    }
    return false;
}

4.2 唤醒等待线程

关键方法: unparkSuccessor()

这个时候 AQS 的状态情况:

  • state = 0
  • exclusiveOwnerThread = null

线程 B 被唤醒

关键方法如下:

  private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        // 注意:LockSupport.park 会响应中断
        // 非中断返回 false
        return Thread.interrupted();
    }

interrupted() 是一个静态方法,它检查当前线程是否被中断,并清除中断状态。这意味着如果线程被中断,第一次调用interrupted() 会返回true,并且重置中断状态。因此,如果紧接着再次调用interrupted() ,它将返回false,因为中断状态已经被清除了

4.3 线程 B 获得锁

线程 B 继续执行逻辑

线程 B 获得锁执行代码,线程 C 再获得锁执行代码。由于线程 C 的释放过程和线程 B 是一样的,就不必备再赘述。

当线程 ABC 都释放后,这个时候 AQS 的状态:

最后 AQS 的状态如下:

注意 head、tail 并没有销毁,将常驻内存!

4.4 释放过程总结

  1. Lock.park 挂起线程
  2. Lock.unpark 唤醒线程
  3. 当线程执行完成后,会唤醒最靠前的那个线程节点,注意不是 head 节点。 head 节点是不具备线程信息的
  4. 整个释放过程,类似链表的删除!

4.5 公平锁 faireSync

公平锁逻辑稍微简单一些:公平锁的逻辑则完全按照队列的思想来,FIFO

思路:判断队列是否存在,如果不存在则创建,如果存在则加入到队尾

五、总结

到这里,对于 AQS 的队列、线程阻塞等问题基本算是清楚了。如果不清楚,可以按照代码一步步地进行调试,感受一下 AQS 的魅力所在。

5.1 锁抢占过程和释放过程

  1. 第一个线程,通过 CAS 设置 state 和 exclusiveOwnerThread 获得锁
  2. 第二个线程,创建一个双线队列,同时第一个节点为空信息节点 head,不携带线程相关信息;同时将该线程封装成 Node,插入到队尾
  3. 第三个线程,会继续插入到队尾
  4. 进入队列的线程会通过 LockSupport.park 进入挂起阻塞状态
  5. 当获得锁的线程执行完,调用释放锁的过程,会通过 LockSupport.unpark 将第一个线程节点唤醒。注意不是 head 节点。 其他线程唤醒过程类似。

5.2 AQS代码设计中的优点

  1. 设计模式:AQS 中的模版方法,通过 tryAcquire、tryRelease 等方法的重写;从而实现了不同的工具类。很显然结构设计是很棒的
  2. CAS,使用大量的 CAS 空控制线程安全
  3. 通过 for(;;) 等细节,多次尝试对锁的获取,避免直接将线程挂起阻塞,细节上很讲究
  4. 通过精心的变量值设计,诸如 state、waitStatus 等,代码更少,逻辑更清晰

5.3 未涉及知识和弊端

部分细节未深入了解,主要有以下:

  • 中断获取 acquireInterruptibly
  • 条件判断 condition
  • 异常情况
  • 以 ReentrantLock 进行讲解,有一定局限

如果有时间再挖一挖。

5.4 为什么队列中的 head 不携带线程信息

问题:队列中的 head 节点不绑定线程,为什么需要这样的 head 节点存在?

答案:一些自己的看法(不一定对和全,欢迎补充):

由于线程的生命周期非常短;如果 head 是线程节点,那么随着锁的争夺和释放,整个队列将被反复创建和销毁。

但是给一个无关的 head 信息,只创建一次,并能反复使用,常驻内存。 从整体而言,确实可以带来性能的提升!

工作这么多年,第一次对 AQS 有这么深入的理解,不多废话了,本文到此结束!