Java 并发面试二
# Java 并发面试二
# Java 锁
# 【中等】Java 中,根据不同维度划分,锁有哪些分类?⭐⭐⭐
在 Java 中,锁可以按照 多个维度 进行分类,不同维度的锁适用于不同的并发场景。以下是详细的分类:
按锁的公平性划分
| 锁类型 | 特点 | 实现类/关键字 |
|---|---|---|
| 公平锁 | 严格按照线程请求顺序(FIFO)分配锁,避免线程饥饿,但性能较低。 | ReentrantLock(true) |
| 非公平锁 | 允许插队,新请求的线程可能直接抢到锁,吞吐量高,但可能导致线程饥饿(默认方式)。 | ReentrantLock(false)、synchronized |
按锁的获取方式划分
| 锁类型 | 特点 | 实现类/关键字 |
|---|---|---|
| 悲观锁 | 认为并发冲突必然发生,先加锁再操作(阻塞其他线程)。 | synchronized、ReentrantLock |
| 乐观锁 | 认为并发冲突较少,不加锁,更新时检查(CAS 或版本号机制)。 | AtomicInteger、StampedLock |
按锁的可重入性划分
| 锁类型 | 特点 | 实现类/关键字 |
|---|---|---|
| 可重入锁 | 同一线程可多次获取同一把锁(避免死锁)。 | ReentrantLock、synchronized |
| 不可重入锁 | 同一线程重复获取同一把锁会导致死锁(Java 无原生实现,需自定义)。 | 无(需自行实现) |
按锁的共享性划分
| 锁类型 | 特点 | 实现类/关键字 |
|---|---|---|
| 独占锁(排他锁) | 同一时间只有一个线程能持有锁(如 synchronized、ReentrantLock)。 | synchronized、ReentrantLock |
| 共享锁 | 允许多个线程同时读取,但写入时独占(如 ReadWriteLock)。 | ReentrantReadWriteLock |
按锁的阻塞方式划分
| 锁类型 | 特点 | 实现类/关键字 |
|---|---|---|
| 阻塞锁 | 获取不到锁时,线程进入阻塞状态(如 synchronized)。 | synchronized、ReentrantLock |
| 自旋锁 | 获取不到锁时,线程循环尝试(避免线程切换,但消耗 CPU)。 | AtomicInteger(CAS 自旋) |
| 适应性自旋锁 | JVM 自动优化自旋次数(如 synchronized 在 JDK 6+ 的优化)。 | JVM 内部优化 |
按锁的优化策略划分
| 锁类型 | 特点 | 实现类/关键字 |
|---|---|---|
| 偏向锁 | 单线程访问时无同步开销(JDK 6+ 对 synchronized 的优化)。 | JVM 自动优化(synchronized) |
| 轻量级锁 | 多线程无竞争时,使用 CAS 代替阻塞(JDK 6+ 优化)。 | JVM 自动优化(synchronized) |
| 重量级锁 | 真正的互斥锁,涉及 OS 线程阻塞(如 synchronized 竞争激烈时)。 | JVM 自动升级(synchronized) |
按锁的实现方式划分
| 锁类型 | 特点 | 实现类/关键字 |
|---|---|---|
| 内置锁(JVM 锁) | 由 JVM 实现(如 synchronized)。 | synchronized |
| 显式锁 | 由 Java API 提供(如 ReentrantLock)。 | ReentrantLock、ReadWriteLock |
| 分布式锁 | 跨 JVM 的锁(如 Redis、ZooKeeper 实现)。 | Redisson、Curator |
总结
| 分类维度 | 锁类型 |
|---|---|
| 公平性 | 公平锁、非公平锁 |
| 获取方式 | 悲观锁、乐观锁 |
| 可重入性 | 可重入锁、不可重入锁 |
| 共享性 | 独占锁、共享锁 |
| 阻塞方式 | 阻塞锁、自旋锁、适应性自旋锁 |
| 优化策略 | 偏向锁、轻量级锁、重量级锁 |
| 实现方式 | 内置锁(synchronized)、显式锁(ReentrantLock)、分布式锁(Redisson) |
选择合适的锁取决于:
- 并发竞争程度(高竞争→悲观锁,低竞争→乐观锁)
- 任务执行时间(长任务→公平锁,短任务→非公平锁)
- 读写比例(读多→共享锁,写多→独占锁)
- 是否需要跨 JVM(是→分布式锁)
这些分类帮助开发者根据业务场景选择最优的锁策略,平衡 性能、公平性、一致性。
# 【中等】悲观锁和乐观锁有什么区别?⭐⭐⭐⭐
悲观锁假定会冲突,提前加锁阻塞;乐观锁假定不冲突,提交时检测版本,冲突则重试。
- 悲观锁:先加锁再操作,适合写多读少的高并发场景,保证安全但性能较低,如金融交易。
- 乐观锁:通过版本号或 CAS 机制实现,提交时检查数据是否被修改,适合读多写少的场景,如电商库存。
以下是悲观锁与乐观锁的详细对比:
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心思想 | 假定并发冲突必然发生,先加锁再访问数据 | 假定并发冲突较少,先操作再检测冲突 |
| 锁机制 | 显式加锁(阻塞其他线程) | 无锁机制(依赖 CAS 或版本号控制) |
| 实现方式 | synchronized、ReentrantLock、数据库SELECT FOR UPDATE | Atomic类(CAS)、版本号机制、数据库乐观锁(如 MVCC) |
| 线程阻塞 | 会阻塞竞争线程(线程挂起) | 不阻塞线程,但可能自旋重试或失败 |
| 数据一致性 | 强一致性(独占访问) | 最终一致性(可能需重试) |
| 适用场景 | - 写操作频繁 - 临界区代码执行时间长 - 强一致性要求高 | - 读多写少 - 短平快操作 - 高吞吐量需求 |
| 性能特点 | - 高竞争时性能下降明显(线程切换开销) - 低竞争时仍有固定锁开销 | - 低竞争时性能极佳(无阻塞) - 高竞争时 CPU 自旋浪费 |
| 冲突处理 | 通过锁排队避免冲突 | 通过重试或放弃处理冲突 |
| 典型应用 | - 银行转账 - 订单支付 - 数据库行级锁 | - 库存扣减 - 计数器 - 点赞系统 |
| 优缺点 | ✔️ 强一致性 ❌ 吞吐量低、死锁风险 | ✔️ 高并发性能好 ❌ 实现复杂、可能 ABA 问题 |
# 【中等】公平锁和非公平锁有什么区别?⭐⭐⭐
公平锁按请求顺序分配,非公平锁允许插队,可能让先到的线程等待。
- 公平锁:线程获取锁的顺序严格遵循请求的先后顺序,保证公平性但可能降低吞吐量。
- 非公平锁:允许后请求的线程“插队”抢先获取锁,虽可能造成饥饿但通常能提高系统整体性能。
公平锁和非公平锁的详细对比:
| 对比维度 | 公平锁 (Fair Lock) | 非公平锁 (Nonfair Lock) |
|---|---|---|
| 锁获取顺序 | 严格按照线程请求顺序(FIFO)分配锁 | 允许插队,新请求的线程可能直接抢到锁 |
| 性能表现 | 吞吐量较低(上下文切换频繁) | 吞吐量较高(减少线程切换,但可能线程饥饿) |
| 响应时间 | 等待时间稳定(适合长任务) | 短任务可能更快获取锁(适合高并发短任务) |
| 适用场景 | - 需要严格公平性 - 线程执行时间差异大(避免饥饿) | - 高并发短任务 - 追求吞吐量 |
| 锁实现类 | ReentrantLock(true) | ReentrantLock(false)(默认) |
| 实现 | 依赖 AQS 维护等待线程,先到先得 | 先尝试 CAS 抢锁,失败后进入 AQS 队列 |
| 线程饥饿 | 不会发生 | 可能发生(高并发时某些线程长期无法获取锁) |
| 操作系统调度影响 | 依赖系统线程调度,可能因优先级反转影响公平性 | 更依赖 JVM 的锁优化策略 |
| 锁重入性 | 支持(与公平性无关) | 支持(与公平性无关) |
| 适用并发模型 | 适合任务执行时间不均衡的场景 | 适合任务执行时间短的场景 |
如何选择?
选公平锁:
- 需要严格顺序执行(如订单处理)
- 避免低优先级线程饥饿
- 线程任务执行时间差异大
选非公平锁:
- 追求高吞吐量(如秒杀系统)
- 任务执行时间短且均匀
- 能接受偶尔的线程饥饿
注意事项:
- 默认行为:
ReentrantLock和synchronized默认都是非公平锁(因为性能更好)。 - 性能差异:非公平锁在高并发下吞吐量可提升 10%~30%,但可能增加延迟方差。
- synchronized 的公平性:Java 的
synchronized不支持公平锁,仅ReentrantLock可配置。
# 【困难】AQS 的实现原理是什么?⭐⭐⭐⭐⭐
AQS(AbstractQueuedSynchronizer)是 Java 并发包(java.util.concurrent.locks)的核心框架,用于构建锁(如 ReentrantLock)和同步器(如 CountDownLatch、Semaphore)。
AQS 用一个 volatile int 状态值 + 一个双向链表队列(CLH),通过 CAS 自旋实现线程的排队与唤醒,是 Java 并发锁的“骨架引擎”。
::: info AQS 要点
:::
AQS 原理可归纳为:2 种模式,3 大核心,4 步操作
2 种模式
- 独占模式(Exclusive):同一时刻只有一个线程能获取资源(如
ReentrantLock)。 - 共享模式(Shared):多个线程可同时获取资源(如
Semaphore,CountDownLatch)。
3 大核心
- 状态(State):一个
volatile整型变量,用于表示同步状态。state在不同的同步组件中意义不同。- 锁的意义:0 代表无锁,>0 代表有锁(可重入时累加)。例如在
ReentrantLock里,state为 0 表示锁未被持有,大于 0 表示锁已被持有,且重入次数就是state的值。 - 信号量/CountDownLatch 等意义:state 表示可用资源数或倒数计数。
- 锁的意义:0 代表无锁,>0 代表有锁(可重入时累加)。例如在
- 同步队列(CLH 变体):一个双链表,存放等待获取资源的线程。
Node包含以下重要属性:thread:指向等待获取同步状态的线程。prev和next:分别指向前一个节点和后一个节点,从而形成双向链表。waitStatus:表示节点的等待状态,常见的状态有:CANCELLED(1):表示该节点对应的线程已取消等待。SIGNAL(-1):表示该节点的后继节点需要被唤醒。CONDITION(-2):表示该节点处于条件队列中。PROPAGATE(-3):用于共享模式下,表明状态需要向后传播。
- 每个线程被封装为一个 Node 节点。
- 队列头节点(head)是当前持有资源的线程(独占模式)或已唤醒的节点。
- CAS 操作:所有对
state和队列头的修改,都通过Unsafe.compareAndSwap原子完成,保证线程安全。
4 步关键操作
可以把一个线程获取锁到释放锁的过程,想象成**“尝试加锁 → 排队等候 → 被唤醒 → 解锁”**的流程。
graph TD
A[线程请求资源] --> B{state == 0?}
B -->|是| C[CAS 修改 state]
C -->|成功| D[获取资源执行]
C -->|失败| E[加入 CLH 队列尾部]
B -->|否| E
E --> F[自旋检查前驱是否为 head]
F -->|是| G[再次 tryAcquire]
G -->|成功| H[设置自己为 head 执行]
G -->|失败| I[park 挂起等待唤醒]
F -->|否| I
D --> J[释放资源 state=0]
J --> K[unparkSuccessor 唤醒后继节点]
K --> F
源码锚点(资深面试加分项)
(1)head 是 dummy node(哨兵节点):CLH 队列的 head 不保存有效线程(node.thread == null),它代表「最近一次成功获取锁」的位置占位。这样设计让 acquireQueued 的自旋条件统一为「我的前驱是 head 就再试一次 tryAcquire」,无需对队首做特判。
(2)acquireQueued 的自旋条件:
final boolean acquireQueued(final Node node, int arg) {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 只有前驱是 head 才允许抢锁
setHead(node); // 自己成为新的 dummy head
p.next = null; // 帮助 GC
return false;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) { // 否则 park 挂起
...
}
}
}
关键设计:只有 head 的后继节点才允许 tryAcquire,其余节点直接 park。这把「队列中所有线程都在 CAS 抢锁」退化为「至多一个线程在抢」,将竞争收敛到队首,避免惊群效应。
(3)hasQueuedPredecessors 是公平锁的核心:FairSync.tryAcquire 在 CAS 前先调用它检查队列中是否有等待者,有才放弃抢锁去排队;NonfairSync 不检查直接 CAS。一个方法区分了公平与非公平。
(4)SIGNAL 状态的责任转移:线程 park 前会把前驱节点的 waitStatus 置为 SIGNAL(「我睡了,你释放锁时记得叫醒我」),把唤醒责任挂到前驱身上;CANCELLED(=1,如 tryLock 超时)节点在遍历中被跳过并清理。
::: info L4 深度:CLH 队列 vs MCS 队列的设计差异
:::
CLH 队列 vs MCS 队列:为什么 AQS 选择 CLH 变体?
CLH(Craig, Landin, and Hagersten)和 MCS(Mellor-Crummey and Scott)是两种经典的 FIFO 自旋锁队列算法,两者的核心差异决定了 AQS 的设计选择:
| 维度 | CLH 队列 | MCS 队列 | AQS 的 CLH 变体 |
|---|---|---|---|
| 自旋位置 | 在前驱节点上自旋(pred.locked) | 在本地节点上自旋(myNode.locked) | 不自旋,park() 挂起 |
| 链表方向 | 隐式单向链表(通过 tail + 前驱指针遍历) | 显式双向链表(prev + next) | 显式双向链表 |
| 本地 vs 远程 | 远程自旋:每个线程 watch 前驱节点状态 | 本地自旋:每个线程 watch 自身节点状态 | 本地阻塞:自身 park/unpark |
| 适配阻塞 | 原生不适合阻塞,若挂起则 successor 不知唤醒谁 | 天然适合:本地节点被前驱唤醒 | AQS 采用 MCS 思想 + CLH 队列结构 |
AQS 为什么用 CLH 变体而非纯 CLH/MCS?
CLH 的自旋问题:纯 CLH 每个线程在前驱节点上自旋(
while (pred.locked)),在 Java 层面,CPU 空转会浪费资源。AQS 将其改为park()挂起,由前驱释放锁时unpark后继——这个「唤醒链」本质是将 CLH 的自旋检测替换为 OS 级阻塞。CLH 的单向链表问题:纯 CLH 只有隐式单向链表(通过 tail CAS + 前驱引用),无法支持取消操作和条件队列。AQS 将其改造为显式双向链表(
prev+next),以便:取消节点时通过next快速找到后继、条件队列信号时能反向找到自身在同步队列的位置。MCS 的冗余开销:MCS 本身是双向链表 + 本地自旋,更接近 AQS 的最终形态。但 MCS 需要每个节点有
locked标志位,而 AQS 通过waitStatus中的SIGNAL状态实现了更丰富的语义(取消、条件、传播),因此 AQS 本质上是「CLH 的队列结构 + MCS 的阻塞思想 + AQS 的状态机」。
面试记忆点:AQS = CLH 的 FIFO 队列骨架 + MCS 的「前驱唤醒后继」阻塞机制 + 双向链表改造(取消/条件队列需求)。
::: info L4 深度:Linux futex 与 park/unpark 的关系
:::
LockSupport.park() / unpark() 与 Linux futex 的对应关系
Java 的 LockSupport.park() / unpark() 最终依赖操作系统的线程阻塞原语,在 Linux 上通过 futex(Fast Userspace muTEX)系统调用实现。
futex 的核心设计:futex 是 Linux 内核提供的混合同步原语——低竞争时在用户空间通过 CAS 完成(零系统调用),高竞争时才陷入内核阻塞线程。这正是 AQS 的「先 CAS 抢锁,抢不到再 park 入队」策略的 OS 层基础。
Java 层: LockSupport.park(this) / LockSupport.unpark(thread)
│
JVM 层: Unsafe_Park / Unsafe_Unpark (HotSpot)
│
OS 层: pthread_cond_wait / pthread_cond_signal (POSIX)
│ 或
futex(FUTEX_WAIT, ...) / futex(FUTEX_WAKE, ...) (Linux)
关键机制对比:
| 维度 | park()/unpark() | Linux futex | pthread mutex |
|---|---|---|---|
| 许可机制 | unpark() 先于 park() 调用时,permit 被记录,后续 park() 立即返回(类似 binary semaphore) | FUTEX_WAIT 严格在值相等时才等待(无许可累积) | 无许可累积,严格互斥 |
| 唤醒语义 | unpark() 针对特定线程(一对一) | FUTEX_WAKE 可唤醒 N 个等待者(一对多) | pthread_cond_signal 唤醒一个 |
| 超时支持 | parkNanos() / parkUntil() | FUTEX_WAIT 自带 timeout 参数 | pthread_cond_timedwait |
为什么 unpark() 可以先于 park() 调用?
这是一个面试高频细节:LockSupport 内部维护每个线程的 permit 标志(boolean)。unpark(t) 将 permit 设为 1(如果已是 1 则不变),park() 将 permit 设为 0 并返回(如果已是 0 则阻塞)。因此 unpark() → park() 的顺序不阻塞,而 park() → unpark() 的顺序正常唤醒。
这与 futex 的 FUTEX_WAIT 行为不同——FUTEX_WAIT 要求 *addr == val(原子比较),若值已被修改则不等待。AQS 的 SIGNAL → unpark 正是利用了这一语义:释放线程 CAS 修改 state 后再 unpark,被唤醒线程重新 tryAcquire 时能立刻感知状态变化。
面试记忆点:
park/unpark=permit机制(类似信号量)+ futex 内核阻塞;SIGNAL状态是 AQS 层对 futex 的适配,确保「先改 state 再唤醒」的可见性顺序。
::: info AQS 独占模式工作流程
:::
独占模式:同一时刻仅允许一个线程获取同步状态,例如 ReentrantLock。
- tryAcquire:线程尝试直接获取锁(CAS 修改 state)。
- 成功:拿到锁,设置自己为独占线程。
- 失败:进入第 2 步。
- addWaiter:将当前线程包装成 Node 节点,用 CAS 快速插入到同步队列尾部。
- acquireQueued:在队列中进入“自旋-检测-挂起”循环。
- 检查自己是不是第二个节点(即 head 的下一个),如果是则再次尝试获取锁(tryAcquire)。
- 如果获取失败,则根据前驱节点的状态,决定是否将自己挂起(
LockSupport.park)。
- release & unparkSuccessor:持有锁的线程释放锁时。
tryRelease:修改 state。- 唤醒(
LockSupport.unpark)队列中下一个等待的节点(后继节点),让它重新尝试获取锁。
::: info AQS 共享模式工作流程
:::
共享模式:同一时刻允许多个线程获取同步状态,例如 CountDownLatch 和 Semaphore。
- 获取锁:
- 线程调用
acquireShared(int)→tryAcquireShared(int)(子类实现)。 - 如果成功(返回
≥0),获取锁;否则进入队列等待。
- 线程调用
- 释放锁:
- 线程调用
releaseShared(int)→tryReleaseShared(int)(子类实现)。 - 如果成功,唤醒后续等待的线程(可能多个)。
- 线程调用
::: info AQS 关键方法
:::
AQS 的关键方法采用模板方法设计模式串联起来:
- 独占模式
tryAcquire(int arg):尝试以独占模式获取同步状态,此方法需由子类实现。acquire(int arg):以独占模式获取同步状态,若获取失败则将线程加入队列并阻塞。tryRelease(int arg):尝试以独占模式释放同步状态,需子类实现。release(int arg):以独占模式释放同步状态,若释放成功则唤醒队列中的后继节点。
- 共享模式
tryAcquireShared(int arg):尝试以共享模式获取同步状态,需子类实现。acquireShared(int arg):以共享模式获取同步状态,若获取失败则将线程加入队列并阻塞。tryReleaseShared(int arg):尝试以共享模式释放同步状态,需子类实现。releaseShared(int arg):以共享模式释放同步状态,若释放成功则唤醒队列中的后继节点。
# 【中等】synchronized 和 ReentrantLock 有什么区别?⭐⭐⭐⭐⭐
ReentrantLock更强大:支持公平锁、可中断、超时、多条件变量。ReentrantLock必须手动释放锁,否则会导致死锁!synchronized更简单:自动管理锁,适合基础同步需求。- 性能差异:JDK 6 后,synchronized 经过一系列优化,两者性能接近,但
ReentrantLock在高竞争场景仍略有优势。

:::info synchronized 和 ReentrantLock 详细对比
:::
以下是 synchronized 和 ReentrantLock 的详细对比表格,涵盖 锁机制、功能、性能、使用场景 等核心维度:
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 锁类型 | JVM 内置关键字(隐式锁) | JDK 提供的类(显式锁) |
| 加锁解锁方式 | 自动加锁/释放锁(进入同步代码块加锁,退出时释放) | 需手动调用 lock() 和 unlock()(必须配合 try-finally 使用) |
| 是否可重入 | 支持(同一线程可重复获取) | 支持(同一线程可重复获取) |
| 是否支持公平 | 仅支持非公平锁 | 可配置公平锁或非公平锁(构造函数传参 true/false) |
| 是否可中断 | 不支持中断 | 支持 lockInterruptibly(),可响应中断 |
| 是否支持超时 | 不支持超时 | 支持 tryLock(timeout, unit),可设置超时时间 |
| 是否支持多条件 | 通过 wait()/notify() 实现,单一等待队列 | 支持多个 Condition,可精确控制线程唤醒(如 await()/signal()) |
| 性能 | JDK 6+ 优化后(偏向锁→轻量级锁→重量级锁)性能接近 ReentrantLock | 在高竞争场景下性能略优(减少上下文切换) |
| 死锁检测 | 无内置死锁检测 | 可通过 tryLock 避免死锁 |
| 适用场景 | 简单同步场景(如单方法同步) | 复杂同步需求(如公平锁、可中断锁、超时锁) |
| 底层实现 | JVM 通过 monitorenter/monitorexit 字节码实现 | 基于 AbstractQueuedSynchronizer (AQS) 实现 |
:::info synchronized 和 ReentrantLock 的使用差异
:::
::: code-tabs#synchronized 和 ReentrantLock 使用差异
@tab synchronized 使用
// 1. 用于代码块
synchronized (this) {}
// 2. 用于对象
synchronized (object) {}
// 3. 用于方法
public synchronized void test () {}
// 4. 可重入
for (int i = 0; i < 100; i++) {
synchronized (this) {}
}
@tab ReentrantLock 使用
public void test () throw Exception {
// 1. 初始化选择公平锁、非公平锁
ReentrantLock lock = new ReentrantLock(true);
// 2. 可用于代码块
lock.lock();
try {
try {
// 3. 支持多种加锁方式,比较灵活;具有可重入特性
if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ }
} finally {
// 4. 手动释放锁
lock.unlock()
}
} finally {
lock.unlock();
}
}
:::
:::info synchronized 和 ReentrantLock 的适用场景
:::
synchronized适用场景:单例模式的双重检查锁、简单的线程安全计数器。ReentrantLock适用场景:- 需要公平性的任务队列(如订单处理)。
- 需要超时控制的资源争用(如避免死锁)。
- 复杂的多条件线程协调(如生产者-消费者模型)。
使用选择建议
- 选择
synchronized:- 需要简单的代码块同步。
- 不需要高级功能(如超时、公平锁)。
- 选择
ReentrantLock:- 需要精细控制(如公平性、可中断)。
- 需要避免死锁(
tryLock)。
# 【困难】ReentrantLock 的实现原理是什么?⭐⭐⭐⭐
本质上,ReentrantLock 是 AQS 在独占模式下的一个经典实现。
ReentrantLock 以 AQS 的 state 和同步队列为基础,通过 NonfairSync / FairSync 实现(非)公平策略,并内置可重入计数和条件队列机制的互斥锁实现。
- 核心依赖:
ReentrantLock通过内部类Sync(继承AQS)实现锁机制。 - AQS 作用:提供线程阻塞/唤醒的队列管理(CLH 变体)和状态(
state)的原子操作。
:::info 两种模式
:::
ReentrantLock 内部有两个主要的静态内部类,决定了其抢占行为:
NonfairSync(非公平锁,默认):允许插队- 新线程来了直接尝试 CAS 抢锁(插队),抢不到才排队。
- 优点:吞吐量高。
- 缺点:可能导致饥饿问题。
FairSync(公平锁):先到先得- 新线程来了先检查同步队列是否为空,有排队者则直接去队尾排队。
- 优点:公平,无饥饿问题。
- 缺点:上下文切换多,吞吐量相对低。
二者核心区别就在 lock() 方法中,尝试获取锁前是否检查同步队列中有等待者(hasQueuedPredecessors())。
:::info 三大核心
:::
ReentrantLock
│
├── Sync (extends AQS)
│ ├── state (锁计数器)
│ ├── exclusiveOwnerThread (当前持有线程)
│ └── CLH Queue (等待锁的线程队列)
│
├── NonfairSync (默认,插队抢锁)
├── FairSync (先来后到)
│
└── ConditionObject
└── Condition Queue (等待特定条件的线程队列)
- AQS 同步器(Sync):继承自 AQS。
- 状态 (
state):volatile int,表示锁被持有的次数。0=空闲,N=被同一个线程重入了N次。 - 同步队列:存储等待线程的 CLH 变体队列。
- 独占线程:记录当前持有锁的线程 (
exclusiveOwnerThread)。
- 状态 (
- 可重入机制:
- 加锁:若当前线程是持有者,则
state加 1(无需 CAS)。 - 解锁:
state减 1,减到 0 时才完全释放,唤醒后继节点。
- 加锁:若当前线程是持有者,则
- 条件变量 (
ConditionObject):- 每个
Condition对象内部维护一个 独立的等待队列。 await()将当前线程从锁的同步队列移到条件等待队列,并释放锁。signal()将条件等待队列的头节点移到锁的同步队列中,重新等待获取锁。
- 每个
:::info 关键步骤
:::
🔒 加锁四步曲
graph TD
A[线程调用 lock] --> B[CAS 尝试将 state 0→1]
B -->|成功| C[设置 exclusiveOwnerThread 执行临界区]
B -->|失败| D{当前线程已持有锁?}
D -->|是| E[state+1 重入]
D -->|否| F[加入 CLH 同步队列尾部]
F --> G[自旋检查前驱是否为 head]
G -->|是| B
G -->|否| H[park 挂起等待唤醒]
C --> I[调用 unlock]
E --> I
I --> J[state-1]
J --> K{state == 0?}
K -->|是| L[释放锁 唤醒后继节点]
K -->|否| M[仍持有锁]
- 快速抢票:新线程直接 CAS 尝试将
state从 0 改为 1(插队)。 - 抢到则坐:成功则设置自己为独占线程,进入临界区。
- 没抢则排:失败则调用 AQS 的
acquire(1),进入同步队列队尾。 - 队列中等:在队列中进入“自旋-检查-挂起”循环,等待被前驱节点唤醒。
🔓 解锁两步曲(unlock() 本质是两个关键操作)
- 尝试释放:调用
tryRelease(1),将state减 1。如果state减到 0,则清空独占线程标记。 - 唤醒后继:如果锁完全释放(
state == 0),则唤醒同步队列中下一个符合条件的等待线程。
::: info L4 深度:C++ std::mutex 与 Java ReentrantLock 的跨语言对比
:::
C++ std::mutex vs Java ReentrantLock:设计哲学的核心分歧
| 维度 | C++ std::mutex | Java ReentrantLock |
|---|---|---|
| 可重入性 | 不可重入(同一线程重复 lock 是 UB,通常死锁) | 可重入(state 累加计数) |
| 公平性 | 标准不规定(通常实现为非公平,无公平模式 API) | 构造参数控制公平/非公平 |
| 条件变量 | 分离设计:std::condition_variable + std::unique_lock | 内聚设计:lock.newCondition() 返回 Condition |
| 超时机制 | std::timed_mutex(独立类型);std::unique_lock 不参与 | tryLock(long, TimeUnit) 直接内置 |
| RAII 封装 | std::lock_guard / std::unique_lock(C++ 核心惯用法) | 无标准 RAII wrapper(需手动 try-finally) |
| 底层实现 | Linux:futex(低竞争用户态 CAS,高竞争内核阻塞) | 同样基于 futex(通过 park/unpark) |
| recursive_mutex | 需显式使用 std::recursive_mutex(独立类型) | 默认即可重入(同类型) |
C++ 为什么把 mutex 和 condition_variable 分开?
C++ 遵循「单一职责」原则:
std::mutex只负责互斥(lock/unlock)std::condition_variable只负责等待-通知(wait/notify)std::unique_lock负责 RAII 式的锁生命周期管理
三者组合使用:
std::mutex mtx;
std::condition_variable cv;
std::queue<int> queue;
// 生产者
{
std::lock_guard<std::mutex> lock(mtx); // RAII 自动释放
queue.push(data);
}
cv.notify_one(); // 通知在锁外,避免「惊群」
// 消费者
std::unique_lock<std::mutex> lock(mtx); // 可手动 unlock
cv.wait(lock, [&]{ return !queue.empty(); }); // 谓词检查防虚假唤醒
Java ReentrantLock 为什么把 Condition 内聚在锁中?
Java 的设计哲学是「便利性 + 正确性优先」:
newCondition()绑定锁实例:避免 C++ 中condition_variable与错误的mutex搭配使用。Java 的Condition在await()时必须持有关联锁,编译期 + 运行期双重校验,杜绝 C++ 中「condition_variable 用错 mutex」的未定义行为。await()原子释放锁再阻塞:Java 的await()内部自动完成「原子释放锁 → 加入条件队列 → park 阻塞」的三步操作,而 C++ 的wait()需要外部先unique_lock再传入——虽然效果等价,但 Java 的内聚设计降低了误用概率。signal()到lock的转移链:被signal()的线程从条件队列移至 AQS 同步队列,等待重新获取锁——这个转移在 AQS 内部原子完成。C++ 无此机制,被 notify 的线程需重新竞争锁,可能面临优先级反转。
std::unique_lock vs Java ReentrantLock 的设计哲学
| 维度 | C++ std::unique_lock | Java ReentrantLock |
|---|---|---|
| 所有权模型 | move-only(所有权可转移) | 无所有权转移概念(任意线程可 unlock,但语义上只有持有者应 unlock) |
| 生命周期 | RAII 自动管理(构造加锁,析构释放) | 手动 try-finally(无析构保证) |
| 灵活性 | 可延迟加锁、提前解锁、转移所有权 | tryLock 支持超时/非阻塞,但无所有权转移 |
| 性能 | 零开销抽象(编译期内联,无虚函数) | JIT 优化 + 偏向锁消除,热点路径可达 native |
面试记忆点:C++ 是「积木式组合」——mutex + condition_variable + unique_lock 三者松耦合换取最大灵活性;Java 是「一体化工具」——ReentrantLock 内置公平性、可重入、Condition、超时,牺牲部分灵活性换取开箱即用的正确性。C++ 的
recursive_mutex独立成类型说明可重入是有代价的(通常暗示设计问题),而 Java 默认支持可重入反映了「方便优先」的实用主义。
# 【困难】ReentrantReadWriteLock 的实现原理是什么?⭐⭐⭐
::: info ReentrantReadWriteLock 的特性
:::
ReentrantReadWriteLock 是为【读多写少】的并发场景设计的锁实现。
ReentrantReadWriteLock 允许多个线程同时持有读锁,但同一时刻只允许一个线程持有写锁。此外,存在读锁时无法获取写锁,存在写锁时无法获取读锁。
ReentrantReadWriteLock 有以下特性:
- 可重入:读写锁都支持可重入。
- 支持公平锁,默认为非公平锁。
- 支持锁降级:持有写锁可以获取读锁;反之不允许。
::: info ReentrantReadWriteLock 的核心设计
:::
ReentrantReadWriteLock 基于 AQS 实现的读写锁。
ReentrantReadWriteLock 的核心设计思想是将一个 32 位的 int 状态变量拆分为两部分:state = (readCount << 16) | writeCount
虽然提供了两个锁对象(readLock, writeLock),但底层共享同一个 AQS 同步器。
| 视角 | 读锁 (ReadLock) | 写锁 (WriteLock) |
|---|---|---|
| 行为 | 共享锁 | 独占锁 |
| 占用 state | 高 16 位 | 低 16 位 |
| 互斥规则 | 与写锁互斥 | 与所有锁(读、写)互斥 |
| 重入计数 | 所有读线程的总重入次数 | 单个写线程的重入次数 |
| 条件变量 | 不支持 Condition | 支持 Condition |
::: info ReentrantReadWriteLock 写锁实现(WriteLock)
:::
ReentrantReadWriteLock 写锁基于 AQS 的独占模式实现。
写锁获取步骤:
- 线程申请写锁(
writeLock.lock()) - 检查有没有读锁或写锁(
state != 0) - 没有,CAS 设置 state 的低 16 为 1(获得写锁)
- 有,当前线程是否已持有写锁(可重入)
- 是:CAS 将 state 的低 16 加 1(获得写锁)
- 否:排队等待(进入 AQS 同步队列挂起)
实现方法:
protected final boolean tryAcquire(int acquires) {
// 检查是否有读锁或其他线程持有写锁
if (c != 0 && w == 0) return false;
// 检查重入或 CAS 设置状态
// ...
}
::: info ReentrantReadWriteLock 读锁实现(ReadLock)
:::
ReentrantReadWriteLock 读锁基于 AQS 的共享模式实现。
- 线程申请读锁(
readLock.lock()) - 检查有没有写锁(
(state & 0xFFFF) != 0) - 没有,CAS 将 state 的高 16 加 1(获得读锁)
- 有,排队等待(进入 AQS 同步队列挂起)
::: info ReentrantReadWriteLock 锁降级实现
:::
- 线程持有写锁
- 直接申请读锁:因为线程有写锁,因此一定成功(高 16 位加 1)
- 释放写锁
- 锁状态从 “独占写” 降级为 “共享读”。
// 锁降级示例代码
writeLock.lock(); // 获取写锁
try {
// 修改数据。..
readLock.lock(); // 在保持写锁的情况下获取读锁(锁降级关键步骤)
} finally {
writeLock.unlock(); // 释放写锁,降级为读锁
}
// 此时仍持有读锁,其他线程可以获取读锁但不能获取写锁
性能优化技巧
- firstReader 优化:记录第一个获取读锁的线程,避免 ThreadLocal 查找
- cachedHoldCounter:缓存最近一个获取读锁的线程计数器
- 读锁计数存储:使用 ThreadLocal 保存每个线程的重入次数,避免竞争
# 【困难】StampedLock 的实现原理是什么?⭐⭐
StampedLock是 JDK8 引入的高性能锁,适合读多写少且追求极致吞吐的场景,但需谨慎处理乐观读失败和死锁风险。
StampedLock 是 通过一个 64 位 long 值同时编码版本号、读计数和写标记,并利用戳记(Stamp)实现乐观读、锁升级等高级并发控制,在牺牲部分易用性和重入性的前提下,提供极高读性能的同步器。
::: info StampedLock 三种模式
:::
StampedLock 的状态存储在一个 long 型(64 位)变量中,分为三个逻辑部分:
| 模式 | 占用位 | 作用 |
|---|---|---|
| 读锁 | 低 7 位 | 读线程计数(实际是readerCount+1) |
| 写锁 | 第 8 位 | 独占标记,0 未占用,1 已占用 |
| 版本号 | 未使用的高位 | 加锁返回有效戳,解锁需验证戳; 戳无效 = 状态变更(有写操作),戳有效 = 数据一致 |
状态流转:
初始状态:state = (version: 0, readCount: 0, write: 0)
[操作与状态变化示例]
1. 写锁获取 (`writeLock()`)
-> state 低 8 位置 1,同时整个 long 值改变 (version++)
-> 返回 stamp W1
2. 乐观读 (`tryOptimisticRead()`)
-> 不修改 state!仅记录当前 state 值作为 stamp O1
-> 校验时:比较当前 state 是否等于 O1
3. 读锁获取 (`readLock()`)
-> 高 56 位读计数+1 (如果写锁未被占用)d
-> 返回 stamp R1
4. 锁升级 (`tryConvertToWriteLock(R1)`)
-> 原子操作:读计数-1,同时写标记置 1
-> 成功返回新 stamp W2,失败返回 0
::: info StampedLock 乐观锁
:::
StampedLock 乐观锁是一种 “读时复制+版本校验” 的乐观并发控制。
乐观锁在读远多于写且写操作不频繁的场景下,性能极高(完全无锁)。
StampedLock 乐观锁流程
- 调用
tryOptimisticRead()获取一个戳记(Stamp),此时完全不阻塞。 - 读取共享数据到局部变量。
- 调用
validate(stamp)校验:自获取戳记以来,是否有写锁被获取过?- 无变化:数据有效,直接使用。
- 有变化:升级为悲观读锁,重新读取数据。
::: info StampedLock 锁升级
:::
读锁 → 写锁升级:tryConvertToWriteLock(stamp)
- 前提:当前线程已持有读锁(
stamp是有效的读锁戳记)。 - 过程:原子地尝试释放读计数,获取写锁标记。
- 结果:成功返回新写戳记,失败返回 0。
- 注意:可能死锁!如果当前还有其他读锁持有者,升级会失败(因为读写互斥)。
乐观读 → 写锁升级:
- 乐观读戳记本身不代表持有锁,升级失败是常态。
- 通常先获取悲观读锁,再进行升级尝试。
::: info StampedLock vs. ReentrantReadWriteLock
:::
| 特性 | StampedLock | ReentrantReadWriteLock |
|---|---|---|
| 读并发度 | 最高(乐观读无阻塞) | 高(悲观读阻塞写) |
| 写饥饿 | 可能发生 | 非公平模式下可能发生 |
| 锁重入 | 不支持 | 支持 |
| 公平性 | 仅非公平 | 支持公平/非公平 |
| 条件变量 | 不支持 | 支持 |
::: info StampedLock 使用示例
:::
StampedLock lock = new StampedLock();
// 乐观读示例
long stamp = lock.tryOptimisticRead();
// 读取共享数据。..
if (!lock.validate(stamp)) {
// 版本失效,转悲观读
stamp = lock.readLock();
try {
// 重新读取数据。..
} finally {
lock.unlockRead(stamp);
}
}
// 写锁示例
long stamp = lock.writeLock();
try {
// 修改数据。..
} finally {
lock.unlockWrite(stamp);
}
# 【中等】Condition 的原理是什么?与 Object.wait/notify 有什么区别?⭐⭐⭐
Condition 是 Lock 接口的条件变量,提供比 Object.wait/notify 更灵活的线程等待/通知机制。一个 Lock 可以创建多个 Condition,实现精确唤醒。
(1)Condition 的核心结构
ConditionObject 是 AQS 的内部类,维护一个独立的等待队列(单向链表):
public class ConditionObject implements Condition {
private transient Node firstWaiter; // 等待队列首节点
private transient Node lastWaiter; // 等待队列尾节点
}
(2)核心机制
graph TD
A[线程调用 await] --> B[创建 Node 加入条件队列]
B --> C[释放锁 fullyRelease]
C --> D[park 挂起线程]
D --> E[被 signal 唤醒]
E --> F[从条件队列移到同步队列]
F --> G[重新竞争锁]
G --> H[恢复执行]
await():当前线程包装为 Node 加入条件队列,完全释放锁,park 挂起。被唤醒后从条件队列转移到同步队列,重新竞争锁。signal():将条件队列的首节点转移到同步队列,唤醒该线程(注意只是转移,线程需重新竞争锁)。
(3)与 Object.wait/notify 的对比
| 特性 | Condition | Object.wait/notify |
|---|---|---|
| 依赖 | Lock(如 ReentrantLock) | synchronized |
| 等待队列数量 | 可创建多个,支持精确唤醒 | 仅一个,notifyAll 唤醒所有 |
| 中断响应 | awaitUninterruptibly() 不响应中断 | wait() 必响应中断 |
| 超时 | awaitUntil(date) 支持截止时间 | wait(timeout) 支持超时 |
| 不释放锁场景 | 无(await 必释放锁) | 无(wait 必释放锁) |
(4)典型应用:生产者-消费者精确唤醒
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 队列未满条件
Condition notEmpty = lock.newCondition(); // 队列非空条件
// 生产者:队列满时等待 notFull,生产后唤醒 notEmpty
// 消费者:队列空时等待 notEmpty,消费后唤醒 notFull
# 【困难】AQS 的 CLH 队列与原始 CLH 队列有什么区别?⭐⭐
AQS 的等待队列是 CLH(Craig, Landin, and Hagersten)队列的变体,并非原始 CLH 队列。
(1)原始 CLH 队列
- 基于隐式链表,每个节点只持有前驱节点的引用。
- 自旋等待:线程在前驱节点的状态上自旋(spin on predecessor)。
- 只支持独占模式。
(2)AQS 的 CLH 变体
| 特性 | 原始 CLH | AQS CLH 变体 |
|---|---|---|
| 队列结构 | 隐式链表(仅前驱) | 显式双向链表(prev + next) |
| 等待方式 | 自旋 | park 阻塞(避免 CPU 浪费) |
| 模式 | 仅独占 | 独占 + 共享 |
| 状态判断 | 前驱节点的状态 | 前驱节点的 waitStatus |
| 取消处理 | 无 | 支持 CANCELLED 状态,节点可被清理 |
| 条件队列 | 无 | ConditionObject 独立的条件队列 |
(3)为什么选择阻塞而非自旋?
- 自旋在等待时间长的场景浪费 CPU。
- AQS 通过
LockSupport.park()挂起线程,unpark()唤醒,交由 OS 调度。 - 仅在「自旋检查前驱状态」时短暂自旋,避免不必要的 park/unpark 开销。
(4)入队与出队的关键逻辑
// 入队:CAS 设置 tail
Node node = new Node(thread);
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
// 出队:head 指向新节点,旧 head 被 GC
setHead(node);
node.prev = null;
(5)为什么是双向链表?
- 取消节点时需找到前驱来断开链接(单向链表需遍历,O(n))。
- 检查是否为 head 的后继时需要反向遍历。
- 共享模式唤醒需要向后传播。
# Java 无锁
# 【中等】什么是 CAS?CAS 的实现原理是什么?⭐⭐⭐⭐⭐
::: info 什么是 CAS?
:::
CAS 是 Compare-And-Swap(比较并交换) 的缩写,是实现并发编程的无锁原子操作核心。
CAS 核心规则是:先比较内存中某个值是否等于预期值,若相等则将其更新为新值;若不等则不操作,整个过程原子性完成。
CAS 操作伪代码
boolean CAS(Variable var, int expected, int newValue) {
if (var.value == expected) { // 比较当前值是否等于预期值
var.value = newValue; // 如果相等,更新为新值
return true;
}
return false; // 否则失败
}
说明:
- 读取内存值
V。 - 比较
V和预期值A:- 如果
V == A,说明没有其他线程修改过,更新为B。 - 如果
V != A,说明值已被修改,放弃更新。
- 如果
- 返回操作是否成功。
graph TD
A[线程读取内存值 V] --> B{V == 预期值 A?}
B -->|是| C[原子更新 V = B]
C --> D[返回成功 true]
B -->|否| E[放弃更新]
E --> F{是否需要自旋重试?}
F -->|是| A
F -->|否| G[返回失败 false]
::: info CAS 特性
:::
- 无锁:无需加 synchronized/Lock,减少线程阻塞 / 唤醒开销,性能更高;
- 原子性:CPU 指令级保证,比手动加锁更可靠;
- ABA 问题:V 先从 A 变 B 再变回 A,CAS 会误判为未修改(解决:加版本号,如
AtomicStampedReference)。
::: info CAS 的实现原理是什么?
:::
Java 层面,通过 Unsafe 类调用 native 方法(如 compareAndSwapInt())实现 CAS。
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int newValue);
更底层(CPU 层面),CAS 实现依赖于 CPU 提供的原子指令(如 x86 的 cmpxchg 指令)。
从 native 到硬件的完整链路:
- HotSpot 为
compareAndSwapInt生成的机器码是lock cmpxchg(x86):cmpxchg本身只保证单次比较交换的语义,lock前缀锁总线/锁缓存行,保证多核下的原子性。 lock前缀同时带来两个副作用:将当前核心缓存行写回主内存并使其他核心的缓存行失效(可见性);充当全量内存屏障,禁止指令重排序(有序性)。这就是为什么 CAS 变量不需要再叠加volatile做读写同步——但 Atomic 类的value字段本身仍是volatile,保证普通get()的可见性。- 高竞争下
lock cmpxchg的缓存行乒乓(cache line bouncing)是性能瓶颈根源,LongAdder的分段 Cell 正是为此而生(见下文 LongAdder 题)。
::: info CAS 典型应用
:::
(1)原子类
AtomicInteger atomicInt = new AtomicInteger(0);
atomicInt.incrementAndGet(); // CAS 实现原子自增
底层实现:
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
(2)自旋锁
while (!CAS(lock, 0, 1)) { // 尝试获取锁
// 自旋等待
}
(3)无锁数据结构
ConcurrentHashMap(JDK 8 使用 CAS +synchronized替代分段锁)。CopyOnWriteArrayList(CAS 保证写入原子性)。
::: info L4 深度:x86 CMPXCHG 指令 + LOCK 前缀详解
:::
x86 CMPXCHG 指令的微架构执行流程
CMPXCHG 是 x86 架构的核心原子指令,其语义是:比较 EAX(累加器)与目标内存操作数,若相等则将源操作数写入目标,否则将目标值加载到 EAX。
; CMPXCHG [mem], r (Intel 语法)
; 伪代码:
TEMP = [mem]
IF EAX == TEMP:
ZF = 1 ; 设置零标志
[mem] = r ; 写入新值
ELSE:
ZF = 0 ; 清除零标志
EAX = TEMP ; 将旧值加载到 EAX
LOCK 前缀的硬件行为(多核原子性保证):
CMPXCHG 本身不是原子的!在多核环境下,若无 LOCK 前缀,两个核心可能同时执行 CMPXCHG 并都认为自己成功。LOCK 前缀通过以下机制保证原子性:
锁缓存行(Cache Lock):现代 CPU(P6 之后)通过 MESI 协议锁定目标地址所在的缓存行(断言
#LOCK信号),而非锁整个总线。锁期间该缓存行在其它核心处于Invalid状态。总线锁兜底:当操作数跨缓存行边界(非对齐)或无法锁缓存行时,回退到锁整个前端总线(bus lock),代价极高(~100 cycles+ vs ~20-40 cycles)。
内存屏障效应:
LOCK前缀隐式充当全量内存屏障(full fence)——所有之前的 load/store 对之后可见,禁止 StoreLoad 重排序。这是 CAS 提供volatile-like 可见性的硬件根源。
lock cmpxchg 的完整时序(x86 多核视角):
Core-0 Core-1
│ │
├─ lock cmpxchg [addr], r │
├─ 发出 RFO(Read-For-Ownership) │
├─ 获取 addr 所在缓存行的独占权 │
├─ MESI: E → M(Modified) │ 缓存行被 Invalidate
├─ EAX vs [addr] 比较并写入 │
├─ 缓存行写回(或保持 M 状态) │
└─ 释放 lock 信号 ├─ 下次访问 addr 时 Cache Miss
└─ 重新从 Core-0 获取最新值
x86 vs ARM(LDREX/STREX / LL/SC)的 CAS 实现差异
| 维度 | x86(CMPXCHG) | ARM(LDREX/STREX) |
|---|---|---|
| 指令模式 | 单指令(CMPXCHG 一条完成比较+交换) | 双指令:LDREX(加载+标记)→ STREX(条件存储) |
| 原子性保证 | LOCK 前缀锁总线/缓存行 | Exclusive Monitor(硬件监视器)检测 LDREX→STREX 之间是否有其他写入 |
| ABA 敏感性 | 值与预期相同即成功(ABA 不感知) | STREX 失败即表示有他人写入(中间状态变化感知) |
| 限制 | 操作数必须对齐,跨缓存行退化为总线锁 | LDREX/STREX 之间不能有复杂操作(通常几十条指令内),否则硬件监视器可能超时 |
| Java 适配 | Unsafe::compareAndSwapInt → lock cmpxchg | Unsafe::compareAndSwapInt → ldrex/strex 循环(LL/SC 循环) |
ARM LL/SC 的 CAS 模拟:由于 ARM 没有单指令 CAS,HotSpot 用 LL/SC 循环模拟:
// ARM 上的 CAS 伪汇编
retry:
LDREX r1, [r0] // Load-Link: 加载值并标记地址
CMP r1, r2 // 比较期望值
BNE fail // 不相等则失败
STREX r3, r3, [r0] // Store-Conditional: 条件存储
CMP r3, #0 // 检查 STREX 是否成功
BNE retry // 失败则重试(被其他核心打断)
fail:
// 返回结果
STREX 的返回值(r3)为 0 表示成功;非 0 表示 Exclusive Monitor 检测到地址被修改(其他核心写入、中断、上下文切换都可能导致 Monitor 清除),需重试整个 LL/SC 序列。
::: info L4 深度:ABA 问题在 C++ shared_ptr 中的表现
:::
ABA 问题的跨语言视角:C++ std::shared_ptr 的 ABA 陷阱
std::shared_ptr 是 C++ 的引用计数智能指针,当它与无锁数据结构结合时,ABA 问题会以更危险的形式出现。
场景:lock-free stack 的 ABA 致危代码
template<typename T>
class LockFreeStack {
struct Node { T data; Node* next; };
std::atomic<Node*> head;
void push(T val) {
Node* n = new Node{val};
do {
n->next = head.load();
} while (!head.compare_exchange_weak(n->next, n));
}
std::shared_ptr<T> pop() { // ← 危险!
Node* old_head;
do {
old_head = head.load();
if (!old_head) return nullptr;
} while (!head.compare_exchange_weak(old_head, old_head->next));
auto res = std::make_shared<T>(old_head->data);
delete old_head; // ← 问题核心:直接 delete
return res;
}
};
ABA 攻击时序:
时刻 T1: Thread-A 读到 head = Node-A(next = Node-B)
时刻 T2: Thread-A 被挂起(操作系统调度)
时刻 T3: Thread-B pop 了 Node-A,delete Node-A,再 pop 了 Node-B
时刻 T4: Thread-B push 新节点,恰好分配到已释放 Node-A 的内存地址 → head = Node-C(address==&Node-A)
时刻 T5: Thread-A 恢复,CAS(head, Node-A, Node-B) → 成功!但 head 应指向 Node-C...
结果:Node-C 丢失,内存泄漏 + 数据丢失
C++ 的解法:hazard pointer(危险指针)或 epoch-based reclamation(基于 epoch 的回收)。核心思想:延迟释放内存直到确认没有线程持有该指针。
Java 的 AtomicStampedReference vs C++ shared_ptr 思路对比:
| 维度 | Java AtomicStampedReference | C++ std::shared_ptr |
|---|---|---|
| 解决 ABA 的方式 | 版本号/时间戳(显式 stamp,每次 CAS 校验) | 引用计数(内存不被释放就不会被复用) |
| 额外开销 | stamp 需要额外 4/8 字节 + CAS 双字操作 | 引用计数原子操作(inc/dec) |
| 局限性 | 只防值层面的 ABA,不防指针复用 | 性能开销大(atomic inc/dec + 内存分配),不适用于高频 CAS 路径 |
面试记忆点:ABA 问题分两层面——「值 ABA」(值从 A→B→A,如
AtomicInteger,用版本号解决)和「指针 ABA」(指针地址被释放后重用,如shared_ptrlock-free 场景,用延迟回收/引用计数解决)。Java 的AtomicStampedReference解决值 ABA,C++ 的shared_ptr通过保证内存不被释放来间接解决指针 ABA,但代价是引用计数的原子操作开销。
# 【中等】CAS 算法存在哪些问题?⭐⭐⭐⭐
CAS(Compare-And-Swap)是一种无锁并发编程技术,广泛用于 Java 的 Atomic 类、AQS、ConcurrentHashMap 等并发工具中。但它也存在一些问题和限制:
ABA 问题
问题描述:变量值从
A→B→A,CAS 检查时认为没有变化,但实际上已经被修改过。影响:可能导致数据不一致(如链表操作时节点被替换但指针仍有效)。

解决方案:
- 使用 版本号/时间戳(如
AtomicStampedReference)。 - 使用
boolean标记(如AtomicMarkableReference)。
- 使用 版本号/时间戳(如
自旋产生的 CPU 空转
- 问题描述:如果 CAS 长时间失败,线程会持续自旋(
while循环),占用 CPU 资源。 - 影响:高并发竞争时,可能导致 CPU 使用率飙升。
- 解决方案:
- 限制自旋次数(如
LongAdder改用分段 CAS)。 - 结合
yield()或Thread.sleep()减少竞争。
- 限制自旋次数(如
只能保证单个变量的原子性
- 问题描述:CAS 只能对一个变量进行原子操作,无法保证多个变量的复合操作(如
i++和j--)。 - 影响:需要额外同步机制(如锁)来保证多变量一致性。
- 解决方案:
- 使用
synchronized或ReentrantLock。 - 设计不可变对象(如
String、BigInteger)。
- 使用
公平性问题
- 问题描述:CAS 是非公平的,新线程可能比等待队列中的线程更快获取锁。
- 影响:可能导致线程饥饿(某些线程长期得不到执行)。
- 解决方案:
- 使用公平锁(如
ReentrantLock(true))。 - 结合队列调度(如 AQS 的 CLH 队列)。
- 使用公平锁(如
不适用于复杂操作
- 问题描述:CAS 适合简单操作(如
count++),但不适合复杂逻辑(如数据库事务)。 - 影响:需要拆分为多个 CAS 步骤,可能引入中间状态不一致。
- 解决方案:
- 使用锁(如
synchronized)。 - 改用事务内存(如 Clojure STM)。
- 使用锁(如
平台依赖性
- 问题描述:CAS 依赖底层 CPU 指令(如
CMPXCHG),不同架构性能可能差异较大。 - 影响:在 ARM 等弱内存模型平台可能出现意外行为。
- 解决方案:使用 JVM 内置原子类(如
AtomicInteger),而非手动实现。
总结
| 问题 | 影响 | 解决方案 |
|---|---|---|
| ABA 问题 | 数据不一致 | AtomicStampedReference |
| 自旋开销 | CPU 占用高 | 限制自旋次数 / 退让策略 |
| 单变量限制 | 复合操作不安全 | 锁 / 不可变对象 |
| 公平性 | 线程饥饿 | 公平锁 / 队列调度 |
| 复杂操作 | 难以实现 | 锁 / 事务内存 |
| 平台依赖 | 跨平台兼容性差 | 使用标准库 |
CAS 在无锁编程中非常高效,但需结合场景权衡利弊。在高竞争环境下,可能需要改用锁或其他并发策略。
# 【困难】LongAdder 的原理是什么?为什么高并发下比 AtomicLong 快?⭐⭐⭐
LongAdder 是 JDK8 引入的高性能累加器,在超高并发场景下性能比 AtomicLong 高数倍,核心思想是分段累加(Cell)+ 合并求和。
(1)AtomicLong 的瓶颈
AtomicLong 基于 CAS 自旋,高并发下大量线程同时竞争同一个 value 字段:
- CAS 失败率高,自旋浪费 CPU。
- 所有线程竞争同一缓存行,缓存一致性流量大(MESI 协议频繁失效)。
(2)LongAdder 的分段设计
LongAdder 继承 Striped64,核心结构:
transient volatile long base; // 基础值,无竞争时直接 CAS 累加
transient volatile Cell[] cells; // 分段数组,竞争时分散到不同 Cell
每个 Cell 封装一个 volatile long value,并通过 @Contended 注解避免伪共享(padding 填充缓存行)。
(3)累加流程
graph TD
A[调用 add] --> B{cells 是否为 null?}
B -->|是| C[CAS 累加 base]
C -->|成功| D[返回]
C -->|失败| E[初始化 cells 数组]
B -->|否| F[根据线程 hash 定位 Cell]
F --> G[CAS 累加 Cell.value]
G -->|成功| D
G -->|失败| H{是否需要扩容?}
H -->|是| I[扩容 cells 2倍]
H -->|否| F
I --> F
- 无竞争时:直接 CAS 累加
base(类似 AtomicLong)。 - 有竞争时:根据线程的 hash 定位到某个
Cell,CAS 累加该 Cell 的 value。 - Cell 竞争激烈时:rehash(重新计算 hash)或扩容 cells 数组(最大为 CPU 核心数的 2 的幂次)。
(4)求和
sum() 遍历所有 Cell 并加上 base,非原子操作,适合统计场景而非精确计数。
public long sum() {
Cell[] as = cells;
long sum = base;
if (as != null) {
for (Cell a : as) {
if (a != null) sum += a.value;
}
}
return sum;
}
(5)性能对比
| 并发度 | AtomicLong | LongAdder | 提升倍数 |
|---|---|---|---|
| 低(1 线程) | 基准 | 略慢 | 0.9x |
| 中(8 线程) | 基准 | 2-3x | 2-3x |
| 高(64 线程) | 基准 | 5-10x | 5-10x |
(6)选型建议
- 需要精确值(如序列号生成)→
AtomicLong - 统计场景(如 QPS、PV 计数)→
LongAdder - 自定义累加规则(如求最大值)→
LongAccumulator
# 【中等】Java 中支持哪些原子类?⭐⭐
Java 原子类底层基于 CAS 指令(CPU 级原子操作)+ 自旋重试 实现无锁原子操作。部分高性能原子类(如 LongAdder)采用分段累加优化高并发性能。
原子类相当于一种泛化的 volatile 变量,能够支持原子的、有条件的读/改/写操作。
::: info 原子类分类
:::
| 分类 | 核心类 | 作用 |
|---|---|---|
| 基本类型原子类 | AtomicInteger、AtomicLong、AtomicBoolean | 对 int / long / boolean 做原子增删改查,替代加锁 |
| 引用类型原子类 | AtomicReference、AtomicStampedReference、AtomicMarkableReference | 对引用类型做原子操作,解决 CAS 的 ABA 问题 |
| 数组类型原子类 | AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray | 对数组元素做原子操作(数组本身不原子,元素原子) |
| 字段更新器 | AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater | 对字段做原子更新,无需改字段类型 |
| 累加器 | LongAdder、DoubleAdder、LongAccumulator、DoubleAccumulator | 高并发下替代 AtomicLong/Double,分段累加提性能 适合统计,但不保证实时精确值 |
::: info 核心原子类
:::
(1)基础类
| 类 | 核心特性 | 典型场景 |
|---|---|---|
| AtomicInteger | 支持 getAndIncrement(i++)、compareAndSet 等 | 计数器、序列号生成 |
| AtomicBoolean | 原子更新布尔值,底层用 int 存储(0/1) | 状态标记(如开关) |
| AtomicReference | 原子更新对象引用 | 原子替换对象实例 |
(2)解决 ABA 问题
| 类 | 核心特性 | 记忆点 |
|---|---|---|
| AtomicStampedReference | 加版本号(戳),CAS 时校验值 + 版本 | 彻底解决 ABA(版本唯一) |
| AtomicMarkableReference | 加标记位(boolean),CAS 校验值 + 标记 | 简化版 ABA 解决(仅标记是否修改) |
(3)高性能累加器
| 类 | 核心特性 | 记忆点 |
|---|---|---|
| LongAdder | 分段累加(base+cells 数组),低竞争用 base,高竞争分 cell | 高并发计数性能≈10 倍 AtomicLong |
| LongAccumulator | 自定义累加规则(如乘法、最大值),比 LongAdder 更灵活 | 支持非加减的原子计算 |
(4)灵活类
| 类 | 核心特性 | 记忆点 |
|---|---|---|
| AtomicIntegerFieldUpdater | 需通过静态方法创建,字段必须 volatile | 不改原有类结构,原子更新字段 |
| AtomicReferenceArray | 索引操作原子,数组长度不可变 | 原子更新数组元素 |
::: info 原子类选型
:::
- **低并发计数(如普通计数器)**→ 基本类型原子类(AtomicInteger 等)
- **高并发计数(如接口 QPS 统计)**→ 累加器(LongAdder 等)
- 操作引用对象 + 防 ABA → 引用类型原子类(AtomicStampedReference 等)
- 操作对象的普通字段 → 字段更新器(AtomicIntegerFieldUpdater 等)
- 操作数组元素 → 数组类型原子类(AtomicIntegerArray 等)
# 【中等】什么是 ThreadLocal?⭐⭐⭐⭐⭐
::: info 什么是 ThreadLocal?
:::
在多线程环境下,共享变量存在并发安全问题。换个思路,如果变量非共享,而是各个线程独享,就不会有并发安全问题。这种思想有个术语叫线程封闭,其本质上就是避免共享。没有共享,自然也就没有并发安全问题。在 Java 中,ThreadLocal 正是根据这个思路而设计的。
ThreadLocal 为每个线程都创建了一个本地副本,这个副本只能被当前线程访问,其他线程无法访问,那么自然是线程安全的。
::: info ThreadLocal 有哪些应用场景?
:::
(1)存储线程私有数据
用户会话(Session)管理:每个请求线程存储当前用户的
Session。private static final ThreadLocal<User> currentUser = ThreadLocal.withInitial(() -> null); // 设置当前用户 currentUser.set(user); // 获取当前用户 User user = currentUser.get();数据库连接(Connection)管理:避免传递
Connection参数。private static final ThreadLocal<Connection> connectionHolder = ThreadLocal.withInitial(() -> dataSource.getConnection());
(2)避免参数透传
问题:多层方法调用需要透传某个上下文参数(如 traceId)。
解决:使用 ThreadLocal 存储,避免方法参数传递。
private static final ThreadLocal<String> traceIdHolder = new ThreadLocal<>();
// 在入口处设置 traceId
traceIdHolder.set("req-123");
// 在任意深层方法获取
String traceId = traceIdHolder.get(); // 无需透传参数
(3)线程安全的工具类
例如:SimpleDateFormat 是线程不安全的,但可以用 ThreadLocal 包装:
private static final ThreadLocal<SimpleDateFormat> dateFormatHolder =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 线程安全地使用
String formattedDate = dateFormatHolder.get().format(new Date());
最佳实践
(1)尽量用 static final
private static final ThreadLocal<User> userHolder = new ThreadLocal<>();
避免重复创建 ThreadLocal 实例。
(2)必须调用 remove()
尤其在线程池场景,否则会导致内存泄漏。
(3)推荐初始化默认值
ThreadLocal<User> userHolder = ThreadLocal.withInitial(() -> new User());
(4)避免在父子线程间传递
ThreadLocal 不能自动继承,需手动处理(可用 InheritableThreadLocal)。
# 【中等】ThreadLocal 的原理是什么?⭐⭐⭐⭐⭐
ThreadLocal 是线程本地变量,核心作用是为每个线程创建独立的变量副本,实现线程间数据隔离。
::: info 核心结构
:::

| 角色 | 作用(记忆点) | 核心关系 |
|---|---|---|
| ThreadLocal | 对外暴露的操作入口(get/set/remove) | 作为 Key,关联线程的变量副本 |
| Thread | 线程对象,内置 ThreadLocalMap 成员变量 | 每个线程有专属的 ThreadLocalMap |
| ThreadLocalMap | 线程内部的哈希表(类似 HashMap) | Key=ThreadLocal(弱引用),Value = 变量副本 |
::: info 核心机制
:::
- 弱引用解决内存泄漏(关键)
- ThreadLocalMap 的 Key 是 ThreadLocal 的弱引用。
- 当 ThreadLocal 无强引用时,GC 会回收 Key。
- 仅回收 Key 仍会残留 Value(强引用),需手动调用
remove()清空,避免内存泄漏;
- 线程隔离本质:变量副本存在 Thread 自身的 Map 中,而非 ThreadLocal 里,ThreadLocal 仅作为 “索引”;
- 初始化机制:重写
initialValue()可指定初始值,也可通过setInitialValue()手动初始化。
::: info 源码锚点
:::
(1)Entry 的弱引用设计:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v; // value 是强引用
}
}
(2)散列与寻址:ThreadLocal.threadLocalHashCode 由 AtomicInteger 按 斐波那契散列(黄金分割数 0x61c88647) 递增生成,让 key 在 2 的幂次数组中均匀分布;冲突时采用线性探测(nextIndex 顺移),而非 HashMap 的链地址法。
(3)泄漏因果链(面试必背):
ThreadLocal 实例失去强引用 → GC 回收弱引用 key(Entry.key 变 null)→ value 仍是强引用,被 Thread → threadLocals → table[i].value 这条引用链牢牢拽住 → 线程不死(线程池复用),value 永远无法回收 → 内存泄漏。
(4)JDK 的三道防线:
set():线性探测途中遇到 key==null 的 Entry 时触发replaceStaleEntry清理;get():未命中时触发expungeStaleEntry清理;remove():直接清理(唯一治本手段)。- 前两道是「顺路清理」,线程池空闲线程长期不操作 Map 时完全失效,所以必须 finally 中 remove()。
::: info L4 深度:Go context.Context 替代 ThreadLocal 的设计哲学
:::
隐式线程局部存储 vs 显式上下文传递:两种范式的根本分歧
Go 语言从设计之初就刻意不提供 goroutine-local storage(类似 ThreadLocal 的机制),而是通过 context.Context 显式传递请求级上下文。这体现了两种截然不同的并发编程哲学。
| 维度 | Java ThreadLocal | Go context.Context |
|---|---|---|
| 传递方式 | 隐式获取:任意深层方法直接 get(),无需参数 | 显式传递:每个函数签名需 ctx context.Context 参数 |
| 线程/协程模型 | 1:1 内核线程绑定,ThreadLocal 随线程存活 | M:N 协程调度,goroutine 频繁创建/切换,线程局部存储无意义 |
| 耦合度 | 隐式依赖全局状态(测试需 mock ThreadLocal) | 显式依赖参数(测试直接传 ctx) |
| 内存管理 | 线程池场景需手动 remove() 防泄漏 | GC 自动回收(ctx 随请求结束释放) |
| 超时/取消 | 无内置传播(需自行实现) | context.WithTimeout/WithCancel 沿调用链自动传播 |
| 可追溯性 | 隐式上下文难以从代码推断数据流 | 显式 ctx 让依赖链一目了然 |
Go 为什么不提供 goroutine-local storage?
goroutine 的轻量性:goroutine 创建成本极低(~2KB 栈空间),生命周期短暂,随请求结束即销毁。
ThreadLocal依赖线程长期存活才有意义,而 Go 中一个请求可能横跨多个 goroutine(如go func()子协程),线程局部存储无法随协程迁移。M:N 调度的复杂性:goroutine 在 OS 线程间动态迁移(work-stealing),若存在 goroutine-local storage,调度器每次迁移都需搬运上下文,与 Go 的「轻量调度」理念冲突。
显式优于隐式(Go 的哲学口号 "Explicit is better than implicit"):
context.Context强制将追踪信息(trace_id、超时、取消信号)写入函数签名,代码审查时能一眼看到依赖链。Java 中ThreadLocal的隐式获取可能导致「远距离幽灵操作」——一个深层方法偷偷读取了 10 层调用前的traceId,却不在任何函数签名中体现。
Go context.Context 的实现机制
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
// 典型用法:沿调用链传递 traceId
func HandleRequest(ctx context.Context, req *Request) {
ctx = context.WithValue(ctx, "traceId", uuid.New())
processOrder(ctx, req.Order)
}
func processOrder(ctx context.Context, order Order) {
traceId := ctx.Value("traceId").(string) // 显式取,但仍需参数传递
// ...
}
各自的优缺点
Java ThreadLocal | Go context.Context | |
|---|---|---|
| 优点 | 无需改函数签名,深度调用链方便;线程绑定天然隔离 | 显式依赖,可测试性强;支持超时/取消传播;无泄漏风险 |
| 缺点 | 隐式耦合,测试困难;线程池需手动清理;不支持取消传播 | 函数签名污染(每个函数都要 ctx);性能略低(值查找是链表遍历) |
设计权衡总结:
| 维度 | 选择 ThreadLocal | 选择 context.Context |
|---|---|---|
| 并发模型 | 1:1 线程模型(Java) | M:N 协程模型(Go) |
| 请求生命周期 | 长生命周期(线程池复用) | 短生命周期(请求即来即走) |
| 架构哲学 | 框架/中间件拦截,隐式注入 | 显式传参,依赖清晰 |
面试记忆点:Java 的
ThreadLocal本质是「用空间换方便」——把上下文绑定到 OS 线程上,隐式获取,代价是内存管理复杂度;Go 的context.Context是「用签名换清晰」——强制显式传递,代价是每个函数多一个参数。选择取决于并发模型(线程 vs 协程)和团队对「隐式魔法 vs 显式啰嗦」的偏好。当你看到ThreadLocal的内存泄漏问题时,Go 的方式天然免疫——因为 ctx 的生命周期与请求绑定,请求结束即 GC。
::: info 应用场景
:::
| 场景 | 核心用法 |
|---|---|
| 资源隔离 | 数据库连接、Session、用户上下文(如登录态),避免线程共享冲突 |
| 性能优化 | 替代方法传参,减少多线程下锁的使用(如 SimpleDateFormat 隔离) |
| 链路追踪 | 存储线程专属的追踪 ID(TraceID),全链路日志关联 |
# 【中等】如何解决 ThreadLocal 内存泄漏问题?⭐⭐⭐⭐⭐
ThreadLocal 的内存泄漏问题源于其特殊的 "弱引用 Key + 强引用 Value" 存储结构。
| 泄漏原因 | 核心逻辑 |
|---|---|
| 核心矛盾 | ThreadLocalMap 的 Key 是弱引用(GC 回收),Value 是强引用(绑定线程),导致 Key 回收后 Value 成 “僵尸值”,随线程长期存活 |
| 高危场景 | 线程池(线程复用)+ 未手动清理 → 僵尸值累积,内存持续泄漏 |
泄漏的完整因果链:
ThreadLocal 失去强引用(如局部变量出作用域)
→ GC 回收弱引用 key(Entry.key == null)
→ value 仍被引用链 Thread → threadLocals → Entry.value 强引用
→ 线程池线程复用不死 → value 永远无法回收 → 泄漏
为什么线程池场景「必现」:普通线程执行完即销毁,threadLocals 随线程对象一起被 GC,泄漏窗口很短;线程池的核心线程与 JVM 同生命周期,一个未 remove 的 value 会随线程无限期存活,且每次任务叠加新的 value,泄漏持续累积。线上典型症状:老年代缓慢爬升、Full GC 后回落不明显,MAT 分析可见 ThreadLocalMap$Entry retained 大对象。

::: info ThreadLocal 内存泄露场景
:::
::: code-tabs#内存泄漏的具体场景
@tab 线程池环境未清理
ExecutorService pool = Executors.newFixedThreadPool(5);
ThreadLocal<BigObject> tl = new ThreadLocal<>();
pool.execute(() -> {
tl.set(new BigObject()); // 存储大对象
// 业务逻辑。..
// 缺少 tl.remove()!线程复用后旧 Value 仍然存在
});
后果:线程被重复使用时,之前的 BigObject 实例无法被回收
@tab remove() 未放 finally 块
异常时 remove() 不执行
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("数据");
if (业务异常) {
throw new Exception(); // 跳过 remove()
}
tl.remove();
:::
::: info ThreadLocal 内存泄露解决方案
:::
(1)最核心:用完必在 finally 调用 remove()(治标治本)
在业务代码结束处(finally 块)调用 threadLocal.remove(),清空当前线程的 Value;
ThreadLocal<String> tl = new ThreadLocal<>();
try {
tl.set("业务数据");
// 业务逻辑执行
} finally {
tl.remove(); // 无论是否异常,必清理
}
(2)兜底:依赖 Key 的弱引用特性(被动防护)
ThreadLocalMap 会在 set()/get()/remove() 时,自动清理 “Key 为 null” 的 Entry(僵尸值)。
弱引用是 JDK 层面的兜底,但若长期不操作 Map(如线程池空闲),仍会泄漏,需配合主动 remove。
(3)高危场景:线程池使用必规范(重点避坑)
线程池线程复用,若前一个任务未清理 ThreadLocal,后一个任务会读取到脏数据 + 内存泄漏;
线程池任务中,ThreadLocal 必须在 finally 中 remove,或使用线程池的任务包装器统一清理。
(4)辅助:规范初始化(减少泄漏风险)
规范初始化可减少无效 set 操作,降低僵尸值产生概率。
- 方式 1:重写
initialValue()初始化,避免多次 set 导致旧值残留; - 方式 2:使用
ThreadLocal.withInitial()(Java 8+),初始化逻辑更清晰,减少空值操作;
::: info ThreadLocal 使用避坑
:::
| 常见误区 | 正确做法 |
|---|---|
| 依赖 GC 自动回收 | GC 仅回收 Key,无法回收 Value,必须手动 remove |
| 线程池只初始化一次 ThreadLocal | 每次任务执行完都要 remove,而非仅初始化 |
| 认为 ThreadLocal 静态化会泄漏 | 静态化本身不泄漏,泄漏根源是未 remove + 线程复用 |
# 【中等】虚拟线程环境下 ThreadLocal 有什么问题?ScopedValue 如何解决?⭐⭐⭐
核心结论:虚拟线程把线程数量从数千放大到百万级,ThreadLocal 的三大旧疾被同时放大——内存开销(百万线程 × 每线程副本 = 内存爆炸)、线程池污染(Executors.newVirtualThreadPerTaskExecutor() 每任务新线程,Inherited 语义失效)、不可变上下文传递(TraceID 这类「传下去不许改」的场景,ThreadLocal 全靠约定)。JDK 21 引入的 ScopedValue(JEP 446,JDK 21 预览 / JDK 25 转正) 就是为虚拟线程时代设计的替代品。
(1)ThreadLocal 在虚拟线程下的三个致命问题
| 问题 | 平台线程时代 | 虚拟线程时代 |
|---|---|---|
| 内存占用 | 数千线程 × 副本,可接受 | 百万线程 × 副本,可能直接 OOM |
| 生命周期管理 | 线程池复用,忘记 remove 会脏读 | 每任务新线程,无脏读但创建/销毁副本的开销被放大 |
| 上下文传递 | InheritableThreadLocal 在线程池失效,靠 TTL 等第三方库 | 任务即线程,需要一等公民的「作用域内只读共享」机制 |
(2)ScopedValue 的设计思想
ScopedValue 与 ThreadLocal 的根本差异是绑定模型:ThreadLocal 把值绑定到线程的整个生命周期(可随时 set/remove,可写);ScopedValue 把值绑定到一段代码的作用域(作用域内可读、不可变,作用域结束自动失效,无需手动 remove):
// ScopedValue(JDK 21 预览引入,JDK 22 起使用 Carrier 链式 API,JDK 25 转正)
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
void handleRequest() {
ScopedValue.where(TRACE_ID, "req-123") // 绑定值
.run(() -> { // 在作用域内执行
service(); // 任意深层调用都能读到
}); // 作用域结束自动解绑,无需 remove
}
void service() {
String id = TRACE_ID.get(); // "req-123"
}
(3)与虚拟线程的协同:结构化并发下自动继承
配合 StructuredTaskScope 时,子虚拟线程自动继承父线程作用域内绑定的 ScopedValue——这正是 ThreadLocal/InheritableThreadLocal 在线程池场景一直没能干净解决的问题:
ScopedValue.where(TRACE_ID, "req-123").run(() -> {
try (var scope = new StructuredTaskScope<>()) {
var user = scope.fork(() -> loadUser()); // 子虚拟线程内 TRACE_ID 可读
var order = scope.fork(() -> loadOrder()); // 同样继承
scope.join();
}
});
(4)选型对比
| 维度 | ThreadLocal | ScopedValue |
|---|---|---|
| 绑定目标 | 线程整个生命周期 | 一段代码作用域 |
| 可变性 | 随时 set/remove | 作用域内只读(不可变) |
| 清理方式 | 手动 remove(易遗漏) | 作用域结束自动解绑 |
| 子线程继承 | 需 InheritableThreadLocal/TTL | 结构化并发下自动继承 |
| 虚拟线程适配 | 不推荐(内存与开销) | 官方推荐 |
| 版本 | JDK 1.2+ | JDK 21 预览 / JDK 25 转正 |
面试回答要点:先点明「ThreadLocal 的问题不是新 bug,而是设计模型与虚拟线程的规模不匹配」,再给出 ScopedValue 的「作用域绑定 + 不可变 + 自动解绑 + 结构化继承」四个关键词,最后落到「新项目用虚拟线程时应优先 ScopedValue,存量 ThreadLocal 代码不必强迁」。
# 【中等】InheritableThreadLocal 的实现原理是什么?⭐⭐⭐
核心设计目标
- 线程间值继承:子线程自动继承父线程的 ThreadLocal 值
- 与 ThreadLocal 兼容:继承自
ThreadLocal,保持相同 API
数据存储位置
继承自ThreadLocal,但使用线程对象的独立字段;Thread.inheritableThreadLocals(专门存储可继承的变量)
线程创建时的值拷贝
触发时机:当父线程创建子线程(
Thread.init()方法)拷贝逻辑:
if (parent.inheritableThreadLocals != null) { this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); }深拷贝保证隔离:子线程获得父线程值的独立副本(修改互不影响)
值传递规则
- 仅初始化时拷贝:子线程创建后父线程对值的修改不再影响子线程
- 浅拷贝问题:若存储引用对象,父子线程仍共享同一对象(需开发者自行处理线程安全)
与 ThreadLocal 的对比
| 特性 | InheritableThreadLocal | ThreadLocal |
|---|---|---|
| 继承性 | 子线程自动继承父线程值 | 完全隔离 |
| 存储字段 | Thread.inheritableThreadLocals | Thread.threadLocals |
| 性能开销 | 略高(需初始化时拷贝数据) | 更低 |
| 使用场景 | 需要跨线程传递上下文(如 TraceID) | 线程私有数据 |
使用注意事项
- 对象共享风险:若值是可变的引用对象,需自行保证线程安全
- 线程池陷阱:线程池复用线程时会导致旧值残留(需手动清理)
- 性能影响:大量线程创建时,值拷贝可能成为瓶颈
典型应用场景
// 父线程设置值
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();
itl.set("parent_value");
new Thread(() -> {
// 子线程自动读取到父线程设置的值
System.out.println(itl.get()); // 输出:parent_value
}).start();
实现局限
- 不支持动态更新:子线程启动后父线程的修改不可见
- 无回调机制:无法像
ThreadLocal的initialValue()那样自定义子线程初始值
# 【中等】TransmittableThreadLocal 是什么?解决了什么问题?⭐⭐⭐
TransmittableThreadLocal(TTL) 是阿里巴巴开源的 transmittable-thread-local (opens new window) 项目,解决线程池场景下 ThreadLocal 值传递的问题。
(1)问题背景
| 场景 | ThreadLocal | InheritableThreadLocal | TTL |
|---|---|---|---|
| 子线程继承父线程值 | ❌ | ✔️ | ✔️ |
| 线程池复用线程传递 | ❌ | ❌(仅创建时拷贝一次) | ✔️ |
| 提交任务时动态传递 | ❌ | ❌ | ✔️ |
InheritableThreadLocal 只在线程创建时拷贝父线程的值,线程池复用线程时,后续提交的任务无法获取提交者的上下文。
(2)TTL 的核心原理
TTL 通过 装饰 Runnable + 任务提交时快照 实现值传递:
- 提交任务时:通过
TtlRunnable.get(runnable)包装任务,捕获当前线程的所有 TTL 值快照。 - 任务执行前:在执行线程中,将快照值设置到执行线程的 TTL 中(backup 旧值)。
- 任务执行后:恢复执行线程的旧值,避免污染线程池中的其他任务。
// 使用示例
TransmittableThreadLocal<String> context = new TransmittableThreadLocal<>();
context.set("parent-value");
ExecutorService executor = Executors.newFixedThreadPool(2);
// 关键:用 TtlRunnable 包装,或使用 TtlExecutors.getTtlExecutorService(executor)
executor.submit(TtlRunnable.get(() -> {
System.out.println(context.get()); // 输出 parent-value
}));
(3)典型应用场景
- 链路追踪:TraceID 跨线程池传递。
- 用户上下文:登录态、租户 ID 在异步任务中传递。
- 日志 MDC:MDC 上下文跨线程传递。
(4)使用建议
- 优先使用
TtlExecutors.getTtlExecutorService(executor)装饰线程池,自动包装所有任务。 - TTL 值同样需要
remove(),避免内存泄漏。 - 对于 Java Agent 方式接入(无代码侵入),适合大型项目快速接入。