synchronized 与 AQS 锁竞争剖析:从锁升级到 CLH 队列的定位路径

synchronized 与 AQS 锁竞争剖析:从锁升级到 CLH 队列的定位路径

0. 读者要完成的那件事

这篇文章只服务一个目标:当你怀疑某段同步代码在高并发下变慢时,能够沿着一条可执行的路径把"是不是锁竞争、竞争发生在哪一级、瓶颈在自旋还是在排队、下一步该改什么"讲清楚,并且拿得出证据。

它不承诺让你学会所有锁优化技巧,也不承诺某次调优一定有效。它承诺的是方法论:先用现象建立假设,再用与假设对应的观测手段取证,最后只在证据指向的地方动手。

1. 具体问题:一个看起来"没做什么"的慢方法

假设一段后端代码在压测与生产上都出现过类似现象:单个请求的 CPU 时间不高,方法体里也没有远程调用、没有大对象分配,但吞吐上不去,P99 延迟在高并发时陡增;降低并发后立刻恢复。方法里唯一的可疑点是它被 synchronized 保护,或者它调用了一个用 ReentrantLock 实现的服务。

这就是典型的"锁竞争嫌疑现场"。但它只是嫌疑。慢的原因可能是:

  • 临界区太长,持锁时间被放大;
  • 锁粒度过粗,本来无关的操作被迫串行;
  • 竞争线程太多,唤醒与调度成本上升;
  • 临界区内其实藏着阻塞 IO 或数据库访问,锁只是放大器;
  • 根本不是锁的问题,是线程池太小、连接池耗尽或 GC 停顿。

把这些可能性区分开,就是本文要做的定位工作。为了讲清楚"为什么锁会变慢",必须先讲清 Java 里两套最常被混淆的排队机制:synchronized 背后的对象监视器,和 java.util.concurrent 里 AQS 的 CLH 变体队列。

2. 环境与兼容版本

本文正文以 Java 21 为基准,应用框架以 Spring Boot 3.5 为基准。需要先说清几件与版本强相关的事,否则后面所有观察都可能被误读。

2.1 偏向锁在 Java 21 已不可用

关键事实(适用范围:HotSpot,JDK 15 起默认禁用,JDK 18 起相关实现被移除)。 偏向锁(biased locking)是 HotSpot 曾经的一种优化:当同一个锁对象长期只被一个线程使用时,把对象头标记成"偏向该线程",之后该线程进入同步块时只需做一次极轻的标记比对,不再执行 CAS。它解决的是"无竞争场景下 synchronized 仍然有原子操作开销"的问题。

偏向锁在 JDK 15 中被默认禁用(JEP 374),在 JDK 18 中相关实现被移除。因此在 Java 21 上,你不会再看到"偏向锁 → 轻量级锁 → 重量级锁"这条经典三级升级链中的第一级。很多博客仍把它当作现状,照抄会得到错误结论。本文在讲锁升级时会把这一点单独标出来,避免读者去追一个在该版本上不存在的状态。

同时要区分"标准保证"和"实现细节":Java 语言规范只规定 synchronized 的互斥与可见性语义,并没有规定虚拟机必须用轻量级锁、必须自旋多少次、必须膨胀到哪个结构。下面关于对象头、轻量级锁、重量级锁的描述属于 HotSpot 在特定版本下的实现行为,不是跨虚拟机的规范承诺。

2.2 观测工具与实验依赖

本次实验需要的工具都在 JDK 自带范围内:

  • jcmd:用于导出线程栈(Thread.print)和触发 JFR 记录;
  • jfr:JDK 自带的 JFR 文件读取工具;
  • JDK Mission Control 或异步剖析器(如 async-profiler)用于生成火焰图;文中火焰图部分会说明其输入来源,若你没有安装剖析器,可以用 JFR 事件替代,结论方向一致但细粒度不同。

实验代码不依赖 Spring Boot;Spring Boot 3.5 只作为"真实应用里怎么接入观察点"的上下文出现。所有命令行示例都标注了它是可直接执行的命令,还是示意性片段。

3. 核心机制:从对象头到两套等待队列

3.1 对象头里到底存了什么

Java 对象在 HotSpot 中有一个对象头(object header),其中包含 Mark Word。Mark Word 是一个与机器字长同宽的区域(64 位 JVM 上通常 64 位),用来存放哈希码、GC 年龄、锁状态等信息。它之所以存在,是因为"对象自身的元数据"需要有一个固定位置,而不同状态下这些信息的种类不同,于是 Mark Word 采用按状态复用同一片位的设计。

这里的关键结论是:锁状态记录在对象上,而不是记录在线程上。 这一点解释了为什么锁竞争可以只通过观察对象和线程状态来定位。

普通中文解释:轻量级锁解决的是"有并发但几乎不重叠"的场景------多个线程交替进入同步块,实际上没有同时争抢。它的做法是在线程栈上分配一个"锁记录"(Lock Record),把对象头 Mark Word 的旧值保存进去,再用 CAS 把对象头改成指向该锁记录的指针。如果 CAS 成功,说明没有竞争,代价只是一次原子操作;如果失败,说明有别的线程正在持锁或刚刚抢到,才需要考虑更重的路径。

正式名称:轻量级锁(lightweight locking)、锁记录(Lock Record)、标记为"轻量级锁定"的 Mark Word 状态。

3.2 锁膨胀:从轻量级到重量级

锁膨胀指的是当轻量级锁的自旋尝试失败到一定程度,虚拟机会把锁升级为重量级锁,并在对象上关联一个监视器结构。普通中文解释:轻量级锁假设"抢不到就再等一小会儿,对方马上就放",一旦发现等待时间不可忽略、线程开始堆积,就切换成"排队 + 阻塞唤醒"的模式,把线程交给操作系统调度。正式名称:重量级锁(inflated monitor)、对象监视器(ObjectMonitor)。

膨胀链条可以这样理解(示意思路,具体阈值由虚拟机实现决定,不是规范):

text 复制代码
无锁
  | 第一个线程进入同步块(CAS 成功)
  v
轻量级锁(持锁线程在栈上有 Lock Record)
  | 另一线程来抢,自旋若干次仍失败
  v
重量级锁(对象关联 ObjectMonitor,竞争线程进入等待队列)

注意两个常见误解:

  1. 认为膨胀后"永远回不去"。HotSpot 在某些条件下确实会做锁消除或撤销,但不能把"一定可以降级"当成编程假设。工程上应当认为一旦进入高竞争,成本结构就变了。
  2. 认为自旋次数是固定常量。自旋策略与版本、CPU 核数、运行模式有关,把它写成"自旋 10 次就膨胀"是错误的确定性断言。

3.3 ObjectMonitor 与 AQS:两套不同的排队结构

synchronized 膨胀后依赖 HotSpot 内部的 ObjectMonitor。它内部维护若干入口与等待集合,用来记录"哪些线程在等这把锁、哪些线程调用了 wait 在条件上等待"。这套结构由虚拟机实现,Java 层看不到也不应直接依赖。

ReentrantLock、Semaphore、CountDownLatch 等则建立在 AQS 之上。AQS 全称 AbstractQueuedSynchronizer,普通中文解释:它把"用一个整数表示同步状态 + 用一条双向队列管理没抢到同步状态的线程"这套逻辑抽成公共基类,让各种同步器只实现"怎样算获取成功、怎样算释放"的少量方法。正式名称:抽象队列同步器(AbstractQueuedSynchronizer)。

AQS 内部的等待队列通常被称为 CLH 队列的变体 。CLH 原本是一种基于前驱节点自旋的排队锁,AQS 借用它"每个等待者盯着前驱"的思想,但为了适配阻塞唤醒,改成了显式的双向链表节点,并配合状态字段与 LockSupport.park/unpark。所以更准确的说法是"AQS 使用 CLH 风格的等待队列",而不是"AQS 就是 CLH 锁"。

两条路径的差异可以这样对照:

维度 synchronized(膨胀后) AQS 同步器
排队结构 虚拟机内部 ObjectMonitor 结构 Java 层可见的双向等待队列
队列是否可观测 线程栈可见等待位置,结构本身不可直接读 可通过线程栈与自定义调试观察
是否支持超时 不支持(tryLock 类能力由库提供) 支持 tryAcquire、带超时获取
是否支持条件等待 支持 wait/notify 支持 Condition,可多个条件队列
是否公平 非公平语义 可选择公平/非公平

这张表的用处是:当你在火焰图或线程栈里看到等待,先判断它属于哪套结构,再决定用哪种取证手段。

4. 排队与唤醒为什么会产生延迟

竞争线程进入等待后,恢复执行需要被唤醒。synchronized 的唤醒由虚拟机与操作系统协作完成,AQS 则通过 LockSupport.unpark 唤醒后继节点。无论哪条路径,被唤醒的线程都要重新参与调度,可能还要与其他就绪线程竞争 CPU。普通中文解释:线程从"睡眠"回到"运行"不是瞬时的,它要经过调度器排队,这段时间在高压下会累积成可观察的延迟。

这带来两个工程推论:

  1. 临界区越短、越多线程频繁进出,唤醒开销占比越高,可能出现"CPU 不低但吞吐不涨"的情况;
  2. 当临界区里包含阻塞操作(如数据库查询),等待队列会稳定变长,锁从"保护共享状态"变成"串行化整个下游调用"。

这两条推论都是待验证假设,必须用后面的观测手段证实,不能只凭直觉下结论。

5. 一个贯穿示例:高并发下的计数器与临界区放大

5.1 前置环境与版本

  • JDK:Java 21(java -version 应显示 21);
  • 操作系统:Linux 或 macOS 均可,容器内运行需要保证有足够 CPU 配额;
  • 不引入第三方依赖,示例只使用 JDK 标准库;
  • 需要 jcmd、jfr 可执行文件在 PATH 中。

以下代码是可直接编译运行的完整程序 ,不是节选。它构造三种情形:短临界区纯内存计数、长临界区模拟耗时、ReentrantLock 版本,便于对比。

java 复制代码
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;

public class LockContentionDemo {

    private static final Object MONITOR = new Object();
    private static final ReentrantLock LOCK = new ReentrantLock();
    private static long syncCounter = 0;
    private static long lockCounter = 0;

    // 情形 A:短临界区,synchronized
    static void shortSync() {
        synchronized (MONITOR) {
            syncCounter++;
        }
    }

    // 情形 B:长临界区,模拟持锁期间做了耗时工作
    static void longSync(int spin) {
        synchronized (MONITOR) {
            long acc = 0;
            for (int i = 0; i < spin; i++) {
                acc += i;
            }
            syncCounter += acc % 7;
        }
    }

    // 情形 C:ReentrantLock 版本
    static void lockVersion(int spin) {
        LOCK.lock();
        try {
            long acc = 0;
            for (int i = 0; i < spin; i++) {
                acc += i;
            }
            lockCounter += acc % 7;
        } finally {
            LOCK.unlock();
        }
    }

    static long run(CountDownLatch start, Runnable task, int threads, int iterations)
            throws InterruptedException {
        Thread[] workers = new Thread[threads];
        for (int t = 0; t < threads; t++) {
            workers[t] = new Thread(() -> {
                try {
                    start.await();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                for (int i = 0; i < iterations; i++) {
                    task.run();
                }
            }, "worker-" + t);
            workers[t].start();
        }
        long begin = System.nanoTime();
        start.countDown();
        for (Thread w : workers) {
            w.join();
        }
        return System.nanoTime() - begin;
    }

    public static void main(String[] args) throws Exception {
        int threads = Integer.getInteger("threads", 8);
        int iterations = Integer.getInteger("iterations", 200_000);
        int spin = Integer.getInteger("spin", 200_000);

        long a = run(new CountDownLatch(1), LockContentionDemo::shortSync,
                threads, iterations);
        System.out.printf("A shortSync  : %d ms, counter=%d%n", a / 1_000_000, syncCounter);

        long b = run(new CountDownLatch(1), () -> longSync(spin), threads, iterations / 20);
        System.out.printf("B longSync   : %d ms, counter=%d%n", b / 1_000_000, syncCounter);

        long c = run(new CountDownLatch(1), () -> lockVersion(spin), threads, iterations / 20);
        System.out.printf("C reentrant  : %d ms, counter=%d%n", c / 1_000_000, lockCounter);
    }
}

5.2 执行步骤

假设文件保存为 LockContentionDemo.java,在同一目录执行以下可直接运行的命令:

bash 复制代码
javac LockContentionDemo.java
java -Dthreads=8 -Diterations=200000 -Dspin=200000 LockContentionDemo

若希望在运行期间观察,另开终端记录进程号后执行:

bash 复制代码
jcmd <pid> Thread.print -l

其中 -l 会额外输出与锁相关的位置信息。也可以用 JFR 记录一段时间:

bash 复制代码
jcmd <pid> JFR.start name=demo settings=profile duration=20s filename=demo.jfr
jfr print --events jdk.JavaMonitorEnter,jdk.JavaMonitorWait demo.jfr

jdk.JavaMonitorEnter 这类事件用于观测监视器进入等待的行为,属于诊断数据,不是精确计费数据;事件有采样与阈值控制,不能把事件计数直接当成真实争用次数。

5.3 输入、状态变化与预期的可观察结果

输入:线程数、迭代次数、自旋次数由系统属性控制。

状态变化:所有工作线程先在同一个 CountDownLatch 上等待,countDown 后同时开始,制造尽量接近的起点。情形 A 每次只在锁内做一次自增;情形 B 与 C 在锁内做较长的纯计算,放大持锁时间;C 与 B 的计算量相同,区别只在同步原语不同。

预期结果(这是预期,不是本文实测数据):

  • 情形 A 的总耗时随线程数增加先略降后上升,出现"增加线程反而变慢"的拐点;
  • 情形 B 的耗时远大于 A,因为单位操作持锁更久,等待队列更容易变长;
  • 情形 C 与 B 数量级接近,但在某些机器上 ReentrantLock 可能因为支持更细的队列与公平策略而略有差异,差异方向不稳定;
  • 在情形 B/C 运行期间抓线程栈,会看到多个 worker-* 线程停留在同步进入点或 LockSupport.park 附近。

若你的观测与上述方向相反,不要急着否定机制,先检查:机器核数是否足够、容器 CPU 配额是否被限、是否跑在开启了大量调试或采样的环境里、spin 是否小到不足以拉开差距。

5.4 为什么会出现这种结果

短临界区时,线程之间真正重叠的概率低,大部分进入都能快速完成,偶尔的等待成本也不高;当线程数超过并行能力,重叠概率上升,等待与唤醒占比提高,总吞吐下降。长临界区把"重叠时间"按比例放大,相当于把排队时间乘以持锁时长,于是延迟增长更明显。这里的解释是因果推演,不是对某台机器的实测断言。

5.5 资源清理

示例不创建文件、不启动外部服务,进程结束后 LOCK、CountDownLatch 与工作线程都由 JVM 回收。如果启动了 JFR 记录,用 jcmd <pid> JFR.stop name=demo 或等待 duration 到期,然后确认 demo.jfr 已落盘再分析。容器环境中不要遗留大量 jfr 文件占满磁盘。

6. 执行与观察:把"变慢"变成证据

6.1 线程栈:看到等待位置

jcmd <pid> Thread.print 输出中,等待监视器的线程通常显示为 BLOCKED,栈顶附近会有 - waiting to lock <0x...> 行,其中的地址对应被争用的对象。等待 ReentrantLock 的线程通常显示 WAITING,栈顶是 LockSupport.park,再往下是 AQS 的 acquire 路径。

判断方法:

  • 若大量线程处于 BLOCKED 且锁定地址集中在同一处,说明 synchronized 是热点;
  • 若大量线程处于 WAITING 且栈里有 AbstractQueuedSynchronizer 相关帧,说明 AQS 同步器在排队;
  • 若线程都在 RUNNABLE 但吞吐不涨,问题更可能是 CPU 饱和或 GC,而不是锁等待。

一次快照只是瞬时切片,不能代表整体。要在不同时间点多次抓取,观察是否稳定复现。

6.2 JFR 与锁事件

JFR 提供监视器进入等待与线程阻塞类事件,并支持阈值过滤。它的价值在于给出时间分布:某段时间内等待事件密集,说明竞争是持续性的;偶发几条则可能是启动阶段或 GC 引起的抖动。

使用时要记住边界:JFR 是诊断工具,其事件有开销与采样偏差,不能把事件数量当作精确的争用次数,也不能据此直接推导出"优化后快了百分之多少"。

6.3 锁竞争火焰图

火焰图是一种把采样栈按时间堆叠的可视化:横轴越宽表示该栈出现越多,纵向表示调用层级。锁竞争火焰图的核心用法是找到"因为等待锁而被阻塞"的栈帧占比。

普通中文解释:普通 CPU 火焰图展示的是线程真正在占用 CPU 时执行的代码;锁竞争火焰图展示的是线程停在同步点上等待的时间。两者的差别很重要------锁竞争高时,CPU 火焰图可能看起来很"空",你会误以为 CPU 不是瓶颈。

生成方式:使用支持锁/等待模式的剖析器,对被测进程采集一段时间后输出火焰图。若手头只有 JFR,也可以用 JFR 的监视器事件配合工具做聚合视图,但细粒度栈信息可能少于专用剖析器。
渲染错误: Mermaid 渲染失败: Parse error on line 4: ...-->|"BLOCKED 同一地址"|" C["synchronized 监视器 -----------------------^ Expecting 'SPACE', 'AMP', 'COLON', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'STR'

图中的走向是诊断顺序示意,不是唯一路径。实际排查中,常常需要在线程栈与事件数据之间来回验证。

6.4 观察结果的记录方式

建议每次实验记录:JDK 版本、CPU 核数与容器配额、线程数、迭代数、抓取时刻、线程栈中 BLOCKED/WAITING 的比例、JFR 事件的时间段。没有这些上下文,跨版本的观察结果无法互相比较。禁止把不同版本或错误会话下的观察当作验证:例如在 JDK 21 上抱怨"偏向锁没有生效",或在不含同步代码的会话里抓栈找竞争,都属于无效证据。

7. 失败模式与反例

7.1 反例一:把无锁当锁

有一种常见错误:代码里写的是 AtomicLong 或 LongAdder,却在火焰图里看到高延迟后仍去调自旋次数。AtomicLong 基于 CAS 循环,高竞争下 CAS 失败会重试,表现是 CPU 高而吞吐低,线程栈多为 RUNNABLE,不会出现监视器等待。这时应减少共享写、改用分段累加,而不是调锁参数。

7.2 反例二:临界区内有阻塞调用

若在 synchronized 块里执行数据库查询,等待队列会稳定增长,并且放大下游抖动。此时降低锁竞争的正确方向是把阻塞调用移出临界区,或改为"只保护必要的共享状态",而不是换一种锁。换锁只能改变排队实现,不能消除串行化语义。

7.3 反例三:误读公平与非公平

AQS 提供公平与非公平两种获取策略,但这不代表非公平一定更快或公平一定更慢。某些负载下公平锁减少饥饿、降低长尾;另一些负载下非公平锁吞吐更高。结论必须结合实测,不能把单一场景的对比写成通用规律。

7.4 反例四:样本不足

只抓一次线程栈、只跑一次压测、只在一台机器上比较,都不足以支撑结论。特别是容器环境,CPU 配额与宿主机负载会显著影响结果;把宿主机负载造成的差异读成锁优化效果,是常见误判。

8. 在生产框架里怎么接入观察

Spring Boot 3.5 不改变 JVM 层锁的实现,但它影响你从哪里观察。典型做法:

  1. 在服务启动后记录 JVM 参数与 JDK 版本,确认没有继承实验环境里的调试参数;
  2. 通过 jcmd <pid> VM.command_line 和 jcmd <pid> VM.system_properties 核对运行配置;
  3. 在压测前半段、峰值段、降载段各抓一次线程栈,比较等待线程比例的变化;
  4. 对可疑 synchronized 方法做代码审查,确认临界区内是否包含 IO;
  5. 若怀疑 AQS 同步器排队,检查其获取是否设置了超时,未设置超时会导致线程长时间驻留在等待队列。

以上都是工程建议,不是规范保证。是否在生产开启 JFR 需要单独评估开销与磁盘策略,本文不假设你已经在生产中启用。

9. 适用条件与结论

9.1 结论

  • Java 21 下不再有偏向锁,锁升级讨论应从"无锁/轻量级/重量级"这一层次开始,涉及对象头、Lock Record 与 ObjectMonitor 的描述都属于特定实现的工程观察,不是语言规范。
  • synchronized 与 AQS 同步器是两套排队结构,观察手段不同:前者更多体现为 BLOCKED 与监视器地址,后者更多体现为 WAITING 与 AQS 帧。
  • 定位顺序应是:确认线程状态 → 区分同步原语 → 用事件与火焰图看时间分布 → 判断是持锁过长、粒度过粗还是并发过高 → 只在证据指向处改动。
  • 火焰图、线程栈、JFR 都是诊断数据,存在采样与开销,不支持精确的收益计算,也不支持在没有对照的情况下断言某个改动"更快"。

9.2 不适用或需复核的情况

  • 非 HotSpot 虚拟机(如 OpenJ9)的锁实现不同,本文关于 Mark Word 与锁膨胀的描述不能直接套用;
  • 使用了虚拟线程或响应式模型时,阻塞等待的代价结构变化很大,本文的排队与唤醒论述需要重新评估;
  • 结论依赖具体负载与机器,任何数字都应视为示意,实际项目需要自行复现并记录环境。

9.3 待确认问题

本文资料区未提供外部证据,以下属于需要读者自行核验的点:你所使用的 JDK 发行版是否包含某些诊断事件、剖析器版本是否支持锁模式采样、容器内 jcmd 是否受权限限制。建议以官方文档与本地复现为准,不要直接引用本文的示意结果作为性能承诺。

10. 速查清单

  1. 先确认 JDK 版本,排除"偏向锁仍可用"的旧结论;
  2. 抓多次线程栈,区分 BLOCKED、WAITING、RUNNABLE;
  3. 用 JFR 看等待事件的时间分布,而不是只看总数;
  4. 用锁竞争火焰图定位等待集中的栈帧;
  5. 区分持锁过长、锁粒度过粗、并发过高三种原因;
  6. 改动后在同一环境、同一负载下复测,记录版本与配额;
  7. 不把一次观测当作通用规律,不把诊断数据当作精确计费。
相关推荐
letisgo51 个月前
JAVA 高级进阶02篇《并发编程三板斧:JMM、CAS与AQS的源码级拆解》
java·面试·并发编程·aqs·jmm
随遇而安zx1 个月前
【多线程】---AQS 原理 知识点(设计思想与源码深度解析)
java·多线程·aqs
J_bean2 个月前
阐述 volatile 和 synchronized 的区别以及底层原理
synchronized·volatile·原子性·可见性·有序性·指令重排
赵丙双2 个月前
CountDownLatch 源码分析
java·aqs·countdownlatch
Tsuki_tl2 个月前
【面试高频】 HashMap, HashTable,ConcurrentHashMap 之间的区别
面试·hashmap·synchronized·哈希表·多线程并发·hashtable·分段锁
月落归舟5 个月前
AQS独占模式源码详解全流程
并发编程·aqs
better_liang5 个月前
每日Java面试场景题知识点之-JUC并发编程核心原理与实战
java·线程池·并发编程·juc·aqs·reentrantlock·concurrenthashmap
tongluowan0075 个月前
以ReentrantLock为例解释AQS的工作流程
java·模板方法模式·aqs·reentrantlock
Tsuki_tl5 个月前
【面试高频】常见锁策略
多线程·synchronized·八股文·java面试·锁机制·悲观锁·java后端面试