并发内功-代码线程安全分析四个步骤整合版
[并发内功] 代码线程安全分析四个步骤整合版
前言
很多初学者在审查代码(Code Review)时,判断线程安全全靠“感觉”,或者认为只要没有报错就是安全的。
其实,线程安全问题的根源往往隐藏在变量的定义、初始化的顺序以及对方法的调用方式中。要看穿这些隐患,你需要一套系统的审查方法论。
本文将隆重介绍 S.E.O.P 模型 —— 从 State (状态)、Escape (逃逸)、Operation (操作)、Protection (防护) 四个维度,带你建立一眼看穿并发 Bug 的能力。
⚡️ 第一部分:S.E.O.P 极速速查表 (Cheat Sheet)
在深入细节前,请死死记住这张表。当你 Review 代码时,按此顺序逐一排查。
1. State (找状态) —— 哪里有鬼?
| 🚦 等级 | 典型特征 | 判定口诀 |
|---|---|---|
| 🟢 安全 | 局部变量 (方法内定义) | 只要不传出去,天生线程隔离(栈封闭)。 |
| 🟢 安全 | 不可变基础类型 (final int) |
不能改只能读,随便并发。 |
| 🟡 隐患 | Final 引用类型 (final List) |
骗局! 引用没变,但 List 里面的数据能变。 |
| 🔴 高危 | 单例中的实例变量 (Spring @Service) |
必死无疑! 所有请求共享同一个变量,数据必串台。 |
| 🔴 高危 | Static 静态变量 (static Map) |
核武级污染! 全 JVM 共享,不加锁就是裸奔。 |
2. Escape (看范围) —— 鬼跑出去了吗?
| 🚦 等级 | 典型特征 | 判定口诀 |
|---|---|---|
| 🟢 安全 | 方法内自生自灭 | New 出来用完就扔,没 return,没赋值给成员变量。 |
| 🟢 安全 | ThreadLocal | 虽然是 static,但每个线程只能看到自己的副本。 |
| 🔴 危险 | 作为返回值 Return | 只要 return 出去,外部线程就能随意修改它。 |
| 🔴 危险 | 构造函数中暴露 this |
对象还没初始化完就被别的线程拿去用了(半成品)。 |
3. Operation (查操作) —— 动作是原子的吗?
| 🚦 等级 | 典型特征 | 判定口诀 |
|---|---|---|
| 🟢 安全 | 原子类 (AtomicInteger) |
CAS 机制保证安全。 |
| 🔴 危险 | 读-改-写 (i++) |
三步操作(读、改、写),中间会被打断。 |
| 🔴 危险 | 先检查后执行 (if (map.containsKey)) |
时间差攻击。检查通过的那一瞬,条件可能已变。 |
| 🔴 危险 | 并发容器组合操作 | 即使是 ConcurrentHashMap,get 和 put 之间的缝隙也不安全。 |
4. Protection (验防护) —— 锁加对了吗?
| 🚦 等级 | 典型特征 | 判定口诀 |
|---|---|---|
| 🟢 有效 | synchronized(this) 锁实例变量 |
门当户对。 |
| 🔴 无效 | synchronized(this) 锁 static 变量 |
锁自家门,管广场人。实例锁管不住类变量。 |
| 🔴 无效 | synchronized(new Object()) |
一次性锁。每人一把新锁,根本没排队。 |
| 🔴 无效 | 写加锁,读不加锁 | 导致脏读(读取到修改了一半的数据)。 |
🔍 第二部分:深度实战解析
Step 1: State (找状态) —— 锁定嫌疑人
核心公式:$\text{线程不安全} = \text{共享资源} + \text{可变性} + \text{并发访问}$
这一步的目标是识别出“共享且可变”的变量。
❌ 典型反例:单例下的实例变量
这是 Web 开发中最隐蔽、最致命的错误。
1 | // 默认是单例 |
- 分析:Spring Bean 是单例的,所有请求共享这一个
processId。 - 修正:立刻移入方法内,变为局部变量(栈封闭)。
⚠️ 隐蔽陷阱:Final 的骗局
1 | public class TeamConfig { |
- 分析:
final只能保证你不能把members指向另一个 List,但不能阻止你修改 List 的内容。
Step 2: Escape (看范围) —— 确认越狱
如果在 Step 1 中发现了可变变量,不要慌。如果它没有逃出当前线程的手掌心,它依然是安全的。
✅ 正例:栈封闭 (Stack Confinement)
1 | public void process() { |
❌ 反例:返回值逃逸
1 | public class Cache { |
- 分析:一旦
return map,外部线程就可以在任何地方调用map.clear()。封装彻底失效。 - 修正:返回防御性副本
return new HashMap<>(map);。
Step 3: Operation (查操作) —— 还原案发现场
变量共享了,也逃逸了。现在要看我们是怎么操作它的。永远不要相信两行代码之间没有空隙。
❌ 典型反例:读-改-写 (Read-Modify-Write)
1 | private volatile int count = 0; |
❌ 典型反例:并发容器的组合操作失效
即使使用了 ConcurrentHashMap,如果操作不是原子的,依然会出 Bug。
1 | // 错误示范 |
Step 4: Protection (验防护) —— 检查盾牌
最后一步,如果你看到了 synchronized 或锁,千万别掉以轻心。错误的锁比没锁更可怕,因为它会给你虚假的安全感。
❌ 典型反例:锁错对象 (Lock Identity)
1 | public class GlobalCounter { |
- 分析:
count是全局唯一的。但synchronized锁的是new出来的实例。线程 A 锁ObjectA,线程 B 锁ObjectB,两人互不干扰,依然会并发修改count。 - 修正:
static synchronized或synchronized(GlobalCounter.class)。
❌ 典型反例:半边锁 (Lock Scope)
1 | public class Account { |
- 分析:读操作没有加锁,会导致可见性问题(读到旧值)或原子性问题(在 32 位机器上读到 long/double 的半个值)。
- 原则:对同一个变量的读和写,必须持有同一把锁。
🏆 总结
并发编程没有玄学,只有逻辑。当你面对一段复杂的 Java 代码时,运用 S.E.O.P 模型 进行扫描:
- State: 它是共享且可变的吗?(Spring 单例成员变量、Static)
- Escape: 它逃出去了吗?(Return, Params)
- Operation: 操作是原子的吗?(++ , Check-Then-Act)
- Protection: 锁的对象和范围对吗?
只有这四步都经得起推敲,你的代码才是真正健壮的。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 技术博客!


