Skip to content

Commit 04f0dc0

Browse files
author
REALROOK1E
committed
补充volatile、CAS和ThreadLocal的深入原理说明
1 parent a4b10f2 commit 04f0dc0

File tree

3 files changed

+333
-1
lines changed

3 files changed

+333
-1
lines changed

docs/java/concurrent/cas.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,86 @@ public boolean compareAndSet(V expectedReference,
147147
}
148148
```
149149

150+
#### ABA 问题的实际影响场景
151+
152+
虽然 ABA 问题在理论上存在,但在实际应用中是否真的会造成影响需要具体分析:
153+
154+
**无影响场景**: 如果只关心变量的当前值而不关心变更历史,ABA 问题通常不会造成实际影响。例如 `AtomicInteger` 的累加操作,即使值经历了 A→B→A 的变化,最终累加结果仍然正确。
155+
156+
**有影响场景**: 在需要维护数据结构完整性的场景中,ABA 问题可能导致严重错误。典型案例是**无锁栈**的出栈操作:
157+
158+
```
159+
线程 1 准备 CAS 弹出节点 A (A→B→C)
160+
线程 2 弹出 A 和 B,然后重新压入 A (栈变为 A→C)
161+
线程 1 的 CAS 成功(发现栈顶仍是 A),将 next 指向原来的 B
162+
结果: 节点 C 丢失,B 可能已被回收导致悬挂指针
163+
```
164+
165+
因此,涉及指针操作和数据结构维护时,应使用 `AtomicStampedReference``AtomicMarkableReference` 避免 ABA 问题。
166+
167+
### CAS 的 CPU 层面实现
168+
169+
在 x86 架构下,CAS 操作最终会被编译为 **CMPXCHG 指令**,并配合 **LOCK 前缀**保证原子性:
170+
171+
```assembly
172+
LOCK CMPXCHG [内存地址], 新值
173+
```
174+
175+
**LOCK 前缀的作用:**
176+
177+
1. **锁定总线或缓存行**: 在多核处理器中,LOCK 前缀会锁定相关的缓存行(而非整个总线),防止其他 CPU 同时修改该内存位置
178+
2. **保证内存可见性**: 强制将写缓冲区的数据刷新到主内存,并使其他 CPU 的缓存行失效
179+
180+
这就是为什么 CAS 能够在硬件层面保证原子性的原因。相比 synchronized 的重量级锁,CAS 仅锁定单个缓存行,因此开销小得多。
181+
182+
### 自旋与阻塞的性能权衡
183+
184+
CAS 失败时会进行自旋重试,这在不同竞争程度下有不同的性能表现:
185+
186+
**低竞争场景**: CAS 几乎不会失败,自旋开销极小,性能远超加锁方式(避免了线程上下文切换)。
187+
188+
**高竞争场景**: CAS 频繁失败导致大量自旋,CPU 空转消耗资源。此时 synchronized 或 Lock 的阻塞机制反而更优,因为线程会被挂起释放 CPU。
189+
190+
JDK 的 **自适应自旋锁** 就是基于这种权衡:JVM 会根据历史竞争情况动态调整自旋次数,在高竞争时及时转为阻塞。
191+
192+
### LongAdder 的分段 CAS 优化
193+
194+
在高并发计数场景下,多个线程对同一个 `AtomicLong` 进行 CAS 更新会导致激烈竞争。`LongAdder` 通过**分段计数**大幅降低竞争:
195+
196+
**核心思想**: 维护一个 `Cell` 数组,每个线程优先更新自己的 Cell,最终求和获取总值。
197+
198+
```java
199+
// LongAdder 内部结构
200+
transient volatile Cell[] cells;
201+
transient volatile long base;
202+
203+
final void longAccumulate(long x, ...) {
204+
// 如果 cells 为空,先尝试更新 base
205+
if (cells == null) {
206+
if (casBase(b = base, b + x))
207+
return;
208+
}
209+
// 否则找到线程对应的 Cell 进行更新
210+
Cell c = cells[getProbe() & m];
211+
if (c.cas(v = c.value, v + x))
212+
return;
213+
// 更新失败则扩容或rehash
214+
}
215+
216+
public long sum() {
217+
Cell[] cs = cells;
218+
long sum = base;
219+
if (cs != null) {
220+
for (Cell c : cs)
221+
if (c != null)
222+
sum += c.value;
223+
}
224+
return sum;
225+
}
226+
```
227+
228+
这种设计与 `ConcurrentHashMap``counterCells` 机制完全一致,都是通过**分散热点、化整为零**的思想实现高并发下的性能优化。在累加场景下,`LongAdder` 的性能可比 `AtomicLong` 高出数倍。
229+
150230
### 循环时间长开销大
151231

152232
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

docs/java/concurrent/jmm.md

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,116 @@ happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中
223223

224224
> **指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致** ,所以在多线程下,指令重排序可能会导致一些问题。
225225
226-
在 Java 中,`volatile` 关键字可以禁止指令进行重排序优化。
226+
在 Java 中,`volatile` 关键字可以禁止指令进行重排序优化。
227+
228+
### volatile 的内存屏障机制
229+
230+
volatile 保证可见性和有序性的底层实现依赖于**内存屏障**。JMM 会在 volatile 变量的读写操作前后插入特定的内存屏障,禁止特定类型的重排序。
231+
232+
**内存屏障的四种类型:**
233+
234+
1. **LoadLoad 屏障**: 确保屏障前的读操作先于屏障后的读操作
235+
2. **StoreStore 屏障**: 确保屏障前的写操作先于屏障后的写操作
236+
3. **LoadStore 屏障**: 确保屏障前的读操作先于屏障后的写操作
237+
4. **StoreLoad 屏障**: 确保屏障前的写操作先于屏障后的读操作(开销最大)
238+
239+
**volatile 写操作的内存屏障插入策略:**
240+
241+
```
242+
StoreStore 屏障
243+
volatile 写操作
244+
StoreLoad 屏障
245+
```
246+
247+
这保证了:volatile 写之前的所有普通写不会被重排序到 volatile 写之后,且 volatile 写的结果立即对其他线程可见。
248+
249+
**volatile 读操作的内存屏障插入策略:**
250+
251+
```
252+
volatile 读操作
253+
LoadLoad 屏障
254+
LoadStore 屏障
255+
```
256+
257+
这保证了:volatile 读之后的所有普通读写不会被重排序到 volatile 读之前。
258+
259+
### volatile 与 happens-before 的关系
260+
261+
volatile 变量规则是 happens-before 原则的重要体现。根据《深入理解 Java 虚拟机》(周志明著)的描述,volatile 变量的内存语义可以总结为:
262+
263+
- **写操作**: 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新到主内存
264+
- **读操作**: 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,直接从主内存中读取共享变量
265+
266+
通过这种机制,volatile 确保了线程间的可见性传递:
267+
268+
```java
269+
class VolatileExample {
270+
int a = 0;
271+
volatile boolean flag = false;
272+
273+
public void writer() {
274+
a = 1; // 1
275+
flag = true; // 2 volatile写
276+
}
277+
278+
public void reader() {
279+
if (flag) { // 3 volatile读
280+
int i = a; // 4 一定能看到a=1
281+
}
282+
}
283+
}
284+
```
285+
286+
根据 happens-before 规则:
287+
- 1 happens-before 2 (程序顺序规则)
288+
- 2 happens-before 3 (volatile 规则)
289+
- 3 happens-before 4 (程序顺序规则)
290+
- 因此 1 happens-before 4 (传递性),操作 4 一定能看到操作 1 的结果
291+
292+
### volatile 与 synchronized 的性能对比
293+
294+
在《深入理解 Java 虚拟机》第三版中,周志明指出 volatile 的性能优势主要体现在:
295+
296+
1. **无锁机制**: volatile 不需要加锁,不会阻塞线程,而 synchronized 会导致线程上下文切换
297+
2. **轻量级同步**: volatile 只保证单个变量的可见性,synchronized 保证整个代码块的原子性
298+
3. **性能差异**: 在高竞争场景下,volatile 读写操作比 synchronized 快约 10 倍以上
299+
300+
**适用场景对比:**
301+
302+
- **使用 volatile**: 单个变量的状态标志、双重检查锁定中的对象引用
303+
- **使用 synchronized**: 需要原子性保证的复合操作(如 i++)
304+
305+
### Double-Checked Locking(DCL) 的 volatile 应用
306+
307+
DCL 单例模式是 volatile 最经典的应用场景,必须用 volatile 修饰实例变量:
308+
309+
```java
310+
public class Singleton {
311+
private volatile static Singleton instance;
312+
313+
private Singleton() {}
314+
315+
public static Singleton getInstance() {
316+
if (instance == null) { // 第一次检查
317+
synchronized (Singleton.class) {
318+
if (instance == null) { // 第二次检查
319+
instance = new Singleton();
320+
}
321+
}
322+
}
323+
return instance;
324+
}
325+
}
326+
```
327+
328+
**为什么必须用 volatile?**
329+
330+
`instance = new Singleton()` 在字节码层面分为三步:
331+
1. 分配内存空间
332+
2. 初始化对象
333+
3. 将 instance 指向内存地址
334+
335+
如果发生指令重排序(2 和 3 交换),可能导致其他线程获取到未初始化完成的对象。volatile 禁止这种重排序,保证对象完全初始化后才对其他线程可见。
227336

228337
## 总结
229338

docs/java/concurrent/threadlocal.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,149 @@ new ThreadLocal<>().set(s);
179179

180180
如果我们的**强引用**不存在的话,那么 `key` 就会被回收,也就是会出现我们 `value` 没被回收,`key` 被回收,导致 `value` 永远存在,出现内存泄漏。
181181

182+
### ThreadLocal 内存泄漏的完整分析
183+
184+
#### 为什么 Entry 的 key 要设计成弱引用?
185+
186+
很多人会疑惑,既然弱引用会导致内存泄漏问题,为什么 `ThreadLocalMap` 还要把 key 设计成弱引用呢?直接用强引用不就没有内存泄漏的问题了吗?
187+
188+
**首先要明确一点**:前面提到"在 `ThreadLocal.get()` 操作时,如果外部代码还持有 ThreadLocal 的强引用(比如 `static ThreadLocal<User> holder`),那么 key 不会为 null"。但问题在于,如果外部的 ThreadLocal 强引用**被清除或失效**(比如对象被回收、类被卸载),就会出现内存泄漏问题。
189+
190+
这是一个**权衡之后的设计决策**,我们来分析一下两种设计的后果:
191+
192+
**如果 key 使用强引用**
193+
- 当外部不再持有 `ThreadLocal` 对象的强引用时(比如业务对象被回收),`ThreadLocalMap` 中的 key 仍然持有对 `ThreadLocal` 对象的强引用
194+
- 这会导致 `ThreadLocal` 对象无法被 GC 回收,`Entry` 对象也无法被回收
195+
- 更严重的是,这种情况下完全没有办法发现和清理这些无用的 Entry
196+
- 最终导致的内存泄漏会更加隐蔽和严重
197+
198+
**如果 key 使用弱引用**(当前的设计):
199+
- 当外部不再持有 `ThreadLocal` 对象的强引用时,下次 GC 时 key 会被回收,变成 null
200+
- 虽然此时 value 仍然存在,但是 key 为 nullEntry 是可以被识别和清理的
201+
- `ThreadLocalMap` 在 `set()`、`get()`、`remove()` 操作中都会主动清理 key 为 nullEntry
202+
- 这种设计虽然不能完全避免内存泄漏,但提供了一种**自我修复**的机制
203+
204+
**Doug Lea 的设计智慧**:使用弱引用,虽然不能完全避免内存泄漏,但至少提供了一个"补救"的机会。而使用强引用则会让问题变得无解。
205+
206+
#### 内存泄漏的完整引用链路分析
207+
208+
让我们用一个完整的引用链路图来理解 ThreadLocal 的内存泄漏问题:
209+
210+
```
211+
Thread (强引用)
212+
└─> ThreadLocalMap (强引用)
213+
└─> Entry[] table (强引用)
214+
└─> Entry (强引用)
215+
├─> key: ThreadLocal (弱引用) ← 这里可能被 GC
216+
└─> value: 业务对象 (强引用) ← 这里可能泄漏
217+
```
218+
219+
**正常情况下的引用链**
220+
1. 外部代码持有 ThreadLocal 对象的强引用(比如 `static ThreadLocal<User> threadLocalUser`)
221+
2. Thread 对象持有 ThreadLocalMap 的强引用
222+
3. ThreadLocalMap 持有 Entry 数组的强引用
223+
4. Entry 继承 WeakReference,持有 ThreadLocal 的弱引用
224+
5. Entry 持有 value 的强引用
225+
226+
**发生内存泄漏的场景**
227+
228+
当外部的 ThreadLocal 强引用被置为 null 后(比如 `threadLocalUser = null`):
229+
1. ThreadLocal 对象只剩下 Entry 中的弱引用
230+
2. 下次 GC 时,ThreadLocal 对象被回收,Entry 的 key 变成 null
231+
3. 但是 Entry 对象本身和 value 仍然被强引用,无法被 GC
232+
4. 如果线程是线程池中的线程,一直不结束,那么这些 Entry 和 value 就会一直存在
233+
234+
**举个生动的例子**
235+
236+
想象一个图书馆的借书系统:
237+
- **Thread** 是一个读者
238+
- **ThreadLocalMap** 是这个读者的借书卡
239+
- **Entry** 是借书记录
240+
- **ThreadLocal** 是图书的索引卡片(使用弱引用)
241+
- **value** 是实际的图书(使用强引用)
242+
243+
正常流程:读者(Thread)用索引卡片(ThreadLocal)借了一本书(value),借书卡(ThreadLocalMap)上有记录(Entry)。
244+
245+
如果索引卡片系统升级,旧的索引卡片被销毁了(ThreadLocal 引用置为 null),那么:
246+
- 使用弱引用设计:下次图书馆清理时(GC),发现借书记录中的索引卡片没了(key 为 null),就知道要处理这条记录
247+
- 使用强引用设计:即使索引卡片系统已经不存在了,借书记录仍然死死抓着旧卡片,无法识别和清理
248+
249+
#### remove() 方法为什么如此重要
250+
251+
理解了内存泄漏的机制后,我们就能明白为什么一定要调用 `remove()` 方法。
252+
253+
`remove()` 方法的作用:
254+
1. 找到当前 ThreadLocal 对应的 Entry
255+
2.Entry 的 key 和 value 都置为 null
256+
3. 触发探测式清理,清除更多的过期 Entry
257+
4. 彻底断开 value 的强引用链,让 GC 可以回收
258+
259+
**阿里巴巴 Java 开发手册的强制要求**
260+
> 必须回收自定义的 ThreadLocal 变量,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的 ThreadLocal 变量,可能会影响后续业务逻辑和造成内存泄漏等问题。尽量在代理中使用 try-finally 块进行回收。
261+
262+
#### 在线程池场景下的特殊风险
263+
264+
线程池场景是 ThreadLocal 内存泄漏的**重灾区**,原因如下:
265+
266+
1. **线程复用**:线程池中的线程会被反复使用,不会销毁
267+
2. **ThreadLocalMap 不会被清理**:由于线程一直存活,ThreadLocalMap 也一直存在
268+
3. **累积效应**:每次使用 ThreadLocal 如果不清理,Entry 就会越积越多
269+
4. **影响后续任务**:下一个使用该线程的任务可能会读取到上一个任务残留的数据,导致业务逻辑错误
270+
271+
**典型的错误案例**(来自阿里技术博客的真实案例):
272+
273+
```java
274+
// 错误示例:在线程池中使用 ThreadLocal 但不清理
275+
private static ThreadLocal<UserInfo> userInfoHolder = new ThreadLocal<>();
276+
277+
public void processRequest(Request request) {
278+
try {
279+
UserInfo userInfo = getUserInfo(request);
280+
userInfoHolder.set(userInfo); // 设置用户信息
281+
// 处理业务逻辑
282+
doBusinessLogic();
283+
} catch (Exception e) {
284+
// 异常处理
285+
}
286+
// ❌ 没有清理 ThreadLocal!
287+
}
288+
```
289+
290+
**正确的做法**
291+
292+
```java
293+
// 正确示例:使用 try-finally 确保清理
294+
private static ThreadLocal<UserInfo> userInfoHolder = new ThreadLocal<>();
295+
296+
public void processRequest(Request request) {
297+
try {
298+
UserInfo userInfo = getUserInfo(request);
299+
userInfoHolder.set(userInfo);
300+
doBusinessLogic();
301+
} catch (Exception e) {
302+
// 异常处理
303+
} finally {
304+
// ✅ 无论是否发生异常,都要清理
305+
userInfoHolder.remove();
306+
}
307+
}
308+
```
309+
310+
**美团技术团队的实践经验**
311+
312+
在美团的实际项目中,曾经出现过因为 ThreadLocal 未清理导致的严重生产事故:
313+
1. 在线程池中使用 ThreadLocal 存储用户权限信息
314+
2. 某次请求处理完后,由于异常导致未执行 remove()
315+
3. 该线程被分配给下一个请求时,错误地使用了上一个用户的权限信息
316+
4. 导致越权访问,产生了严重的安全漏洞
317+
318+
修复方案:
319+
1. 强制要求所有 ThreadLocal 使用都必须配合 try-finally + remove()
320+
2. 通过静态代码扫描工具检测未正确清理的 ThreadLocal 使用
321+
3. 在线程池提交任务时增加清理 ThreadLocal 的包装器
322+
323+
这个案例充分说明了在线程池场景下正确使用 ThreadLocal 的重要性。记住:**ThreadLocal 用完一定要 remove(),特别是在线程池环境下!**
324+
182325
### `ThreadLocal.set()`方法源码详解
183326

184327
![](./images/thread-local/6.png)

0 commit comments

Comments
 (0)