网上的 AQS 文章让我很失望

一、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 默认的锁类型

模板代码如下

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

知识点三、CAS

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

知识点四:线程模式

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

2.3 代码准备

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

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

代码如下:

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

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

Java 复制代码
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();

关键代码如下:

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

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

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

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

Java 复制代码
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 字段的偏移值。

Java 复制代码
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 源码进行查看:

Java 复制代码
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,表示还没有独占的线程。于是尝试将当前线程设置成独占,这段代码与刚刚分析的逻辑是一样的!(做一次尝试

Java 复制代码
final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

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

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

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

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

Java 复制代码
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。

Java 复制代码
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 环节:

Java 复制代码
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 方法的线程,依然存在多个线程竞争的关系。

Java 复制代码
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(;;)

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

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

补充知识点:

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

具体逻辑情况如下:

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

sql 复制代码
/** 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

Java 复制代码
public void unlock() {
    sync.release(1);
}

代码逻辑:

  • 释放锁,exclusiveOwnerThread 设置为 null
  • state 释放对应数值
  • 唤醒等待的线程
Java 复制代码
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 被唤醒

关键方法如下:

Java 复制代码
  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 有这么深入的理解,不多废话了,本文到此结束!

相关推荐
兩尛5 分钟前
Spring面试
java·spring·面试
舒一笑8 分钟前
🚀 PandaCoder 2.0.0 - ES DSL Monitor & SQL Monitor 震撼发布!
后端·ai编程·intellij idea
Java中文社群12 分钟前
服务器被攻击!原因竟然是他?真没想到...
java·后端
Full Stack Developme23 分钟前
java.nio 包详解
java·python·nio
零千叶39 分钟前
【面试】Java JVM 调优面试手册
java·开发语言·jvm
代码充电宝1 小时前
LeetCode 算法题【简单】290. 单词规律
java·算法·leetcode·职场和发展·哈希表
li3714908901 小时前
nginx报400bad request 请求头过大异常处理
java·运维·nginx
摇滚侠1 小时前
Spring Boot 项目, idea 控制台日志设置彩色
java·spring boot·intellij-idea
helloworddm1 小时前
Orleans 流系统握手机制时序图
后端·c#
Aevget2 小时前
「Java EE开发指南」用MyEclipse开发的EJB开发工具(二)
java·ide·java-ee·eclipse·myeclipse