[并发内功] 代码线程安全分析四个步骤整合版

前言

很多初学者在审查代码(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)) 时间差攻击。检查通过的那一瞬,条件可能已变。
🔴 危险 并发容器组合操作 即使是 ConcurrentHashMapgetput 之间的缝隙也不安全。

4. Protection (验防护) —— 锁加对了吗?

🚦 等级 典型特征 判定口诀
🟢 有效 synchronized(this) 锁实例变量 门当户对。
🔴 无效 synchronized(this)static 变量 锁自家门,管广场人。实例锁管不住类变量。
🔴 无效 synchronized(new Object()) 一次性锁。每人一把新锁,根本没排队。
🔴 无效 写加锁,读不加锁 导致脏读(读取到修改了一半的数据)。

🔍 第二部分:深度实战解析

Step 1: State (找状态) —— 锁定嫌疑人

核心公式:$\text{线程不安全} = \text{共享资源} + \text{可变性} + \text{并发访问}$

这一步的目标是识别出“共享且可变”的变量。

❌ 典型反例:单例下的实例变量

这是 Web 开发中最隐蔽、最致命的错误。

1
2
3
4
5
6
7
8
9
10
11
12
@Service // 默认是单例
public class ReportService {
// !!!致命错误!!!
// 开发者想暂存一下当前的 ID
private int processId = 0;

public void generate(String orderId) {
// 线程 A 改成了 100,还没打印,线程 B 把它改成了 200
this.processId = Integer.parseInt(orderId);
System.out.println("Processing: " + this.processId);
}
}
  • 分析:Spring Bean 是单例的,所有请求共享这一个 processId
  • 修正立刻移入方法内,变为局部变量(栈封闭)。

⚠️ 隐蔽陷阱:Final 的骗局

1
2
3
4
5
6
7
8
9
public class TeamConfig {
// !!!陷阱!!!
// final 只锁住了引用,没锁住 ArrayList 内部的数据
private final List<String> members = new ArrayList<>();

public void add(String name) {
members.add(name); // ArrayList 线程不安全,这里会报错或丢数据
}
}
  • 分析final 只能保证你不能把 members 指向另一个 List,但不能阻止你修改 List 的内容。

Step 2: Escape (看范围) —— 确认越狱

如果在 Step 1 中发现了可变变量,不要慌。如果它没有逃出当前线程的手掌心,它依然是安全的。

✅ 正例:栈封闭 (Stack Confinement)

1
2
3
4
5
6
7
public void process() {
// 即使 HashMap 本身不安全
// 但它是局部变量,且没有 return 出去,也没有传给其他线程
Map<String, Object> map = new HashMap<>();
map.put("data", "secure");
// 方法结束,map 销毁。绝对安全。
}

❌ 反例:返回值逃逸

1
2
3
4
5
6
7
8
public class Cache {
private final Map<String, User> map = new HashMap<>();

// !!!把私有变量交出去了!!!
public Map<String, User> getAll() {
return map;
}
}
  • 分析:一旦 return map,外部线程就可以在任何地方调用 map.clear()。封装彻底失效。
  • 修正:返回防御性副本 return new HashMap<>(map);

Step 3: Operation (查操作) —— 还原案发现场

变量共享了,也逃逸了。现在要看我们是怎么操作它的。永远不要相信两行代码之间没有空隙。

❌ 典型反例:读-改-写 (Read-Modify-Write)

1
2
3
4
5
6
7
private volatile int count = 0;

public void add() {
// volatile 只能保证可见性,不能保证原子性
// 这一行包含:读 -> 加 -> 写。
count++;
}

❌ 典型反例:并发容器的组合操作失效

即使使用了 ConcurrentHashMap,如果操作不是原子的,依然会出 Bug。

1
2
3
4
5
6
7
8
9
// 错误示范
if (!concurrentMap.containsKey(key)) {
// 线程 A 和 B 可能同时通过上面的检查
// 然后同时执行 put,导致覆盖或计数错误
concurrentMap.put(key, value);
}

// 正确示范:使用原子组合方法
concurrentMap.putIfAbsent(key, value);

Step 4: Protection (验防护) —— 检查盾牌

最后一步,如果你看到了 synchronized 或锁,千万别掉以轻心。错误的锁比没锁更可怕,因为它会给你虚假的安全感。

❌ 典型反例:锁错对象 (Lock Identity)

1
2
3
4
5
6
7
8
9
public class GlobalCounter {
// 静态变量:属于 Class
private static int count = 0;

// 错误:锁的是 this (实例对象)
public synchronized void add() {
count++;
}
}
  • 分析count 是全局唯一的。但 synchronized 锁的是 new 出来的实例。线程 A 锁 ObjectA,线程 B 锁 ObjectB,两人互不干扰,依然会并发修改 count
  • 修正static synchronizedsynchronized(GlobalCounter.class)

❌ 典型反例:半边锁 (Lock Scope)

1
2
3
4
5
6
7
8
9
public class Account {
private double balance;

// 写加锁了
public synchronized void set(double val) { this.balance = val; }

// 读没加锁!
public double get() { return this.balance; }
}
  • 分析:读操作没有加锁,会导致可见性问题(读到旧值)或原子性问题(在 32 位机器上读到 long/double 的半个值)。
  • 原则:对同一个变量的读和写,必须持有同一把锁。

🏆 总结

并发编程没有玄学,只有逻辑。当你面对一段复杂的 Java 代码时,运用 S.E.O.P 模型 进行扫描:

  1. State: 它是共享且可变的吗?(Spring 单例成员变量、Static)
  2. Escape: 它逃出去了吗?(Return, Params)
  3. Operation: 操作是原子的吗?(++ , Check-Then-Act)
  4. Protection: 锁的对象和范围对吗?

只有这四步都经得起推敲,你的代码才是真正健壮的。