第 03 篇 · 并发编程与锁机制
本篇从 volatile/JMM 边界出发,经锁升级与 AQS,走到并发容器、分治无锁与 OS 调度成本,统一并发排障口径。
骨架见 M12 / M08。本篇只补领域细枝,框在 核心方法论 · 开篇。
并发主线是「正确性语义(三性)→ 同步原语成本 → 数据结构/等待通知选型 → 无锁与 OS/CPU 交互」,用同一套口径解释「锁住了仍慢」与「无锁仍错」。
- volatile 与三性边界:可见性与有序性有、读改写原子性无;这是 Atomic、锁与 AQS 选型的认知入口。
- synchronized 锁升级与显式锁:无锁→轻量→重量(偏向锁已废弃)解释自适应成本;ReentrantLock/StampedLock 按功能与竞争形态选型。
- AQS、CAS 与 ABA:state + CLH 变体队列 + park/unpark 是 JUC 同步器内核;CAS 局限与版本号/引用方案必须会讲。
- 并发容器与等待通知:CHM 从分段锁到 CAS+桶锁;CopyOnWrite/BlockingQueue/SkipList 按读写比选型;Condition 与 LockSupport 分层抽象。
- 分治、计数与无锁队列:ForkJoin 工作窃取与 commonPool 边界;LongAdder 分段计数;Disruptor RingBuffer 与伪共享治理。
- OS 进程线程模型:内核调度、上下文切换与用户态/内核态成本,决定池大小与虚拟线程收益边界。
- 死锁要有线程转储采集规范与锁顺序纪律;多锁场景在代码评审中强制声明获取顺序,违规不得合入。
- 共享可变状态默认不可接受:能局限可变性就局限;必须共享时书面化同步策略(锁/并发容器/无锁)与测试证明。
- ConcurrentHashMap 等容器禁止依赖「非原子复合操作」的错误用法;size/迭代语义与业务一致性分开验收。
- 高竞争计数与统计优先 LongAdder/LongAccumulator,避免 Atomic 在热点上的伪共享与自旋风暴。
- ForkJoin/parallelStream/CompletableFuture 共用 commonPool 的风险写入规范:阻塞任务必须自带 Executor,禁止污染公共池。
- 无锁/Disruptor 类组件上线前验收伪共享填充、生产者消费者依赖与背压;性能数字必须可复现。
- Condition/wait 使用强制模板:while 条件、持锁、超时与中断策略;禁止「if + wait」进生产。
- 线程数与锁粒度变更按容量实验管理:对照上下文切换、尾延迟与错误率,避免「加线程治慢」。
- 先三性后原语:分清原子/可见/有序,再选 synchronized、Lock、CAS;原语选错比实现慢更危险。
- 成本随竞争变化:无竞争走轻量路径,高竞争必付队列与调度代价;用升级路径解释现象。
- 数据结构即策略:读写比、是否阻塞、是否排序决定容器;API 名字不能替代场景分析。
- 无锁非免费:CAS 重试、ABA、伪共享与单变量边界是固有税;复杂不变式往往更适合锁。
- 池与窃取有边界:分治适合 CPU 可切分任务;阻塞 IO 进 commonPool 是经典事故源。
- 下沉到 OS:Java 锁最终映射到调度与缓存一致性;上下文切换与伪共享要用系统视角看。
- 正确性可测:并发缺陷用压力、JCStress/复现脚本与线程转储证明,不靠「本地跑过」。
这道题考察 JMM(Java Memory Model)中对 volatile 变量的两个语义——保证可见性、禁止指令重排,以及它与"原子性"之间的能力边界。volatile 是 Java 提供的最轻量级同步机制,但它只能保证"单次读/写"的可见性与有序性,无法保证"读-改-写"复合操作的原子性。理解这条边界,是正确使用 Atomic 类、synchronized、AQS 的认知地基。
早期 Java 内存模型(JDK 1.4 之前):volatile 语义较弱,只在 32 位读写上保证可见性,对 64 位 long/double 的读写甚至不保证原子性(允许拆成两次 32 位操作)。这导致跨线程读取 long 可能读到"半值",是早期多线程程序的隐患。
JSR-133 语义增强(JDK 5,2004):Doug Lea 等人主导重写 JMM,明确 volatile 的 happens-before 语义——volatile 写 happens-before 后续的 volatile 读。这一改动让 volatile 成为真正可用的轻量同步原语,DCL(双重检查锁定)单例才得以正确工作。
JDK 9 VarHandle(2017):引入
VarHandle
提供更细粒度的内存访问模式(PLAIN/OPAQUE/RELEASE/ACQUIRE/VOLATILE),允许开发者精确控制内存屏障的强度,比 volatile 更灵活。这是 Java 向"显式内存模型"演进的一步,对标 C++ 的 std::atomic。
未来趋势:随着 Virtual Threads(JDK 21)让并发规模从千级跃升到百万级,volatile 的轻量可见性保证仍是不可替代的;但在更高维度上,结构化并发与响应式模型正在减少"手动 volatile + CAS"的直接使用,开发者更倾向于用高层抽象(Flow、CompletableFuture)而非裸内存语义。
可见性的底层机制:内存屏障 + MESI 协议。JVM 在 volatile 写之后插入 StoreLoad 屏障,在 volatile 读之前插入 LoadLoad/LoadStore 屏障。这些屏障在 x86 上对应 lock 前缀指令或 mfence,强制 CPU 将 Store Buffer 刷新到 L1 Cache,并通过 MESI 缓存一致性协议让其他 CPU 核上持有该缓存行的状态从 M(Modified)/S(Shared)降级为 I(Invalid),下次读取时强制从主内存或拥有最新值的缓存行重新加载。
四类内存屏障:
| 屏障类型 | 作用 | volatile 中的位置 |
|---|---|---|
| StoreStore | 禁止前面的普通写与后面的 volatile 写重排 | volatile 写之前 |
| StoreLoad | 禁止 volatile 写与后续 volatile 读/写重排(开销最大) | volatile 写之后 |
| LoadLoad | 禁止后面的普通读与前面的 volatile 读重排 | volatile 读之后 |
| LoadStore | 禁止后面的普通写与前面的 volatile 读重排 | volatile 读之后 |
为何不能保证原子性:以 count++ 为例,它不是单条指令,而是"读 count → +1 → 写 count"三步。volatile 只保证每一步对其他线程可见,但不保证三步之间不被其他线程插入。两个线程同时读到 count=0,各自 +1 后写回 1,结果丢失一次更新。这是"读-改-写"(Read-Modify-Write)复合操作的本质问题,必须用 CAS(AtomicInteger)或锁来保证整体原子性。
// volatile 保证可见性,不保证原子性的典型反例
public class VolatileCounter {
private volatile int count = 0;
public void increment() {
count++; // 等价于: int tmp = count; tmp = tmp + 1; count = tmp;
// 三步之间可被其他线程穿插,导致丢失更新
}
}
// 正确做法 1: Atomic + CAS(无锁)
private final AtomicInteger count = new AtomicInteger(0);
public void increment() { count.incrementAndGet(); }
// 正确做法 2: synchronized(阻塞锁)
private int count = 0;
public synchronized void increment() { count++; }
DCL 单例为何需要 volatile:instance = new Singleton()
在字节码层是三步——① 分配内存、② 初始化对象、③ 将引用指向内存地址。若无 volatile,JIT 可能将②③重排为"先指向再初始化",此时另一线程在第一次 null 检查时拿到未初始化完成的对象,引发 NPE。volatile 的 StoreStore + StoreLoad 屏障禁止了这种重排。
volatile 的正确使用场景:
- 状态标志位:如
volatile boolean running = true,一个线程写、其他线程读,用于优雅停机。 - DCL 单例:双重检查锁定必须有 volatile 修饰实例字段。
- 一次性安全发布:对象初始化完成后用 volatile 发布,保证其他线程看到完整构造的对象(安全发布模式)。
- 搭配 CAS 使用:Atomic 类内部用 volatile 字段 + Unsafe CAS,volatile 保证可见性、CAS 保证原子性。
- Disruptor 序号:环形队列的生产者/消费者序号用 volatile long + 序列屏障实现无锁并发。
// DCL 单例:volatile 禁止对象初始化的重排
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(持锁)
instance = new Singleton();
}
}
}
return instance;
}
}
volatile 的使用口诀:"一写多读用 volatile,多写多读用 Atomic,复合操作用锁"。当只有"单个线程写、多个线程读"且操作本身就是单次 load/store 时,volatile 是最优解(无锁、无 CAS 开销);一旦涉及"读-改-写",必须升级为 Atomic 或 synchronized。
追问 1:StoreLoad 屏障为什么是四类屏障中开销最大的?
因为 StoreLoad 屏障需要等待所有 Store Buffer 刷新完成,同时保证后续的 Load 不会读到屏障之前的旧值。在 x86 上它对应 lock addl $0,(%rsp) 或 mfence,会触发 Store Buffer 排空 + 全局可见性保证,开销远大于只阻止重排的 StoreStore/LoadLoad。这也是 volatile 写比 volatile 读昂贵得多的原因。
追问 2:volatile 修饰对象引用,对象内部字段变化对其他线程可见吗?
volatile 只保证引用本身的可见性,不保证对象内部字段变化对所有线程立即可见。若对象创建后字段还在被修改,那些字段的可见性需要它们自己加 volatile 或用锁。特例:final 字段在构造方法完成后对所有线程可见(JMM 对 final 的特殊保证),这是"安全发布"的核心。
追问 3:x86 架构下 volatile 写的开销真有那么大吗?
x86 是强内存模型(TSO,Total Store Order),硬件本身就保证 LoadLoad/LoadStore/StoreStore 不重排,只有 StoreLoad 需要 fence。所以 x86 上 volatile 读几乎零开销(普通 load),volatile 写只需在写后加一条 lock 指令。但在 ARM(弱内存模型)上,volatile 需要更多屏障指令,开销显著高于 x86——这是云原生时代(ARM 服务器)需要重新审视 volatile 性能的原因。
- 误用 volatile 做计数器:volatile int count + count++ 不是线程安全的,这是最常见的易错点。记住"复合操作 volatile 不保证原子性"。
-
volatile 数组的元素可见性:
volatile int[] arr只保证 arr 引用可见,arr[0] 的修改不保证可见。需用 AtomicIntegerArray。 - 以为 volatile 比 synchronized 快就不用锁:volatile 无锁但能力有限,用它替代锁会引入正确性问题。
- 忽略 happens-before 的传递性:A happens-before B,B happens-before C,则 A happens-before C。volatile 写后的所有操作对后续 volatile 读的线程可见,这是利用 volatile 做安全发布的理论依据。
JMM 定义的 happens-before 完整规则共 8 条,要求能完整列举并解释含义:
- 程序顺序规则:单线程内,前面的操作 happens-before 后面的操作(as-if-serial 语义,感觉上顺序执行)。
- 监视器锁规则:对一个锁的 unlock 操作 happens-before 后续对同一把锁的 lock 操作(synchronized 可见性的来源)。
- volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后续对这个变量的读操作。
- 线程启动规则:
Thread.start()的调用 happens-before 该线程内的任意操作(start 前的准备工作对子线程可见)。 - 线程终止规则:线程内的任意操作 happens-before 其他线程检测到该线程终止(
Thread.join()返回或isAlive() == false)。 - 线程中断规则:对线程的
interrupt()调用 happens-before 被中断线程检测到中断(Thread.interrupted())。 - 对象终结规则:对象的构造函数执行完成 happens-before 该对象的
finalize()方法开始执行。 - 传递性规则:若 A happens-before B,且 B happens-before C,则 A happens-before C。
- 语义澄清:happens-before 不是说"前一个操作一定先执行",而是"前一个操作的结果对后一个操作可见"——它是可见性 + 有序性的约束,不是时序先后。
- 易错点:volatile 只保证单次读/写的可见性与有序性,不保证 i++ 这类复合操作的原子性(读-改-写三步可能交错),计数场景需用
AtomicInteger或 synchronized。
8 条规则是 JMM 向开发者的"契约":JVM 只要在编译器优化、CPU 重排、缓存一致性层面不违反这些约束,其余情况可以任意重排。这解释了很多看似"玄学"的现象——例如子线程看不到主线程的普通变量赋值(无任何 hb 边),但把变量改为 volatile 后即可见(规则 3);线程池中任务能看到提交前的修改(submit 内部有队列锁的 lock/unlock,规则 2 + 传递性)。分析并发正确性时,画出操作间的 happens-before 边是系统化的推导方法。
上线门禁:锁竞争指标(JFR/阻塞采样)、死锁检测演练。表达提纲:热点栈→锁粒度/数据结构→验证吞吐与尾延迟。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
这道题考察对 synchronized 在 JVM 层"自适应优化"的理解——从无竞争到高竞争,JVM 如何在轻量级锁 → 重量级锁两级之间升级(JDK 15 起偏向锁已废弃,JEP 374,锁状态由 4 种简化为 3 种,现升级路径为:无锁 → 轻量级锁 → 重量级锁),以在"锁开销"与"竞争处理"间取得动态平衡。本质是 HotSpot 团队对"绝大多数锁在整个生命周期内不会出现竞争"这一经验的工程化利用。
JDK 1.0~1.3:重量级锁独占。synchronized 直接映射到 OS 的互斥量(mutex),每次加锁解锁都涉及用户态/内核态切换,开销巨大。这也是早期 Java"慢"的代名词之一,Doug Lea 的 util.concurrent 库正是为了绕开它而生。
JDK 6(2006):锁优化大爆发。HotSpot 引入偏向锁、轻量级锁、自旋锁、锁消除、锁粗化五项优化,让 synchronized 在无竞争/低竞争场景下性能逼近 ReentrantLock。这是"synchronized 性能不再差"的认知转折点。
JDK 15(2020):偏向锁废弃并默认关闭(JEP 374)——BiasedLockingStartupDelay 默认值从 4000ms 改为 0ms(等效禁用),-XX:+UseBiasedLocking 标记废弃;JDK 18 起移除启动延迟参数。Oracle 发现偏向锁维护成本高(占 HotSpot 同步子系统的 5%~10% 代码)、收益在现代 CAS 指令下递减,且与 Virtual Threads 等新特性冲突,正式废弃并默认关闭。synchronized 回归到"轻量级锁 + 重量级锁"两级。
未来趋势:synchronized 与 ReentrantLock 的性能差距已可忽略。JDK 24+(JEP 491)重构了 synchronized 实现(ObjectMonitor 改造为可虚拟化),允许虚拟线程在 synchronized 块内 park 时正常卸载,消除了最主要的 pinning 源。JDK 24+ 不必再为 pinning 而回避 synchronized。
Mark Word:锁状态的载体。每个 Java 对象的对象头(Header)中有一个 64 位的 Mark Word,不同锁状态复用这 64 位存储不同信息:
| 锁状态 | Mark Word (64 bit) | 触发条件 |
|---|---|---|
| 无锁 | hash(31) + age(4) + biased(1) + 01 | 刚创建,未加锁 |
| 偏向锁(已废弃:JDK 15 JEP 374 禁用并废弃,锁状态由 4 种简化为 3 种) | threadId(54) + epoch(2) + age(4) + 1(1) + 01 | 首次被某线程获取(仅 JDK 15 前默认开启,历史状态) |
| 轻量级锁 | 指向栈中锁记录的指针(62) + 00 | 多线程交替进入,无真正竞争 |
| 重量级锁 | 指向 ObjectMonitor 的指针(62) + 10 | 自旋失败,真正竞争 |
| GC 标记 | 空 + 11 | GC 期间标记用 |
无锁 01] -->|加锁 CAS| D[轻量级锁
Lock Record 00] D -->|CAS 成功 / 交替进入| D D -->|自旋失败或真实竞争| E[重量级锁
ObjectMonitor 10] E -->|线程阻塞| F[内核态 park
EntryList] F -->|锁释放| G[唤醒等待线程] A -.->|历史路径 JDK15 前
已废弃 JEP 374| B[偏向锁 threadId] B -.->|竞争撤销| D style D fill:#fef3c7,stroke:#d97706 style E fill:#fee2e2,stroke:#dc2626 style B fill:#e5e7eb,stroke:#9ca3af,stroke-dasharray: 5 5
三级锁的核心机制:
- 偏向锁(Biased Locking)(已废弃:JDK 15 JEP 374 禁用并废弃,synchronized 回归「轻量级锁 + 重量级锁」两级):对象头记录持有线程 ID,同线程再次进入只需 CAS 比较 threadId,无需任何同步操作。一旦出现第二个线程竞争,偏向撤销并升级。以下为历史机制描述,JDK 15+ 不再参与锁升级。
- 轻量级锁:在线程栈帧创建 Lock Record,CAS 把 Mark Word 复制到 Lock Record(Displaced Mark Word),并尝试把对象头指向 Lock Record。成功则获得锁,失败则自旋重试。适合"多线程交替进入,但无真正同时竞争"的场景。
- 重量级锁:自旋超过阈值(自适应自旋)仍未获得锁,升级为重量级锁,通过 ObjectMonitor 调用 OS mutex(Linux futex)将线程阻塞在内核态,进入 EntryList 队列等待唤醒。
// 用 JOL(Java Object Layout)观察对象头锁状态变化
import org.openjdk.jol.info.ClassLayout;
Object lock = new Object();
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// 输出 MARK: 0x0000000000000001 (无锁,末尾 001)
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// JDK15 前: 0x00007f...0101 (偏向锁, 末尾 101 + biased=1)
// JDK15 后: 0x00007f...00 (轻量级锁, 末尾 00)
}
额外优化:锁消除与锁粗化。锁消除基于逃逸分析——若 JIT 判定 StringBuffer 只在方法内使用(无逃逸),会消除其 synchronized。锁粗化则是把相邻的多次对同一对象的加锁合并为一次,减少加锁次数。
JDK 25+(JEP 519)引入紧凑对象头(可选启用
-XX:+UseCompactObjectHeaders,默认关闭),将对象头压缩至 64 位(传统默认 96 位、未压缩场景 128 位),Mark Word 位布局有调整;传统对象头仍为默认,使用 JOL 观察时需注意是否启用该特性。
高级开发 · synchronized 使用要点:
- 减小锁粒度:synchronized(this) 会让整个对象成为锁,优先用 synchronized 锁独立的小对象或独立方法,避免无关方法相互阻塞。
-
避免锁字符串/Integer 缓存:
synchronized("constant")会因为字符串常量池导致跨类持同一把锁;synchronized(Integer.valueOf(1))会因 Integer 缓存(-128~127)撞锁。 - JDK15+ 不要依赖偏向锁:若代码逻辑强依赖偏向锁的"零开销同线程重入",迁移到 JDK15+ 后需重新评估性能。
- 观察锁状态:用 JOL 或
jcmd PID Thread.print观察线程在 monitor 上的阻塞情况,定位"轻量级锁升级"热点。
JDK6 优化后,无竞争场景下 synchronized 与 ReentrantLock 性能几乎一致。优先选 synchronized 的理由:① 语法简洁,JVM 深度优化(锁消除/粗化/自旋);② 不担心忘记 unlock(自动释放);③ 异常时自动释放锁。只有需要"可中断/超时/公平/多 Condition/尝试加锁"时才用 ReentrantLock。
追问 1:偏向锁为什么在 JDK15 被废弃?
核心原因是收益递减 + 维护成本高。偏向锁在"单线程长期持有锁"场景收益最大,但现代应用多为多线程并发,偏向锁的"撤销"操作(safepoint 时批量撤销)反而成为开销。Oracle 数据显示,维护偏向锁代码占同步子系统 5%~10%,但平均性能收益 <1%。同时偏向锁与 Virtual Threads 存在语义冲突(载体线程切换时偏向锁需撤销),综合权衡后废弃。
追问 2:锁能降级吗?
理论上存在锁降级(GC safepoint 时可做锁降级优化),但实际运行时几乎不降级。一旦升级到重量级锁,对象头会一直指向 ObjectMonitor,即使后续无竞争。这也是"长生命周期对象长期持锁"性能差的原因——建议用局部对象做锁,让对象随方法栈销毁。
追问 3:调用 hashCode() 或 wait() 对锁有什么影响?
调用 hashCode() 会将 hash 写入 Mark Word,导致偏向锁必须撤销(Mark Word 无空间同时存 threadId 和 hash)。调用 wait() 必须先持有 monitor,会直接升级到重量级锁(wait/notify 依赖 ObjectMonitor 的 WaitSet)。这就是为什么 wait/notify 只能在 synchronized 块内调用。
- 偏向锁启动延迟(历史行为,JDK 15 起偏向锁已废弃 JEP 374,此项不再适用):JDK15 前 JVM 启动 4 秒后才启用偏向锁(-XX:BiasedLockingStartupDelay=4000),短生命周期测试看不到偏向锁。
- 误以为锁会自动降级:升级到重量级锁后基本不降级,长期持锁对象建议替换为新的锁对象。
- 锁字符串/Integer 缓存对象:会导致跨业务撞锁,引发诡异死锁或性能问题。
- 认为 synchronized 性能差而一律用 ReentrantLock:JDK6 后这是过时认知,无竞争场景 synchronized 经 JVM 优化后性能更好。
上线门禁:锁竞争指标(JFR/阻塞采样)、死锁检测演练。表达提纲:热点栈→锁粒度/数据结构→验证吞吐与尾延迟。
先给可观测证据与门禁,再谈实现细节;避免只报组件名。
AQS(AbstractQueuedSynchronizer)是 Doug Lea 设计的 JUC 同步器框架,ReentrantLock/Semaphore/CountDownLatch/ReentrantReadWriteLock 全部基于它构建。这道题考察对"state + CLH 变体队列 + CAS + park/unpark"四要素如何协作实现阻塞同步的深度理解,是 Java 并发编程的"内功心法"。
前 AQS 时代(JDK 1.4 之前):Java 只有 synchronized(OS 互斥量)和 wait/notify,无法实现公平锁、可中断锁、条件变量等高级语义。开发者只能手写各种蹩脚的同步工具。
Doug Lea 的 util.concurrent(2003):Doug Lea 在第三方库中设计了 AQS 的雏形,用 CAS + 等待队列实现了一套通用同步框架。这套设计后被 JSR-166 采纳,于 JDK 5(2004)正式进入 java.util.concurrent。
JDK 6~21 持续优化:AQS 内部从原始 CLH 改为 CLH 变体(引入自旋入队、nextWaiter、condition 队列);JDK 11 引入 VarHandle 替代部分 Unsafe 调用;JDK 19+ 配合 Virtual Threads 探索 park 的虚拟线程感知优化。
设计哲学:AQS 体现了"模板方法 + 状态机"的设计——框架管理队列与阻塞/唤醒,子类只需实现 tryAcquire/tryRelease 等几个 CAS 操作 state 的方法。这种"基础设施与策略分离"的设计让一套框架支撑了十几种同步器。
AQS 的四大基石:
- state(volatile int):同步状态。ReentrantLock 用它表示锁被重入几次,Semaphore 用它表示剩余许可数,CountDownLatch 用它表示还剩多少个计数。volatile 保证了 state 在多线程间的可见性,确保一个线程的更新对其他线程立即可见。
- CLH 变体队列:双向链表,存放等待获取锁的线程封装节点(Node)。原始 CLH 是单向队列且基于自旋,AQS 改为双向并配合 park 阻塞。
- CAS:对 state 和队列指针的无锁原子更新。
- LockSupport.park/unpark:线程的阻塞与唤醒原语。
Thread-A
SIGNAL] N1 --> N2[Node2
Thread-B
WAITING] N2 --> N3[Node3
Thread-C
WAITING] N3 -.-> T[tail] end S["state (volatile int)
0=未占用 1=已占用"] -.-> H style S fill:#fef3c7,stroke:#d97706 style H fill:#dbeafe,stroke:#2563eb
独占模式加锁流程(以 ReentrantLock 非公平锁为例):
// 简化的 AQS 独占 acquire 逻辑
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
Thread.currentThread().interrupt();
}
// 1. tryAcquire: 先 CAS 尝试直接拿锁(非公平可"插队")
// 2. addWaiter: CAS 把当前线程封装成 Node 加入队尾
// 3. acquireQueued: 前驱是 head 时再次 tryAcquire;否则 park 阻塞
final boolean acquireQueued(Node node, int arg) {
boolean failed = true, interrupted = false;
try {
for (;;) {
Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true; // acquire 不抛异常,仅记录中断状态
}
} finally { if (failed) cancelAcquire(node); }
}
// parkAndCheckInterrupt: LockSupport.park 阻塞当前线程
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.currentThread().isInterrupted();
}
CLH 变体与原始 CLH 的关键差异:原始 CLH 队列每个节点只看前驱节点的状态自旋等待(busy-wait),开销大且无法 park。AQS 改造为双向链表,并在前驱节点状态为 SIGNAL(表示前驱释放时会唤醒后继)后调用 LockSupport.park 阻塞,避免自旋空耗 CPU。这种"自旋入队 + park 阻塞"的混合策略兼顾了入队阶段的低延迟与等待阶段的低 CPU 占用。
公平 vs 非公平:非公平锁的 tryAcquire 上来就 CAS 抢,无视队列中有无等待者;公平锁会先调用
hasQueuedPredecessors()
检查队列前是否有等待者。非公平锁吞吐更高(减少线程切换),但可能导致队列线程"饥饿"。
共享模式:Semaphore/CountDownLatch 用共享模式,tryAcquireShared 返回值 ≥0 表示成功且可向后传播唤醒(doReleaseShared 传播唤醒后续共享节点),这是 CountDownLatch"最后一个 countDown 唤醒所有 await"的实现机制。
共享模式 vs 独占模式的核心差异:独占模式(ReentrantLock)的 tryAcquire 返回 true/false 且传播性有限;共享模式(Semaphore)的 tryAcquireShared 返回 int ≥0(剩余许可数)并支持传播唤醒,允许多个线程同时持有锁资源。
Semaphore 共享模式实现细节:tryAcquireShared(int permits) 尝试获取多个许可,成功时返回剩余许可数并传播唤醒其他等待者;doAcquireSharedInterruptibly 会阻塞直到获取足够许可或被中断。这种设计实现了"公平的多资源分配"。
架构师 · 基于 AQS 实现自定义同步器。当 JDK 内置的同步器不满足需求(如实现"同时允许 N 个线程写 + M 个线程读"的混合限流),可继承 AQS:
// 自定义互斥锁(演示 AQS 模板方法)
public class Mutex implements Lock, java.io.Serializable {
private static class Sync extends AbstractQueuedSynchronizer {
// state=0 未占用, state=1 已占用
protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
protected boolean tryRelease(int unused) {
setExclusiveOwnerThread(null);
setState(0);
return true;
}
protected boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}
}
private final Sync sync = new Sync();
public void lock() { sync.acquire(1); }
public void unlock() { sync.release(1); }
public Condition newCondition() { return sync.newCondition(); }
// ... 其他 Lock 方法委托给 sync
}
AQS 是"基础设施 + 策略"分离的典范。架构师设计中间件时(如分布式锁客户端、限流器、工作流引擎的令牌控制),可借鉴 AQS 的"框架管队列/阻塞,策略管 state 语义"模式,避免每个同步器都重写一遍等待队列逻辑。
追问 1:AQS 用的是 CLH 还是 MCS 队列?
严格说是 CLH 变体。原始 CLH 单向、基于前驱状态自旋;MCS 是"每个节点自旋在自己的状态上,前驱释放时改后继状态"。AQS 借鉴了 CLH 的"看前驱"思想,但改为双向链表 + park 阻塞(而非自旋),并增加 nextWaiter、condition 队列等扩展。Doug Lea 在注释中明确称其为 "CLH lock queue 的变体"。
追问 2:AQS 为什么用 park 而不是自旋?
纯自旋在锁持有时间长时会浪费大量 CPU,尤其在核数有限时。AQS 采用"自旋入队(CAS 加节点)+ park 等待"混合策略——入队失败的少数次自旋后立即 park,把 CPU 让给持有锁的线程,整体吞吐优于纯自旋。但若锁持有时间极短(几纳秒),park 的系统调用开销反而成为瓶颈,这种场景 StampedLock 的乐观读更合适。
追问 3:AQS 的性能瓶颈在哪?
高并发下,所有线程争抢同一个 head 指针的 CAS(入队)和 state 的 CAS(抢锁)会形成热点。这是 AQS 的固有限制——单 state 单队列无法横向扩展。LongAdder 的 Cell[] 分段思路可以借鉴到计数场景,但互斥锁的"排他性"使得分段锁在锁场景收益有限(ConcurrentHashMap 的分段锁已证明这点,JDK8 后退回 synchronized 桶级锁)。
- 误以为 AQS 队列是原始 CLH:AQS 是双向链表 + park 的 CLH 变体,与单向自旋的原始 CLH 不同。
- 误以为 acquire 不可中断:
acquireInterruptibly可中断,acquire会记录中断状态但不立即响应(park 被中断唤醒后会重新检查)。 - 忘记自实现的 tryRelease 要先清 owner 再 setState:顺序反了会导致 isHeldExclusively 短暂返回错误值。state 是 volatile,写顺序有可见性影响。volatile 语义确保 owner 清空后 state 的更新对其他线程可见。
- 以为公平锁一定更好:公平锁吞吐比非公平低 10%~30%,只有"不能饥饿"的严格场景才用公平锁。
AQS 之上构建的四大同步器(Semaphore / CountDownLatch / CyclicBarrier / Phaser)是并发协作的常用工具,常考对比:
- Semaphore(信号量):令牌限流。构造时指定 permits 数量,
acquire()获取令牌、release()归还;release 必须放在 finally 块中防止令牌泄漏;支持公平/非公平模式。共享模式下多个线程可以同时获取许可,常用于连接池限流、并发任务数控制等场景。 - CountDownLatch(倒计时锁存器):一次性,不可重置。
countDown()计数减一,await()等待归零;适用于"等待 N 个线程全部完成"的场景(如服务启动等待依赖组件就绪)。 - CyclicBarrier(循环屏障):可重置。所有线程到达
await()后一起放行;支持 barrier action(放行前先执行一个动作,如合并各线程的局部结果);适用于"分阶段并行计算"场景。 - Phaser(阶段器):高级版 CyclicBarrier,支持动态注册/注销 parties 数量(
register()/arriveAndDeregister())与多阶段同步(arriveAndAwaitAdvance()),适合参与者数量动态变化的 ForkJoin 子任务协调。
| 同步器 | 可重置 | 定位场景 | 典型用例 |
|---|---|---|---|
| Semaphore | N/A | 限流 | 连接池、令牌桶 |
| CountDownLatch | 否 | 一次性等待 | 服务启动等待依赖就绪 |
| CyclicBarrier | 是 | 周期性同步 | 并行计算分阶段推进 |
| Phaser | 是 | 动态多阶段 | ForkJoin 子任务协调 |
-
易踩的坑:CountDownLatch 的
countDown()不放在 finally 中,一旦业务代码抛异常计数永远到不了 0,await()永久阻塞;CyclicBarrier 的 barrier action 由最后到达的线程执行,不应放耗时逻辑。
四者的本质差异在于"状态语义":Semaphore 的 state 是"剩余令牌数",acquire/release 可不对称调用;CountDownLatch 的 state 是"倒计数",单向递减到 0;CyclicBarrier 的状态是"到达人数 + 代(generation)",满员后重置进入下一代;Phaser 则把"代"泛化为任意多阶段并允许参与者动态增减。它们全部基于 AQS(CyclicBarrier 内部借 ReentrantLock + Condition 实现,Phaser 自维护 treiber 栈 + 状态位),理解 state 的语义就能推断出每个 API 的行为边界。
CAS(Compare-And-Swap)是无锁编程的核心原语,所有 JUC 同步器、Atomic 类都建立在它之上。这道题考察 CAS 的工作原理、三大固有局限(ABA、自旋开销、单变量边界),以及针对 ABA 的解决方案。理解 CAS 是理解 volatile、Atomic、LongAdder、AQS 的共同基础。
硬件原语(1980s):CAS 最早是 IBM 370 架构的 Compare-and-Swap 指令,后 x86 提供 CMPXCHG(多核需 LOCK 前缀保证原子性),ARM 提供 LDREX/STREX 对(后来 LL/SC 模式)。
Java 1.5 Unsafe(2004):通过 sun.misc.Unsafe.compareAndSwapXxx 暴露 CAS 能力,但 Unsafe 是内部 API,非公开。AtomicInteger 等原子类封装了它。
Java 9 VarHandle(2017):java.lang.invoke.VarHandle
成为标准化的内存访问 API,替代 Unsafe 的 CAS 调用,支持更丰富的内存顺序(RELEASE/ACQUIRE/OPAQUE)。
未来趋势:硬件层面,Transactional Memory(事务内存)曾被视为 CAS 的继任者,但至今未普及;软件层面,CAS 仍是主流无锁原语,但在高竞争下退化为自旋浪费,LongAdder 的"分段 CAS"是工程上的优雅妥协。
CAS 的本质:三参数操作 CAS(V, expected, new)——若内存值 V == expected,则将 V 更新为 new,返回 true;否则返回 false。整个操作由 CPU 单条指令原子完成(x86 上是 lock cmpxchg)。
// AtomicInteger.incrementAndGet 的 CAS 实现(简化)
public final int incrementAndGet() {
int oldValue, newValue;
do {
oldValue = get(); // volatile 读
newValue = oldValue + 1;
} while (!compareAndSet(oldValue, newValue)); // CAS 自旋
return newValue;
}
// compareAndSet 底层调用 Unsafe.compareAndSwapInt
// -> x86: lock cmpxchg 指令,原子完成"比较+交换"
ABA 问题:线程 1 读到值 A,准备 CAS(A→C);此时线程 2 把 A 改成 B 又改回 A;线程 1 的 CAS 仍成功(值还是 A),但中间的 B 状态被"无视"了。在多数计数场景(如 increment)ABA 无害,但在涉及对象复用/状态机的场景(如无锁栈中节点弹出后又被压回)会导致逻辑错误。
解决方案一:版本号(AtomicStampedReference)。给值附加一个 int stamp(版本号),每次更新同时更新 stamp,CAS 时比较 [值+stamp] 二元组。即使值回到 A,stamp 也变了,CAS 失败。
// AtomicStampedReference 解决 ABA
AtomicStampedReference<String> ref =
new AtomicStampedReference<>("A", 0); // 初始值 A, 版本 0
int[] stamp = new int[1];
String current = ref.get(stamp); // current="A", stamp[0]=0
// 其他线程把 A→B→A,但 stamp 会变为 0→1→2
// 本线程 CAS 时 stamp 不匹配, 失败
boolean ok = ref.compareAndSet("A", "C", 0, 1);
// 若已被改过, 返回 false, 不会误更新
解决方案二:布尔标记(AtomicMarkableReference)。用 boolean mark 替代 stamp,适合只关心"是否被动过"的场景,开销更小。
CAS 的三大局限:
| 局限 | 描述 | 解决方案 |
|---|---|---|
| ABA 问题 | 值经历 A→B→A 后 CAS 仍成功 | AtomicStampedReference(版本号) |
| 自旋开销 | 高竞争下 CAS 反复失败重试,浪费 CPU | 限制自旋次数、退避策略、LongAdder 分段 |
| 单变量边界 | 一次只能 CAS 一个变量,无法原子更新多个 | AtomicReference 封装对象、分布式锁 |
无锁队列的经典实现:Michael-Scott 队列(ConcurrentLinkedQueue 的算法基础)用 CAS 实现无锁入队/出队,但出队时存在 ABA 风险(节点被回收后复用)。Java 中通过 GC 避免了节点复用的 ABA(节点不会被"重用为同一个内存地址"),所以 ConcurrentLinkedQueue 不需要版本号——这是 GC 语言实现无锁结构的天然优势。
实际项目中的 CAS 应用:
- 自旋锁:
while(!CAS(0,1)),适合临界区极短且不能阻塞的场景。 - 乐观锁:数据库 update set ... where version=?,是 CAS 思想在 SQL 的映射。
- Disruptor 序号:生产者争抢下一个可用序号用 CAS。
- 状态机推进:订单状态从"待支付"推进到"已支付"用 CAS(expected=待支付, new=已支付)。
ABA 在纯数值自增场景无害(i++ 不关心中间值),在对象引用复用场景才危险。Java 因 GC 几乎免疫引用级 ABA,只有自己实现对象池/节点复用时才必须用 AtomicStampedReference。不要为了"显得严谨"在计数场景盲目加版本号。
追问 1:CAS 自旋在极高并发下会怎样?
吞吐量不升反降——大量线程同时 CAS 同一个变量,CACHE 行在多核间反复失效(MESI 频繁 invalidate),形成缓存行乒乓。这是 AtomicLong 在 64 核以上高并发计数时性能急剧下降的根因。LongAdder 通过 Cell[] 分散热点解决此问题(见 C03.10)。
追问 2:Unsafe.compareAndSwap 与 VarHandle.compareAndSet 有何区别?
Unsafe 是 JDK 内部 API,JDK 9 后逐步受限访问;VarHandle 是 JDK 9 引入的标准 API,功能更丰富(支持 weakCompareAndSet、getAndAdd 等多种内存顺序)。性能上两者最终都映射到同一 CPU 指令,但 VarHandle 允许 JIT 更激进地优化(如消除冗余屏障)。新代码应优先用 VarHandle。
追问 3:为什么 CAS 必须配合 volatile?
CAS 的"比较"是对内存的读,"交换"是对内存的写。若字段不是 volatile,CPU 可能从工作内存(寄存器/L1)读到旧值做比较,导致逻辑错误(虽然 CAS 指令本身原子,但比较的"旧值"可能已过期)。AtomicInteger 的 value 字段就是 volatile int,保证 get() 读到的是最新值,再交给 CAS 原子更新。
- 在计数场景盲目担心 ABA:incrementAndGet 不需要担心 ABA,ABA 是引用复用场景的问题。
- CAS 自旋无上限:高竞争下无限自旋会 CPU 100%,应限制重试次数或退避。
- 误以为 CAS 是"无等待":CAS 是 lock-free(系统整体会推进),不是 wait-free(每个线程都有限步完成),可能某线程一直失败。
- AtomicStampedReference 的 stamp 复用:stamp 是 int,理论上会回绕(2^32 次更新后回到原值),实际工程中几乎不会触发。
这道题考察 Java 锁的演进脉络与选型决策。synchronized 是 JVM 内置锁,ReentrantLock 是 JDK 层基于 AQS 实现的显式锁,StampedLock 是 JDK8 引入的乐观读锁。三者各有适用场景,选型背后是"功能丰富度 vs 使用简洁性 vs 性能特性"的权衡。
synchronized(JDK 1.0):内置锁,语法简洁但功能单一(不可中断、不可超时、单条件变量)。早期因重量级锁开销大而被诟病。
ReentrantLock(JDK 5,2004):Doug Lea 设计,基于 AQS,提供可中断、超时、公平锁、多 Condition 等高级特性。在 JDK6 synchronized 优化前,它是高性能并发场景的首选。
synchronized 性能反超(JDK 6,2006):偏向锁/轻量级锁/锁消除等优化让 synchronized 在无竞争场景逼近 ReentrantLock,且 JVM 持续深度优化(JIT 可消除 synchronized 但无法消除 ReentrantLock 的 unlock 调用)。
StampedLock(JDK 8,2014):针对"读多写少"场景引入乐观读锁,读操作完全无锁(仅校验 stamp),吞吐远超 ReentrantReadWriteLock。但 API 复杂、不可重入。
JDK15 偏向锁废弃后:synchronized 与 ReentrantLock 在无竞争场景性能差异进一步缩小,选型回归到"功能需求"而非"性能差异"。
三者核心特性对比:
| 特性 | synchronized | ReentrantLock | StampedLock |
|---|---|---|---|
| 实现层面 | JVM 内置(monitorenter/exit) | JDK API(AQS) | JDK API(StampedLock 内部状态) |
| 可中断 | 否 | 是(lockInterruptibly) | 是 |
| 超时获取 | 否 | 是(tryLock(timeout)) | 是 |
| 公平锁 | 否(非公平) | 是(构造参数) | 否 |
| 条件变量 | 1 个(wait/notify) | 多个(Condition) | 无 Condition;asReadLock/asWriteLock 只是兼容视图,不提供 ReentrantLock 的等待/通知模型 |
| 乐观读 | 否 | 否 | 是(tryOptimisticRead) |
| 可重入 | 是 | 是 | 否 |
| 锁释放 | 自动(出块/异常) | 手动(必须 finally unlock) | 手动(必须 finally unlock) |
| 锁消除/粗化 | JIT 可优化 | 无法优化 | 无法优化 |
StampedLock 的乐观读原理:写锁和读锁通过一个 long stamp(含锁状态位 + 版本号)管理。tryOptimisticRead() 返回当前 stamp(无任何 CAS,零开销),读完后
validate(stamp)
校验期间是否发生过写操作。若未发生写,读结果有效;若发生过写,降级为悲观读锁重新读。这避免了 ReentrantReadWriteLock"读锁也要 CAS"的开销。
// StampedLock 乐观读模板(官方推荐写法)
private final StampedLock sl = new StampedLock();
public Point readPoint() {
long stamp = sl.tryOptimisticRead(); // 1. 乐观读(无锁)
double x = this.x, y = this.y;
if (!sl.validate(stamp)) { // 2. 校验期间是否被写
stamp = sl.readLock(); // 3. 降级为悲观读锁
try {
x = this.x; y = this.y;
} finally {
sl.unlockRead(stamp);
}
}
return new Point(x, y);
}
选型决策树:
- 默认选 synchronized:语法简洁、自动释放锁、JIT 可优化。80% 的加锁场景应选它。
- 需要可中断/超时/公平/多 Condition/尝试加锁 → ReentrantLock。
- 读多写少且追求极致读吞吐(如配置缓存、坐标点)→ StampedLock。
- 读写分离但需可重入 → ReentrantReadWriteLock。
不要为"看起来高级"而用 ReentrantLock。ReentrantLock 的 unlock 若忘记在 finally 中调用,会导致锁永久泄漏(synchronized 不会有此问题)。只有 synchronized 无法满足功能需求时才升级。StampedLock 更要谨慎——不可重入、API 复杂、误用易死锁,只在性能压测证明 ReentrantReadWriteLock 成为瓶颈时才引入。
追问 1:为什么 JDK6 后 synchronized 性能能反超 ReentrantLock?
因为 synchronized 是JVM 内置,HotSpot 可做深度优化:① 锁消除(逃逸分析后消除无竞争锁);② 锁粗化(合并相邻加锁);③ 轻量级锁自适应升级(偏向锁 JDK 15 已废弃 JEP 374,现仅轻量级锁/重量级锁两级);④ JIT 可内联 monitorenter/exit 并做激进优化。而 ReentrantLock 的 lock/unlock 是普通 Java 方法调用,JIT 无法消除,每次都有方法调用开销。无竞争场景下 synchronized 的"零开销"优势明显。
追问 2:StampedLock 为什么不可重入?
StampedLock 的设计目标是"读多写少的高吞吐",重入需要记录持有者线程与重入次数,会增加每次读锁的开销(违背乐观读零开销初衷)。若同线程递归调用需要读锁,必须自己用 ThreadLocal 维护 stamp 链,或退回 ReentrantReadWriteLock。这是设计上的刻意取舍。
追问 3:ReentrantLock 的 Condition 比 synchronized 的 wait/notify 强在哪?
一个 Lock 可创建多个 Condition,每个 Condition 有独立的等待队列,可精准唤醒某类等待者(如"非满"和"非空"两个条件)。而 synchronized 的 wait/notify 只有一个等待队列,notify 只能随机唤醒一个,无法分类。这是阻塞队列(ArrayBlockingQueue 等)用 ReentrantLock + 两个 Condition 实现的原因。
- ReentrantLock 忘记 finally unlock:异常时锁泄漏,是最常见的生产事故。lock() 后必须紧跟 try-finally-unlock。
- StampedLock 误用为可重入:同线程重复 readLock 会死锁。
- StampedLock 乐观读后未 validate:读到的可能是中间态数据,必须 validate 校验。
- 公平锁滥用:公平锁吞吐低且不保证"先到先得"的严格性(tryAcquire 与队列检查非原子),多数场景用非公平即可。
心智切换:平台线程时代「线程数 ≈ 并发度」;虚拟线程时代「载体线程数 ≈ CPU 并行度」,临界区过长会钉住载体(JDK 21–23 的 synchronized;任何版本的 native/JNI),把百万虚拟线程吞吐打回「核数级」。
再平衡原则:① 临界区只保护共享可变状态,IO/远程调用移出锁外;② JDK 21–23 偏好短临界区 + ReentrantLock;JDK 24+(JEP 491)synchronized 主要 pinning 源消除,选型回归语义;③ 仍用 JFR jdk.VirtualThreadPinned 验收(工具版本线见 C04.09)。
容量公式(量化):设载体并行度 C ≈ CPU,单请求在临界区内平均占用 t_crit,临界区外阻塞/计算 t_out。粗估:
- 无 pinning 时吞吐 ≈ 受下游与业务逻辑限制,虚拟线程可高并发等待;
- 若临界区 pinning:有效并发 ≤
C,吞吐上限 ≈C / t_crit(数量级)。例:C=16,t_crit=5ms → 上限约 3200 QPS,再多虚拟线程也救不了; - 优化目标:把 t_crit 压到亚毫秒,或改为无共享可变(线程封闭/并发集合/分片)。
沟通时先报「是否 pinning + 临界区耗时占比」,再谈锁类型。空谈「上了虚拟线程」而无临界区度量,是常见翻车点。互链 C03.05 / C04.09 / S06.01。
ConcurrentHashMap 是 Java 并发工程的经典案例,它的演进浓缩了"全表锁 → 分段锁 → CAS + 单桶锁"的锁优化思想。这道题考察 JDK7 分段锁到 JDK8 CAS+synchronized 的演进动机,以及高并发哈希表的设计权衡——在"并发度"与"实现复杂度"间寻找最优点。
HashMap(非线程安全):多线程 put 可能触发扩容,JDK7 头插法会形成链表环导致 CPU 100%(get 死循环),JDK8 改尾插法避免环但仍会丢数据。
Hashtable(JDK 1.0,全表锁):每个方法 synchronized,并发度 1,性能极差,已淘汰。
Collections.synchronizedMap(包装层全表锁):本质同 Hashtable,只是用装饰器包装 HashMap。
JDK7 ConcurrentHashMap(分段锁 Segment):默认 16 个 Segment,每个 Segment 是一个独立的 ReentrantLock + HashEntry[]。并发度 = Segment 数,不同 Segment 可并行写入。读操作无锁(volatile HashEntry)。这是"锁分段"思想的经典实现。
JDK8 ConcurrentHashMap(CAS + synchronized + 红黑树):废弃 Segment,退回 Node[] 数组,对单个桶头节点 synchronized + CAS。并发度 = 桶数(默认 16,随扩容增长)。引入红黑树解决哈希冲突下的链表过长问题。这是"锁粒度从段细化到桶"的演进。
ReentrantLock] --> H1[HashEntry数组] S2[Segment1
ReentrantLock] --> H2[HashEntry数组] SN[...Segment15] --> HN[HashEntry数组] end subgraph JDK8 CAS+synchronized NA[Node 数组] NA --> B0[桶0 synchronized] NA --> B1[桶1 synchronized] NA --> BN[桶N synchronized] B0 --> L0[链表/红黑树] end J7[JDK7: 锁粒度=Segment
并发度=16] --> J8[JDK8: 锁粒度=桶头节点
并发度=桶数,随扩容增长] style S1 fill:#dbeafe,stroke:#2563eb style B0 fill:#fef3c7,stroke:#d97706
JDK8 为何放弃分段锁:① 分段锁的并发度固定(默认 16),无法随数据量增长;② Segment 嵌套 HashEntry 两层数组,内存与查询路径开销大;③ 红黑树需要更细粒度的锁(树化是单桶操作,分段锁粒度太粗)。JDK8 用"synchronized 单桶 + CAS 空桶插入",锁粒度细化到桶,并发度随桶数线性增长,且 synchronized 在 JDK6 优化后性能足够。
JDK8 ConcurrentHashMap 的核心机制:
- Node 数组 + 链表/红黑树:与 HashMap 结构一致,但 Node 的 val 和 next 是 volatile,保证读可见性。
- put 流程:① 计算 hash;② 若桶为空,CAS 插入(无锁);③ 若桶非空,synchronized 锁住桶头节点,链表/树尾插入;④ 链表长度 ≥8 且数组 ≥64 时树化。
- get 流程:全程无锁,volatile 读 Node.val + next,可见性由 volatile 保证。
- sizeCtl(volatile int):控制初始化与扩容。-1 表示正在初始化,-(N+1) 表示有 N 个线程参与扩容,正数表示阈值或下次扩容容量。
- 扩容(transfer):支持多线程并发迁移。每个线程认领一段桶(步长默认 16),迁移完成后推进 ForwardingNode(hash=MOVED),读请求遇到 ForwardingNode 转发到新表。
// JDK8 ConcurrentHashMap.putVal 简化逻辑
final V putVal(K key, V value, boolean onlyIfAbsent) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
int i, n; Node<K,V> f;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // CAS 初始化
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
break; // 空桶 CAS 插入(无锁)
} else if ((fh = f.hash) == MOVED) {
tab = helpTransfer(tab, f); // 协助扩容
} else {
synchronized (f) { // 锁单个桶头节点
if (tabAt(tab, i) == f) { // 双重检查
// 链表或红黑树插入...
}
}
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i); // 树化
break;
}
}
addCount(1L, binCount); // 用 CounterCell 累加 size
return null;
}
size() 的精度问题:ConcurrentHashMap 的 size 不是精确值,而是 baseCount + 所有 CounterCell 的和。因为高并发下对单一计数器 CAS 会成为瓶颈,JDK8 借鉴 LongAdder 用 CounterCell[] 分段计数(见 C03.10)。size() 返回的是"调用时刻的近似值",期间可能有并发修改。
使用要点:
- 禁止 null key/value:ConcurrentHashMap 不允许 null,因为无法区分"键不存在"和"值为 null"(HashMap 在单线程可用 containsKey 判断,并发下有竞态)。用
containsKey或getOrDefault替代。 - 原子复合操作用 compute/computeIfAbsent/merge:get 后 put 不是原子的,用
computeIfAbsent保证"不存在则计算"的原子性。 - size() 不精确:监控场景若需精确计数,用 LongAdder 单独维护。
不要在 mapping/compute 函数中阻塞或递归修改同一个 map:computeIfAbsent
的回调在原子复合操作内执行;修改同一个 map、回调中调用远程/数据库/锁等待,或递归触发同一键计算,都可能长时间占用内部同步、阻塞同桶操作,甚至形成死锁、活锁或抛出异常。具体失败形态与 JDK 实现和数据冲突路径有关,不能概括为“必然死锁”。应把回调限制为纯计算,复杂初始化、IO 和外部锁移到回调外,并用显式状态机或构建器管理。
追问 1:JDK8 为什么用 synchronized 而不是 ReentrantLock 锁桶?
① synchronized 在 JDK6 优化后无竞争场景开销极小(轻量级锁;偏向锁 JDK 15 已废弃 JEP 374),而 ReentrantLock 即使无竞争也有方法调用与 AQS 入队开销;② synchronized 是 JVM 内置,JIT 可做锁消除/粗化等深度优化;③ ReentrantLock 需要额外内存(AQS 队列),每个桶一个 ReentrantLock 内存开销大;④ synchronized 自动释放,不会因异常泄漏锁。综合权衡,单桶锁 synchronized 是更优选择。
追问 2:扩容时多线程如何协作?
扩容时通过 sizeCtl 的低 16 位记录参与扩容的线程数。每个线程通过
transferIndex 原子认领一段桶(stride,默认 16),从后往前迁移。迁移完的桶放置 ForwardingNode(hash=MOVED),put 遇到 ForwardingNode 会调用 helpTransfer 协助扩容,get 遇到则转发到新表。这种"边扩容边服务请求"的设计让扩容不阻塞读写。
追问 3:CounterCell 为什么用 @Contended 注解?
@Contended 让 JVM 在对象前后填充缓存行(默认 128 字节),避免多个 CounterCell 落在同一缓存行导致"伪共享"(false sharing)——否则一个 CPU 核修改 Cell0 会让其他核的 Cell1 缓存行失效,CAS 性能急剧下降。这是高并发计数器性能优化的关键细节,与 LongAdder 的 Cell 设计一致。
- 误以为 size() 精确:高并发下 size 是近似值,不能用于精确判断。
-
get 后 put 非原子:
if(!map.containsKey(k)) map.put(k,v)有竞态,用 computeIfAbsent。 - compute/computeIfAbsent 内部阻塞或递归改同一 map:可能死锁、活锁或抛异常;回调应保持短小且无副作用。
- 误以为迭代是快照:ConcurrentHashMap 的迭代器是弱一致的,反映创建时的状态,但迭代期间可能看到新数据。
这道题考察 Java 等待/通知机制的三层抽象:Object.wait/notify(内置 monitor)、Condition.await/signal(AQS 层)、LockSupport.park/unpark(底层原语)。理解三者的关系与各自的设计动机,是设计阻塞队列、生产消费模型、限流器等并发组件的基础。
Object.wait/notify(JDK 1.0):与 synchronized 绑定,每个对象有一个 WaitSet。局限:① 必须在 synchronized 块内调用;② 只有一个等待队列,无法分类唤醒;③ notify 随机唤醒,无法指定唤醒哪类等待者;④ wait 无法做到"先 notify 后 wait 不阻塞"。
LockSupport.park/unpark(JDK 5,2004):Doug Lea 引入的线程级阻塞原语,每个线程一个 permit(许可)。unpark 先于 park 调用时,park 不会阻塞(permit 已被"存起来")。这是对 wait/notify 时序缺陷的根本修复。
Condition.await/signal(JDK 5):基于 AQS 实现,一个 Lock 可创建多个 Condition,每个 Condition 有独立等待队列,可精准唤醒某类等待者。是阻塞队列、生产消费模型的标准实现工具。
未来趋势:Virtual Threads 时代,park 是虚拟线程卸载的触发点(unmount)。JDK 21-23 环境下,synchronized 持有期间虚拟线程会 pin 在载体线程上无法卸载,此时推荐 ReentrantLock + Condition;JDK 24+(JEP 491) 已消除该限制,synchronized 与 ReentrantLock 在虚拟线程上均可正常卸载,选型回归“语义需要”而非 pinning 规避。
三层关系:Condition.await 会借助 LockSupport.park 阻塞,HotSpot Object.wait 也进入 ObjectMonitor 等待机制。LockSupport 是常用的线程阻塞原语,但不同等待机制的持锁、队列和唤醒契约不同,不能混成一个模型。
LockSupport.park/unpark 的 permit 机制:每个线程内部有一个 permit(许可),初始为 0。unpark(thread) 将 permit 置 1(若已为 1 则不变,不可累加,最多为 1)。park() 检查 permit:若为 1 则消费并立即返回(不阻塞),若为 0 则阻塞直到 permit 变 1 或被中断或超时。这种设计让 unpark "先于" park 调用时不会丢失信号。
Condition 的实现原理:ConditionObject 维护一个独立的等待队列(单向链表)。await() 时:① 释放锁(fullyRelease);② 加入 Condition 等待队列并 park;③ 被 signal 唤醒后,从 Condition 队列移到 AQS 同步队列,重新竞争锁。signal() 时:将 Condition 队列首节点转移到 AQS 同步队列,让其有机会被前驱唤醒后重新拿锁。Condition 不能与所有锁混用;StampedLock 没有 newCondition(),其 asReadLock()/asWriteLock()/asReadWriteLock() 只是兼容视图。
// 经典:ReentrantLock + 两个 Condition 实现有界阻塞队列
public class BoundedBuffer<T> {
private final Object[] items;
private int putIdx, takeIdx, count;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public BoundedBuffer(int capacity) { items = new Object[capacity]; }
public void put(T x) throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == items.length)
notFull.await(); // 满了, 在 notFull 上等待
items[putIdx] = x;
if (++putIdx == items.length) putIdx = 0;
count++;
notEmpty.signal(); // 唤醒一个在 notEmpty 上等待的消费者
} finally { lock.unlock(); }
}
public T take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await(); // 空了, 在 notEmpty 上等待
Object x = items[takeIdx];
items[takeIdx] = null;
if (++takeIdx == items.length) takeIdx = 0;
count--;
notFull.signal(); // 唤醒一个在 notFull 上等待的生产者
return (T) x;
} finally { lock.unlock(); }
}
}
park/unpark vs wait/notify 的关键差异:
| 维度 | Object.wait/notify | LockSupport.park/unpark |
|---|---|---|
| 是否需要持锁 | 必须在 synchronized 内 | 不需要 |
| 时序性 | notify 先于 wait 会丢信号 | unpark 先于 park 不丢信号(permit 累积) |
| 精准唤醒 | notify 随机唤醒 | unpark(thread) 指定线程 |
| 阻塞范围 | 对象级 WaitSet | 线程级 permit |
| 许可累加 | 多次 notify 唤醒多个等待者 | 多次 unpark 只算 1 个 permit |
架构师 · 何时用 Condition vs LockSupport:
- Condition:需要"持锁 + 条件等待"的场景(阻塞队列、生产消费、限流),是最常用的等待/通知抽象。
- LockSupport:实现同步器框架(AQS 内部用 park/unpark 唤醒后继节点)、需要"无锁阻塞指定线程"的场景(如锁的 tryLock 超时等待)。
实际项目中 99% 用 Condition 或更高层的 BlockingQueue,几乎不直接用 LockSupport——它是构建同步器的零件,不是业务 API。
追问 1:为什么 await 必须用 while 循环检查条件,不能用 if?
因为存在虚假唤醒(spurious wakeup)——线程可能在没有 signal 的情况下从 park 返回(OS 层的允许行为)。此外,被 signal 唤醒到重新拿到锁之间,条件可能已被其他线程改变。用 while 在拿到锁后重新检查条件,确保条件真正满足才继续。用 if 会导致条件不满足时错误地继续执行。
追问 2:unpark 的 permit 为什么不可累加?
设计上 permit 是 boolean 语义(0 或 1),不是计数器。这是为了简化语义——park 的目的是"阻塞至有许可",而非"阻塞至收到 N 个许可"。多次 unpark 同一线程只有第一次生效,后续的"丢失"是有意的,调用方需自行用其他机制(如队列)保证不丢业务信号。
追问 3:Virtual Threads 时代 Condition 还适用吗?
适用。JDK 21-23 环境下,synchronized 持有期间虚拟线程会 pin 在载体线程上无法卸载,而 ReentrantLock 在虚拟线程持有时不 pinning,Condition.await 会让虚拟线程优雅 unmount,此时推荐 ReentrantLock + Condition 替代 synchronized + wait/notify;JDK 24+(JEP 491) 已消除 synchronized 的 pinning 限制,synchronized + wait/notify 与 ReentrantLock + Condition 在虚拟线程上均可正常卸载,选型回归“语义需要”而非 pinning 规避。
- await 用 if 而非 while:虚假唤醒或条件竞态会导致逻辑错误,必须 while。
- signal 前忘记持锁:signal 必须在持锁状态下调用,否则可能唤醒了但节点未入同步队列。
- 误以为多次 unpark 可唤醒多次 park:permit 不可累加,多次 unpark 后 park 仍只阻塞一次后返回。
- 误以为 park 抛 InterruptedException:park 响应中断时仅返回,不抛异常,需自行检查
Thread.interrupted()状态,否则忽略中断继续执行。
这道题考察 JUC 并发集合的演进脉络与选型决策。从早期的 Collections.synchronizedXxx(全表锁)到 CopyOnWrite、ConcurrentLinked、BlockingQueue 家族,再到 ConcurrentSkipListMap,每种集合都针对特定并发场景做了优化。理解它们的实现机制才能在"读多写少/写多读少/阻塞消费/排序"等场景正确选型。
第一代:Collections.synchronizedXxx(JDK 1.2):装饰器模式包装普通集合,每个方法 synchronized 全表锁。并发度 1,性能差,迭代需手动加锁。
第二代:ConcurrentHashMap / CopyOnWriteArrayList(JDK 5):Doug Lea 的 util.concurrent 进入 JDK。ConcurrentHashMap 用分段锁,CopyOnWriteArrayList 用写时复制。Vector/Hashtable 正式退场。
第三代:ConcurrentLinkedQueue / ConcurrentSkipListMap(JDK 6~7):无锁并发队列(Michael-Scott 算法)、并发跳表(替代红黑树的并发实现)。
第四代:ConcurrentHashMap 红黑树化(JDK 8):链表过长时树化,最坏复杂度从 O(n) 降到 O(log n)。
并发集合全景对比:
| 集合 | 实现机制 | 读 | 写 | 典型场景 |
|---|---|---|---|---|
| ConcurrentHashMap | CAS + synchronized 单桶 + 红黑树 | 无锁(volatile) | 空桶 CAS / 非空桶 synchronized | 通用并发 Map,缓存/计数 |
| CopyOnWriteArrayList | 写时复制(ReentrantLock + 数组复制) | 无锁(volatile 数组) | 全量复制,O(n) | 读多写少:监听器列表、配置 |
| ConcurrentLinkedQueue | 无锁 CAS(Michael-Scott) | 无锁 | 无锁 CAS | 无界非阻塞队列,高吞吐 |
| ArrayBlockingQueue | ReentrantLock + 2 Condition | 持锁 | 持锁 | 有界阻塞队列,生产消费 |
| LinkedBlockingQueue | 两把 ReentrantLock(头尾分离) | takeLock | putLock | 高吞吐有界阻塞队列 |
| SynchronousQueue | 无容量,直接传递 | 等待生产者 | 等待消费者 | 线程池直接交接(Executors.newCachedThreadPool) |
| PriorityBlockingQueue | 堆 + ReentrantLock | 持锁 | 持锁 | 优先级任务队列 |
| DelayQueue | PriorityQueue + 延迟到期 | 持锁 + 条件等待 | 持锁 | 延时任务、定时调度 |
| ConcurrentSkipListMap | 跳表 + CAS | 无锁 | CAS 无锁 | 并发有序 Map,替代 TreeMap |
CopyOnWriteArrayList 的写时复制:写操作(add/set/remove)先获 ReentrantLock,复制整个底层数组,修改副本,再用 volatile 写替换原数组引用。读操作完全无锁(volatile 读数组引用)。代价是写 O(n) 且占用双倍内存,优势是读极快且迭代无锁(弱一致快照)。适合"监听器列表、配置表"等读远多于写的场景。
ConcurrentSkipListMap 为何用跳表而非红黑树:红黑树的插入/删除涉及旋转与颜色翻转,需要锁住多个节点才能原子完成,并发控制极复杂。跳表是链表 + 多层索引,插入/删除只需 CAS 修改局部指针,天然适合无锁并发。Doug Lea 据此选择了跳表作为并发有序 Map 的底层结构。
选型决策:
- Map:默认 ConcurrentHashMap。需要排序用 ConcurrentSkipListMap。
- List:读多写少用 CopyOnWriteArrayList;写多用 Collections.synchronizedList 或自己加锁。
- 队列:生产消费有界用 ArrayBlockingQueue / LinkedBlockingQueue;无界高吞吐用 ConcurrentLinkedQueue;延时用 DelayQueue;优先级用 PriorityBlockingQueue。
线程池的队列选择直接决定行为:LinkedBlockingQueue(无界)会让最大线程数失效(Executors.newFixedThreadPool 的陷阱);SynchronousQueue(无容量)让任务直接交接(newCachedThreadPool);ArrayBlockingQueue(有界)需配合拒绝策略。生产环境务必显式用 ThreadPoolExecutor + 有界队列,禁用 Executors 工厂方法。
追问 1:CopyOnWriteArrayList 为何不适合写多场景?
每次写都全量复制数组,O(n) 开销且产生大量垃圾对象,频繁 GC。此外写时其他写线程串行(ReentrantLock),吞吐低。写多场景应改用 Collections.synchronizedList 或 ConcurrentHashMap(以 index 为 key 时)。
追问 2:LinkedBlockingQueue 为什么比 ArrayBlockingQueue 吞吐高?
LinkedBlockingQueue 用两把锁(putLock + takeLock),生产者和消费者可真正并行;ArrayBlockingQueue 只有一把锁,put 和 take 互斥。但 LinkedBlockingQueue 每次入队创建 Node 对象,GC 压力更大。无界 LinkedBlockingQueue 还有 OOM 风险,必须设容量。
追问 3:ConcurrentLinkedQueue 与 LinkedBlockingQueue 的本质区别?
ConcurrentLinkedQueue 是无锁非阻塞(CAS),不会阻塞线程,满了也不等(无界);LinkedBlockingQueue 是阻塞(ReentrantLock + Condition),满了 put 会阻塞等待。前者适合"高吞吐、不关心背压"的场景,后者适合"生产消费需要背压控制"的场景。线程池必须用阻塞队列,否则任务丢失。
- CopyOnWriteArrayList 用在写多场景:复制开销 + GC 灾难。
- LinkedBlockingQueue 不设容量:默认 Integer.MAX_VALUE,OOM 风险。
- ConcurrentHashMap 当作线程安全的复合操作:get + put 非原子,需 computeIfAbsent。
- 误用 Vector/Hashtable:全表锁已淘汰,用 CopyOnWriteArrayList/ConcurrentHashMap 替代。
ForkJoinPool 是 Java 并行计算的重要内核。parallelStream 默认走 commonPool,CompletableFuture 在无自定义 executor 且 commonPool 并行度满足条件时也使用 commonPool;虚拟线程调度器则使用专用的 ForkJoinPool 载体池。这道题考察"外部提交任务、分治子任务、共享池"三个边界的区分,以及"分治任务模型 + 工作窃取(work-stealing)"核心;理解这些边界才能避免并行流中的阻塞任务影响全局。
ThreadPoolExecutor(JDK 5):传统线程池,所有线程共享一个工作队列,消费者线程从队列头部取任务。问题:高并发下队列成为竞争热点,且任务间无法"自底向上"地分解。
ForkJoinPool(JDK 7,2011):Doug Lea 设计,每个线程有独立的双端队列(Deque),自己产生的子任务入自己队列的头部(LIFO),空闲时从其他线程队列尾部窃取(FIFO)。工作窃取大幅减少队列竞争。
parallelStream(JDK 8,2014):基于 ForkJoinPool.commonPool 实现并行流,默认并行度 = CPU 核数 - 1。让普通开发者一行 .parallel() 就能用上分治并行。
Virtual Threads(JDK 21):虚拟线程的调度器内部仍用 ForkJoinPool 作为载体线程池,工作窃取保证载体线程的负载均衡。ForkJoinPool 从"并行计算"扩展为"轻量级线程调度基础设施"。
分治任务模型:ForkJoinTask 有两个核心子类——RecursiveTask(有返回值)和 RecursiveAction(无返回值)。任务通过 fork() 拆分子任务异步执行,join() 等待结果。典型用法:递归把大任务拆成两个小任务,直到小于阈值直接计算。
工作窃取的核心机制:
- 每个 Worker 一个 Deque:自己 fork 的子任务压入自己 Deque 的头部,处理时也从头部取(LIFO)——利用局部性,刚产生的子任务数据还在缓存中。
- 窃取从尾部:空闲 Worker 从其他 Worker 的 Deque 尾部窃取(FIFO)——尾部是"老"任务,窃取它不会与 Worker 自己的头部处理冲突,减少竞争。
- 减少竞争:传统线程池所有线程争抢同一队列,ForkJoinPool 每个线程主要操作自己的队列,只有窃取时才访问他人队列,竞争大幅降低。
// 经典:用 ForkJoin 计算 1~n 的和
class SumTask extends RecursiveTask<Long> {
private final long start, end;
private static final long THRESHOLD = 10_000;
SumTask(long start, long end) { this.start = start; this.end = end; }
@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
long sum = 0;
for (long i = start; i <= end; i++) sum += i;
return sum; // 小于阈值直接算
}
long mid = (start + end) >>> 1;
SumTask left = new SumTask(start, mid);
SumTask right = new SumTask(mid + 1, end);
left.fork(); // 异步执行左半
long rightResult = right.compute(); // 当前线程直接算右半
long leftResult = left.join(); // 等待左半结果
return leftResult + rightResult;
}
}
// 使用
ForkJoinPool pool = new ForkJoinPool();
long result = pool.invoke(new SumTask(1, 100_000_000L));
commonPool 的并行度:parallelStream 默认使用 ForkJoinPool.commonPool()。常见 JDK 的默认并行度按
Runtime.availableProcessors() - 1
计算,但容器配额、可用处理器语义和目标 JDK 版本都会影响结果;单核场景下任务通常由调用线程执行。可用系统属性
java.util.concurrent.ForkJoinPool.common.parallelism=N
调整,并打印或监控实际池状态验证。
ForkJoinPool vs ThreadPoolExecutor 的差异:
| 维度 | ThreadPoolExecutor | ForkJoinPool |
|---|---|---|
| 任务队列 | 所有线程共享一个 | 每个线程一个 Deque |
| 任务类型 | 独立无依赖任务 | 可分解的递归任务 |
| 窃取 | 无 | 有(空闲线程窃取他人队列尾) |
| 适合场景 | Web 请求、IO 任务 | CPU 密集分治、并行流 |
不要把阻塞 IO、锁等待或长任务交给 parallelStream 默认池:commonPool 是进程内共享池,阻塞会降低其并行度,可能连带影响其他并行流和无自定义 executor 的 CompletableFuture;使用专用 executor 的任务不一定受影响,调用线程也可能参与执行。IO 和阻塞任务应放独立有界池,或在 ForkJoin 任务中按目标 JDK 规则使用 ManagedBlocker;CPU 分治才优先交给 commonPool 或专用 ForkJoinPool。
追问 1:为什么窃取从尾部而非头部?
Worker 处理自己的任务从头部取(LIFO),若窃取也从头部,会与 Worker 自己的取操作竞争同一端。从尾部窃取(FIFO),窃取者与被窃取者操作 Deque 的不同端,竞争最小。且尾部是"最早入队的老任务",通常是较大的子任务,窃取它能更平衡负载。
追问 2:ForkJoinTask 的异常传播边界是什么?
RecursiveTask.compute()
不声明检查异常,所以检查异常要在任务内捕获并按业务约定转换。join()/getException() 走未检查语义,失败通常表现为 CompletionException(取消对应 CancellationException);get()/get(timeout) 是 Future 风格,声明检查异常并把任务失败包装为 ExecutionException。不要把两者的调用点混在一起照抄。
追问 3:Virtual Threads 如何使用 ForkJoinPool?
虚拟线程的调度器(VirtualThread 调度器)将虚拟线程"挂载"到载体线程(carrier thread)上执行,载体线程池就是一个 ForkJoinPool(默认并行度 = 核数)。虚拟线程在 park/unmount 时从载体线程卸载,载体线程可执行其他虚拟线程——这与 ForkJoinTask 的 fork/join 调度共用工作窃取机制。所以 ForkJoinPool 从"并行计算池"演进为"虚拟线程载体池"。
- parallelStream 中阻塞 IO:耗尽共享 commonPool 并行度,影响其他并行流和无自定义 executor 的异步任务。
- 任务拆分阈值太小:fork/join 自身有调度开销,阈值太小反而比串行慢。
- 误以为 parallelStream 一定更快:小数据集、IO 密集、有共享状态时并行更慢。
- 在 ForkJoinTask 中用 synchronized 阻塞:会阻止该 Worker 窃取其他任务,降低并行度。
这道题是 Java 并发计数器的性能演进史浓缩——从 synchronized 到 AtomicInteger(CAS)再到 LongAdder(分段 CAS),每一步演进都对应一个并发量级的突破。理解这条演进线,就理解了"如何在高并发下用最小开销完成计数",也理解了 ConcurrentHashMap 的 CounterCell、ThreadLocalRandom 的 Probe 等相似设计。
第一代:synchronized + long(JDK 1.0):最朴素的计数器,每次 ++ 都加锁。低并发可用,高并发下线程串行化,吞吐急剧下降。
第二代:AtomicLong / AtomicInteger(JDK 5,CAS):用 volatile + CAS 自旋替代锁,无竞争时几乎零开销。但在高并发(32+ 线程)下,所有线程 CAS 同一个变量,缓存行乒乓导致性能不升反降。
第三代:LongAdder(JDK 8,分段 CAS):Doug Lea 设计,借鉴 Striped64 思想,用 Cell[] 数组分散热点。高并发下不同线程 CAS 不同的 Cell,最后 sum 累加。在 64 核高并发下吞吐比 AtomicLong 高一个数量级。
演进动机的本质:从"单点 CAS"到"分散 CAS 热点",是并发量级从几十到几千的必然选择。这与其他系统的演进同构——ConcurrentHashMap 的 Segment 到单桶、数据库的行锁代替表锁,都是"细化锁/CAS 粒度以提升并发度"。
全串行
低并发可用"] --> B["AtomicLong
单点 CAS 自旋
中并发最优"] B --> C["LongAdder
Cell[] 分段 CAS
高并发最优"] style A fill:#fee2e2,stroke:#dc2626 style B fill:#fef3c7,stroke:#d97706 style C fill:#dbeafe,stroke:#2563eb
三种实现对比:
// 1. synchronized 计数器(重量级)
class SyncCounter {
private long count = 0;
public synchronized void increment() { count++; }
public synchronized long get() { return count; }
}
// 2. AtomicLong 计数器(单点 CAS)
class AtomicCounter {
private final AtomicLong count = new AtomicLong(0);
public void increment() { count.incrementAndGet(); }
public long get() { return count.get(); }
}
// 3. LongAdder 计数器(分段 CAS,高并发首选)
class LongAdderCounter {
private final LongAdder count = new LongAdder();
public void increment() { count.increment(); }
public long get() { return count.sum(); }
}
LongAdder 的分段 CAS 原理:内部维护一个 base(基础值)和 Cell[] 数组(每个 Cell 用 @Contended 避免伪共享)。increment 时:① 先尝试 CAS base,成功则返回;② 失败说明有竞争,根据线程的 probe(哈希值)定位一个 Cell,CAS 该 Cell 的 value;③ Cell 的 CAS 也失败,则重新 hash 探测其他 Cell。最终 sum() = base + 所有 Cell.value 之和。
volatile long"] C1["Cell1 @Contended
volatile long"] C2["Cell2 @Contended
volatile long"] CN["...CellN"] end T1[线程1 probe=h0] -->|CAS| C0 T2[线程2 probe=h1] -->|CAS| C1 T3[线程3 probe=h2] -->|CAS| C2 T4[线程4 无竞争] -->|CAS base| B SUM["sum() = base + Cell0 + Cell1 + ... + CellN"] style B fill:#fef3c7,stroke:#d97706 style C0 fill:#dbeafe,stroke:#2563eb style C1 fill:#dbeafe,stroke:#2563eb style C2 fill:#dbeafe,stroke:#2563eb
@Contended 解决伪共享:若 Cell0 和 Cell1 在同一缓存行(64 字节),CPU0 修改 Cell0 会让 CPU1 的 Cell1 缓存行失效(MESI invalidate),CAS Cell1 时需重新加载,性能崩塌。@Contended 让 JVM 在 Cell 前后填充 128 字节,确保每个 Cell 独占缓存行。需开启 -XX:-RestrictContended 对用户类生效(JDK 内部类默认启用)。
选型决策:
- 低并发(<10 线程):AtomicLong,简单且 sum 精确。
- 高并发(>32 线程)计数:LongAdder,吞吐最优。
- 需要精确值且高频读:AtomicLong,sum() 是强一致快照;LongAdder 的 sum 是弱一致近似值。
- 监控埋点(QPS/调用次数):LongAdder,高并发计数且容忍 sum 近似。
性能压测数据参考(64 核机器,64 线程并发 increment 1 亿次):synchronized ≈ 8s、AtomicLong ≈ 4s、LongAdder ≈ 0.6s。LongAdder 在高并发下优势达 6~10 倍。但 LongAdder 内存占用更高(Cell[] + 填充),且 sum() 不精确,需根据场景权衡。监控类计数器(QPS、调用次数)几乎都该用 LongAdder。
追问 1:LongAdder 的 sum() 为什么不精确?
sum() 遍历 Cell[] 累加时不加锁也不禁写,遍历过程中某个 Cell 可能被修改,导致 sum 反映的是"遍历期间某个时刻的快照",且不同 Cell 的读取时刻不同。在极高并发下 sum 甚至可能略小于真实值。若需精确值,必须用 AtomicLong 或加锁。
追问 2:LongAdder 何时会退化回 AtomicLong?
无竞争时,LongAdder 直接 CAS base(不分配 Cell),行为等同 AtomicLong。Cell 数组是惰性初始化的,只有首次竞争失败才创建。所以 LongAdder 在低并发下不会比 AtomicLong 差,是"自适应"的。
追问 3:还有比 LongAdder 更快的计数方案吗?
在线程绑定场景,ThreadLocal<Long> + 定期汇总比 LongAdder 更快(每个线程完全无竞争,连 CAS 都没有),代价是 sum 需遍历所有 ThreadLocal。Disruptor 的生产者序号在单生产者场景就是用 long 而非 AtomicLong(无竞争无需 CAS)。但通用计数器场景 LongAdder 已是工程最优解。
- 用 LongAdder 做精确判断:sum 不精确,不能用于"if (count == limit)"的精确阈值判断,用 AtomicLong。
- AtomicLong 高并发计数:32+ 线程下 CAS 乒乓严重,应换 LongAdder。
- 忘记 @Contended 需开启参数:用户自定义类用 @Contended 需加
-XX:-RestrictContended,否则注解不生效。 - 误以为 synchronized 最简单就最慢:低并发(<4 线程)下 synchronized 经 JIT 优化(锁消除/偏向)后与 AtomicLong 接近,不必过度优化。
Disruptor 是 LMAX 交易所开发的高性能内存消息组件,本质是一个基于环形数组(RingBuffer)+ 序列号(Sequence)协调的无锁/低锁消息传递框架。本题考察四个核心设计:① RingBuffer 如何用定长环形数组 + 位运算实现 O(1) 寻址;② Sequence 与 Sequence Barrier 如何在不加锁的前提下协调生产者-消费者依赖关系;③ 缓存行填充(Cache Line Padding) 如何消除伪共享(False Sharing);④ 单生产者无锁、多生产者 CAS 的并发模型。架构师需理解它"用数组预分配 + 序列号屏障替代锁"的设计哲学,以及在日志异步写入、事件分发场景中对比 BlockingQueue 的性能优势来源。
- LMAX 团队开发:LMAX 交易所为追求极低延迟交易系统,自研 Disruptor,2011 年开源。其设计动机源于 ArrayBlockingQueue 的性能痛点——锁竞争、对象分配、缓存行伪共享。
- ArrayBlockingQueue 痛点:① 用
ReentrantLock串行化 put/take,高并发下锁竞争严重;② 队列 head/tail/count 等相邻字段共享缓存行导致伪共享;③ 对象在堆上流动,引发 GC。 - RingBuffer + Sequence 设计:用定长环形数组预分配 Event 对象(零运行时分配),生产者/消费者各自维护 Sequence 序号,通过序列比较判断可读写位置,单生产者场景完全无锁。
- 单生产者-多消费者模型:Disruptor 支持菱形依赖(消费者间可串行/并行/菱形编排),通过 Sequence Barrier 统一协调,避免锁。
- JDK 8 引入 @Contended:Disruptor 早期手动填充 7 个 long 字段实现缓存行填充,JDK 8 起
@Contended注解(需-XX:-RestrictContended)提供了标准化方案。 - JDK 9 VarHandle 增强:提供了更细粒度的内存屏障操作(acquire/release/opaque),Disruptor 3.x 后续版本可基于 VarHandle 替代 Unsafe 实现松散读写。
- 与 Project Loom 虚拟线程对比:虚拟线程解决"阻塞线程太多"问题,适合 IO 密集;Disruptor 解决"线程间消息传递太慢"问题,适合 CPU 密集的低延迟流水线。两者正交,可组合使用(虚拟线程消费 Disruptor 事件)。
1. RingBuffer(环形数组):固定大小的数组(大小必须为 2 的幂,2^n),预分配所有 Event 槽位。生产者写
sequence & (size - 1)
处的槽位(位运算代替取模,O(1)),消费者读对应槽位。关键:数组预分配后 Event 对象常驻,生产者只更新字段不 new 对象,零运行时分配、零 GC 压力。
2. Sequence(序列号):Disruptor 的核心协调原语,本质是一个 volatile long 加缓存行填充。生产者/消费者各持一个 Sequence,单调递增。位置计算 index = sequence & (size - 1)。单生产者场景下生产者 Sequence 无竞争,直接用普通 long + StoreStore 屏障即可;多生产者场景用 CAS 争用槽位。
3. 缓存行填充(Cache Line Padding)消除伪共享:CPU 缓存行通常 64 字节,若两个独立变量(如生产者 cursor 与消费者 sequence)落在同一缓存行,一个线程修改变量 A 会使另一线程的变量 B 缓存失效,导致伪共享(False Sharing)。Disruptor 在 Sequence 前后填充 7 个 long(共 56 字节 + 8 字节 value = 64 字节),让 value 独占缓存行。JDK 8+ 可用 @Contended 替代手工填充。
4. 单生产者 vs 多生产者:
- 单生产者(SingleProducerSequencer):无任何 CAS,生产者独占写 cursor,仅用
volatile+ putOrderedLong(松散写)发布,吞吐最高。 -
多生产者(MultiProducerSequencer):每个生产者用 CAS 抢占 sequence 槽位,写入数据后再用
setAvailable标记该槽位可用(基于可用标志位数组),消费者只读已标记可用的槽位,保证可见性。
5. Sequence Barrier(序列屏障)协调消费者依赖:每个消费者通过 Sequence Barrier 感知其上游(依赖的消费者 + 生产者)的进度。消费者只能消费
min(上游 sequence)
之前的数据,从而实现菱形依赖无锁编排——例如"消费者 A → 消费者 B 串行"、"A/B 并行 → C 汇聚",全部通过 Sequence 比较完成,无锁。
6. BatchEventProcessor 批处理与 WaitStrategy:DSL 创建的消费者通常由 BatchEventProcessor 驱动,一次处理从 sequence+1 到可用上限的所有事件(批量)。WaitStrategy
决定消费者等待新事件时的策略:
- BlockingWaitStrategy:基于 Lock + Condition,CPU 友好,唤醒延迟和吞吐由负载与平台决定。
- SleepingWaitStrategy:自旋 → Thread.yield → LockSupport.parkNanos 渐进,平衡 CPU 与延迟。
- YieldingWaitStrategy:自旋 + Thread.yield,低延迟但吃 CPU。
- BusySpinWaitStrategy:纯自旋,接近最低等待延迟但持续占满 CPU,只在隔离核和强低延迟目标下评估。
// Disruptor 3.x DSL 基本用法:单生产者 + A/B 并行,随后 C 汇聚
import com.lmax.disruptor.*;
import com.lmax.disruptor.dsl.*;
import java.util.concurrent.ThreadFactory;
class LogEvent {
long sequence;
String message;
long timestamp;
}
ThreadFactory threadFactory = r -> {
Thread t = new Thread(r, "disruptor-consumer");
t.setDaemon(true);
return t;
};
// RingBuffer size 必须是 2 的幂;启动时通过 EventFactory 预分配槽位。
Disruptor<LogEvent> disruptor = new Disruptor<>(
LogEvent::new, 1024, threadFactory,
ProducerType.SINGLE, new BlockingWaitStrategy());
EventHandler<LogEvent> a = (event, seq, endOfBatch) -> {/* 校验 */};
EventHandler<LogEvent> b = (event, seq, endOfBatch) -> {/* 风控 */};
EventHandler<LogEvent> c = (event, seq, endOfBatch) -> {/* 汇聚落库 */};
// 先配置消费者,再启动;生产推荐 DSL 的 publishEvent,避免手动管理 claim/publish。
disruptor.handleEventsWith(a, b).then(c);
RingBuffer<LogEvent> ringBuffer = disruptor.start();
EventTranslatorOneArg<LogEvent, String> translator =
(event, seq, msg) -> {
event.sequence = seq;
event.message = msg;
event.timestamp = System.nanoTime();
};
ringBuffer.publishEvent(translator, "log message");
// 下面是底层 RingBuffer 手动发布模板;不要调用不存在的 Disruptor.next/get/publish。
long seq = ringBuffer.next();
try {
LogEvent event = ringBuffer.get(seq);
event.message = "manual message";
} finally {
ringBuffer.publish(seq); // claim 后必须发布,否则会阻塞后续 claim。
}
// 优雅停机。
disruptor.shutdown();
高级开发层:
-
Disruptor DSL API 使用:通过
Disruptor构造器指定 EventFactory、RingBuffer 大小(2^n)、ProducerType(SINGLE/MULTI)、WaitStrategy,用handleEventsWith/then编排消费者依赖。 - EventHandler 实现:实现
EventHandler<T>的onEvent,注意事件对象是复用的,处理完不要持有引用(下次会被生产者覆盖)。 - 生产者-消费者模式:优先用 DSL 的
publishEvent;若直接操作RingBuffer,按next()申请序号 →get(seq)填充 →publish(seq)发布,并用 finally 保证发布。 - WaitStrategy 选型:低延迟用 BusySpin/Yielding(绑核),通用用 Sleeping,省 CPU 用 Blocking。
架构师层:
- 日志异步写入:Log4j2 AsyncLogger 底层使用 Disruptor;收益要以目标日志框架版本、队列配置、磁盘和日志速率压测为准。
- 事件分发/限流场景选型:网关/交易系统的事件流水线可用 Disruptor 编排"接收 → 校验 → 风控 → 落库"的依赖;但先确认事件体积、背压、失败恢复和运维成本。
- vs BlockingQueue 性能对比:Disruptor 的优势来源是:① 单生产者无锁、多生产者低 CAS;② RingBuffer 预分配事件槽位,避免热路径事件载体分配;③ 缓存行填充降低伪共享;④ 批量消费减少协调开销。实际吞吐和延迟必须同版本压测。
- GC 是“降低”不是“清零”:事件对象可复用,但业务 payload、日志编码、临时集合和跨环传递仍可能分配。
追问 1:Disruptor 为什么比 BlockingQueue 快?
四个维度的叠加优势:① 无锁/低锁——单生产者无 CAS,多生产者只 CAS 抢序号,而 ArrayBlockingQueue 的 put/take 用同一把 ReentrantLock 串行化;② 分配更少——RingBuffer 预分配复用事件载体,热路径只更新字段,业务 payload 仍需自管;③ 降低伪共享——Disruptor 的 Sequence 用缓存行填充隔离,BlockingQueue 的相邻状态字段可能互相干扰;④ 批量消费——消费者一次处理到可用上限的所有事件,减少协调次数。优势大小由事件形状、消费者速度和生产者并发决定。
追问 2:多生产者场景如何保证消息有序?
多生产者下整体有序无法保证(多个生产者并发抢序号,谁先抢到谁先写),但能保证单条消息的生产-消费顺序与按 sequence 单调递增的消费可见性。机制:多生产者用 CAS 抢占 sequence 后写入数据,但消费者只读 setAvailable 标记过的槽位。若 sequence=5 的槽位先写完但 sequence=4 未标记 available,消费者会停在 4 不前进,避免读到未完成的数据。若业务要求全局有序,必须单生产者(上游加锁/单线程分发),Disruptor 的多生产者模式本身不保证生产顺序。
追问 3:Disruptor 在日志框架(Log4j2 AsyncLogger)中的应用?
Log4j2 的 AsyncLogger 用 Disruptor 作为底层队列,应用线程将日志事件投入 RingBuffer 后通常很快返回,后台线程消费并写磁盘/Appender;配置
AsyncLoggerContextSelector 可启用。要按目标 Log4j2 版本核对队列大小、丢弃策略、溢出行为和磁盘反压。不要把固定线程数下的固定倍数收益当成通用结论。
- 误以为 Disruptor 完全无锁:单生产者模式确实无锁无 CAS,但多生产者模式仍需 CAS 争用序号。不要想当然认为所有场景都"零争用"。
- 忽略 RingBuffer 大小必须是 2^n:Disruptor 强制要求 size 为 2 的幂(位运算
seq & (size-1)寻址)。传入非 2^n 会抛异常,且大小需根据峰值积压预估,过小导致生产者被消费者拖慢阻塞。 - 混淆 BatchEventProcessor 和 WorkProcessor:BatchEventProcessor 是"每个消费者独立 Sequence,可并行处理同一批事件"(发布订阅);WorkProcessor 是"多个 Worker 共享一个 Sequence 池,每个事件只被一个 Worker 处理"(工作分发)。选错会导致事件被重复处理或漏处理。
-
误用 WaitStrategy 导致 CPU 空转:
BusySpinWaitStrategy在没有事件时持续占满 CPU,非隔离核场景会挤占业务;通用场景优先评估SleepingWaitStrategy或BlockingWaitStrategy。 - 持有 Event 引用:Event 对象是复用的,onEvent 处理后不能将其存入集合或异步引用,否则会被生产者下次覆盖,导致数据错乱。
这道题考察对并发底层载体的系统级认识:线程并非凭空存在,而是由 OS 内核调度的执行单元。能否说清 进程与线程的区别、内核态/用户态切换、上下文切换的开销构成,直接决定你对"线程池该开多大、协程/虚拟线程为何省资源、为什么并发量大要降低线程数"的判断力。它把 Java 并发 API 与真实硬件/内核行为连在了一起。
进程(Process):资源分配与调度的基本单位,拥有独立地址空间、文件描述符等,隔离强但创建/切换成本高(需换页表/快表)。
线程(Thread):进程内的执行流,共享地址空间与资源,只保留独立栈与寄存器上下文,创建和切换比进程轻量,但共享意味着要自己加同步。
线程实现模型:① 用户级线程(M:1,调度在用户态,内核通常不单独感知);② 内核级线程(1:1,由内核调度);③ 混合模型(M:N)。不同 OS 和运行时的实现边界不同,不能只按“现代内核都是 1:1”概括。
Java 演进:主流 HotSpot 的平台线程长期映射到内核可调度线程;JDK 21 正式引入 虚拟线程(Virtual Threads,JEP 444),形成 M:N 用户态调度。平台线程受 OS 抢占调度;虚拟线程在阻塞、I/O 完成等待和显式 Thread.yield() 等调度点让出载体线程,CPU 循环不会因为虚拟线程调度器而自动时间片抢占。
上下文切换开销构成:保存/恢复 CPU 寄存器、换页表(进程级切换)、可能刷新或失效 TLB、切换内核栈和调度器记账。常见量级是微秒级,但具体时间随 CPU、内核、缓存状态和负载变化,应作为“高频切换会累积”的机制理解,不写成固定 1~2µs。
内核态/用户态切换:系统调用要从用户态陷入内核态;主流 JDK 创建平台线程涉及
clone
类内核机制。阻塞和调度还伴随睡眠/唤醒、锁队列和缓存局部性损失,不能只看寄存器保存。
CPU 密集 vs 线程数:纯计算型,最优线程数 ≈ CPU 核数;含 IO/阻塞时,利用"阻塞占比较高"提高并发度(公式:线程数 ≈ 核数 × (1 + 等待时间/计算时间)),本质是用线程并行掩盖 IO 等待。
// 直观观察线程/进程开销(Linux)
$ /usr/bin/time -v java -jar app.jar // 进程级资源与耗时
$ cat /proc/<pid>/status | grep Threads // 内核线程数(1:1 时 ≈ 用户线程数)
$ ulimit -u // 每进程线程/进程上限
$ perf stat -- taskset -c 0 ./bench // 观察上下文切换计数
主流 HotSpot 平台线程对应一个内核可调度线程;几十万长驻阻塞平台线程会放大内存和调度压力。虚拟线程把大量并发任务交给用户态调度,用少量载体线程承载,更适合 IO 等待型服务;是否首选要结合阻塞点、CPU 计算、池化资源和 JDK 版本验证。
线程池参数推断:IO 密集(等待/计算 大)→ 线程数可设为 核数 × (1 + 等待/计算);CPU 密集 → 约等于核数,避免过多线程造成上下文切换反噬。
避免线程饥饿与无界线程:高并发异步场景改用 CompletableFuture + 有界线程池;阻塞型中间件(如配合阻塞 IO 的日志/DB)要 setDaemon=false 且限制数量。
虚拟线程落地:JDK 21+ 的 IO 等待密集服务可用 Executors.newVirtualThreadPerTaskExecutor() 承接任务;CPU 密集段仍会占住载体线程,必要时显式让出或改用专用池。JDK 21-23 中长时间持有 synchronized 并在其中阻塞可能造成载体线程 pinning;JDK 24 的 JEP 491 消除了这一主要 pinning 来源,但 native frame、驱动和 JDK 实现仍要按版本核验。
定位线程问题:jstack 看 RUNNABLE/BLOCKED 分布、top -H 看每线程 CPU、vmstat 看 cs(context switch)曲线,衡量是否因线程过多而上下文切换偏高。
追问 1:上下文切换到底贵在哪? 不止"保存寄存器"——还有 TLB 刷新(进程切换)、内核栈切换、调度器锁竞争,以及切走后缓存/TLB 局部性丢失,故高频切换的代价远高于"微秒级"字面值。
追问 2:线程 vs 协程/虚拟线程的关键差异? 平台线程由 OS 调度,在主流系统中可被抢占;虚拟线程由 JVM 用户态调度器承载,在阻塞和显式让出点切换。它不是“更快的内核线程”,CPU 密集任务也不会仅因为虚拟线程更多而获得自动抢占。
追问 3:虚拟线程和 synchronized 的关系要按什么版本说? JDK 21-23 中,虚拟线程在 synchronized 中阻塞可能 pin 载体线程,可用 ReentrantLock 或缩短临界区规避;JDK 24 的 JEP 491 让 synchronized 在该主要场景不再 pin。存量服务应先看 JDK 版本和 JFR jdk.VirtualThreadPinned,不写绝对结论。
- "线程越多越快":超过负载后上下文切换开销大于并行收益,吞吐反而下降。
- "进程与线程切换成本一样":进程切换还需换地址空间与刷 TLB,通常更昂贵。
- 把虚拟线程当万能药:CPU 密集任务不会自动获得更多并行度;旧 JDK 中 synchronized/native 阻塞还可能 pin 载体线程。
- 忽略创建即系统调用:高频创建短命线程会频繁 clone/syscall,应用线程池或虚拟线程。
- 混淆平台线程与协程调度:虚拟线程并非"更快的内核线程",而是用户态轻量线程。