并发内功-代码线程安全分析步骤三之“Operation 查操作”
[并发内功] 代码线程安全分析步骤三之“Operation 查操作”
前言
绝大多数并发 Bug 的根源,不在于变量本身,而在于我们**“想当然”**地认为某些操作是瞬间完成的。
你写了一行代码 count++,觉得它是一气呵成的。但在 CPU 眼里,那是三条指令。当线程在这些指令的缝隙中切换时,灾难就发生了。
Step 3: Operation 专注于审视代码的逻辑动作,寻找**“竞态条件”(Race Condition)**。
⚡️ 精华速查版:操作原子性红黑榜
审查代码时,盯着那些针对共享变量的读写操作。不要被代码的行数迷惑,要看语义。
| 🕵️♂️ 操作类型 | 代码示例 | 判定结果 | 理由 |
|---|---|---|---|
| 🟢 单一读/写 | flag = true; return x; |
✅ 通常安全 | 基础类型的赋值和读取(非 long/double 64位机)通常是原子的。配合 volatile 可保安全。 |
| 🟢 原子类方法 | atomic.incrementAndGet(); |
✅ 安全 | JDK 底层 CAS 保证原子性。 |
| 🔴 读-改-写 (RMW) | i++; i = i + 1; |
❌ 竞态条件 | 经典三步曲:读旧值 -> 改值 -> 写回。中间会被打断。 |
| 🔴 先检查后执行 (Check-Then-Act) | if (map.containsKey(k)) { ... } if (obj == null) { ... } |
❌ 竞态条件 | 时间差攻击。检查通过的那一瞬间,条件可能已经被别的线程改了。 |
| 🔴 容器组合操作 | if (!concurrentMap.containsKey(k)) { concurrentMap.put(k, v); } |
❌ 伪安全 | 容器本身的方法是安全的,但两个方法之间的缝隙是不安全的。 |
🧠 核心原理:原子性与竞态条件
竞态条件 (Race Condition):程序的输出依赖于线程执行的具体时序。
原子性 (Atomicity):一个操作不可分割。要么全做,要么全不做。
公式回顾:
$$\text{不安全操作} = \text{多步操作} - \text{同步保护}$$
⚔️ 实战案例深度剖析
案例一:经典的“读-改-写”陷阱
场景:统计网站访问量。很多新手以为加个 volatile 就能解决 count++ 问题。
1 | public class Counter { |
👀 Operation 审查:
- 动作:
count++。 - 微观视角:
LOAD count(从内存读到寄存器,假设读到 10)ADD 1(寄存器内加 1,变为 11)STORE count(写回内存)
- 危机:线程 A 执行完第 2 步被挂起。线程 B 进来读到 10,加到 11,写回。线程 A 醒来,也写回 11。明明加了两次,结果只加了 1。
- 结论:❌ 非原子操作。
- 修正:使用
AtomicInteger或synchronized。
案例二:隐蔽的“先检查后执行” (Check-Then-Act)
场景:单例模式的懒加载,或者业务逻辑中的条件判断。
1 | public class LazyInit { |
👀 Operation 审查:
- 动作:
if (instance == null) ... instance = ... - 危机:
- 线程 A 检查
instance为 null。 - (切换) 线程 B 检查
instance也为 null。 - 线程 B 创建对象
Obj1,赋值给instance。 - (切换) 线程 A 继续执行(因为它记得刚才检查结果是 null),创建对象
Obj2,覆盖了instance。
- 线程 A 检查
- 结论:❌ 竞态条件。这是典型的 TOCTOU (Time Of Check to Time Of Use) 漏洞。
案例三:并发容器的“组合拳”失效
场景:这是高手也容易犯的错。使用了线程安全的 ConcurrentHashMap,就以为高枕无忧了。
1 | public class Dictionary { |
👀 Operation 审查:
- 动作:
get->check->put。 - 分析:
wordCounts.get()是线程安全的(原子的)。wordCounts.put()是线程安全的(原子的)。- 但是! 两个操作合在一起,就不是原子的。
- 危机:线程 A 发现 “Java” 不在,准备 put 1。此时线程 B 也发现 “Java” 不在,也 put 1。结果 “Java” 的计数是 1,而不是 2。
- 结论:❌ 组合操作不安全。
- 修正:使用并发容器提供的原子组合方法,如
putIfAbsent或compute。1
2// 正确写法:利用 compute 原子指令
wordCounts.compute(word, (k, v) -> (v == null) ? 1 : v + 1);
案例四:不安全的控制流
场景:使用一个布尔标记来控制初始化。
1 | public class Initializer { |
👀 Operation 审查:
- 动作:
check flag->act->set flag。 - 分析:两个线程可能同时通过
if (initialized)的检查,然后都去执行doHeavyInit()。这可能导致资源重复加载甚至严重错误。 - 结论:❌ 竞态条件。
boolean标记的读写虽然是原子的,但逻辑流不是。 - 修正:使用
AtomicBoolean.compareAndSet(false, true)。
🎯 总结与下一步
Step 3: Operation 告诉我们:永远不要相信两行代码之间没有空隙。
当你看到代码中有以下结构时,警报必须拉响:
- Read-Modify-Write: 像
i++这种累加操作。 - Check-Then-Act:
if (x == null) init();这种条件初始化。 - Composite Actions:
map.get后再map.put,即使是 ConcurrentMap 也不行。
到了这一步,我们已经完整确诊了病情:
- Step 1: 发现了病毒(共享变量)。
- Step 2: 确认病毒已扩散(变量逃逸)。
- Step 3: 观察到病毒在攻击细胞(非原子操作导致数据损坏)。
接下来,就是治病救人的时刻了。我们必须施加手段来保护这些脆弱的代码。
这就引出了 S.E.O.P 的最后一环:Step 4: Protection (验防护)。
- 用
synchronized还是Lock? - 锁对象是
this还是class? - 为什么我的锁加了还是没用(锁的覆盖范围不对)?
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 技术博客!


