线程安全
线程安全是指多个线程同时访问对象时,对象仍能满足约定的正确性要求。本文从丢失更新的例子入手,说明竞态怎样发生,再介绍如何确定同步范围、选择同步方式。
多个线程共享可变状态时,需要约定读写规则。否则,程序会丢失更新、读到不一致状态,或根据过期数据作出决定。
1. 面试核心
先记住这几条,再读正文- 共享访问规则。 多个线程访问同一份可变状态,且至少一个线程写入时,各条访问路径需要遵守同一套同步规则。
- 复合操作。
count++包含读、计算和写,并发交错会丢失更新。 - volatile 的边界。
volatile保证单次读写的可见性和顺序,复合更新的原子性需要锁或原子操作保证。 - 临界区范围。 临界区应覆盖维护业务不变量的完整过程,包括条件检查和状态更新。卡 · 竞态条件
- 组合操作。 多个线程安全操作的组合仍可存在竞态。卡 · 线程安全操作的组合
2. 竞态条件
竞态条件是指程序是否正确取决于多个线程的执行先后顺序。下面的计数器中,一次递增就会与另一次递增相互干扰:
public final class Counter {
private int value;
public void increment() {
value++;
}
public int get() {
return value;
}
}
value++ 要读取旧值、计算新值,再写回。两个线程都读到 10 时,就会各自写入 11。这样,两次递增最终只增加一次,称为丢失更新。
这个结果取决于线程怎样交错执行,所以属于竞态条件。两个线程访问同一变量,且至少一个线程写入时,这两个访问就相互冲突。如果两个普通内存访问之间没有 happens-before 顺序,还构成数据竞争。happens-before 是 Java 内存模型(JMM)定义的顺序关系,用来约束跨线程的可见性。
3. 原子性、可见性与有序性
分析并发错误时,要区分原子性、可见性和有序性。它们分别关注操作能否被穿插、写入能否被看见,以及读写应满足什么顺序:
| 性质 | 关注点 | 典型问题 |
|---|---|---|
| 原子性 | 操作能否作为整体生效 | 检查与更新被穿插 |
| 可见性 | 写入何时对另一线程可见 | 停止标志读取过期 |
| 有序性 | 跨线程观察应满足的先后约束 | 发布引用与数据写入缺少顺序 |
例如,发布对象引用时,需要先完成对象数据的写入,再让读者取得引用。否则,读者拿到引用时,字段仍缺少正确发布的保证。
volatile 为单次访问建立可见性与顺序保证,但复合操作仍可被其他线程穿插。示例推演见 volatile。
4. 不变量与临界区
确定加锁范围前,要先写出业务不变量,也就是每次操作前后都应成立的条件。例如,扣款操作要求账户余额保持非负:
if (balance >= amount) {
balance -= amount;
}
假设 amount > 0,业务要求余额保持非负。检查和扣减共同维护该条件。若两个线程同时通过检查,合计扣减就可超过余额。
为了保持余额非负,锁应覆盖检查与扣减的完整过程。这个受保护的代码范围称为临界区。只把扣减换成原子操作,检查与扣减之间仍有空隙。
也可以用带条件的 CAS 循环完成整个更新。CAS 会比较当前值与预期值,相同时才更新;失败后重新读取余额并检查条件。
多个字段也会共同维护不变量。例如,lower <= upper 要求上下界始终匹配。两个独立的 volatile 字段只保证各自可见,因此需要整体更新它们。
5. 同步策略
选择同步方式时,先看状态是否必须共享和修改。常用的四种策略如下:
- 不共享:把局部状态留在任务内,或只交给一个线程访问。这种限制访问线程的方式称为线程封闭。
- 不可变:对象或快照在正确发布后保持内容不变,多个线程可以共同读取。判断是否不可变时,还要检查字段引用的对象是否会变化。
- 单一所有者:由一个线程修改状态,其他线程通过消息提交操作。
- 受控共享:多个线程需要直接修改状态时,用锁、原子操作或并发容器保护它。
工具要与状态的变化方式对应。例如,独立计数适合 AtomicLong,多个字段的整体变更适合用锁。持续处理事件时,则可用队列把事件交给单一消费者。
6. synchronized 状态转换
下面用 synchronized 修复前面的计数器。读方法和写方法都获取同一个对象的锁,让递增完整执行,也让读者看到已完成的更新。
public final class Counter {
private int value;
public synchronized void increment() {
value++;
}
public synchronized int get() {
return value;
}
}
示例的两个实例方法使用同一个对象的 monitor,也就是对象监视器锁。互斥保证同一时刻只有一个线程执行受保护的方法,所以递增过程中不会插入另一次递增。
一次解锁之前的写入,对随后获取同一把锁的线程可见。因此,get() 也要加锁,其他访问 value 的路径同样要遵守这套规则。
机制细节见 synchronized。
7. 线程安全操作的组合
使用线程安全容器时,还要检查多个方法之间的空隙。并发 Map 的单次操作线程安全,但下面的检查与插入是两次独立调用:
// 写法一:分别检查和插入。
if (!map.containsKey(key)) {
map.put(key, value);
}
// 写法二:用原子接口替代上面的组合。
Value previous = map.putIfAbsent(key, value);
两种写法的区别在于检查与插入是否原子地完成:
- 第一种写法中,两个线程可同时通过检查,再分别执行
put,后一次写入会覆盖前一次结果。 - 第二种写法使用
putIfAbsent,原子地完成检查与插入。返回非 null 表示已有值,本次没有覆盖它。
若要求首次写入生效,可以采用第二种写法。如果业务条件涉及多个键或多个对象,还需要共同保护整个操作。
即使单次操作内部都使用锁,组合竞态仍可存在。数据竞争与业务竞态因此有不同的判断范围。
8. 常见问题
8.1 方法里只有局部变量就一定线程安全吗?
局部变量也可以指向共享对象,所以仍要检查它引用的对象。保存引用的变量属于当前方法调用,但列表本身可被多个线程访问。因此,通过局部引用调用共享列表的 add 时,仍需同步。
8.2 压力测试一直通过,能证明线程安全吗?
压力测试只能发现已经执行到的错误,无法覆盖所有线程交错。JIT 编译优化、线程调度和硬件都会改变执行方式。所以,测试通过后,仍要根据业务不变量和同步关系推导正确性。
9 · 面试题卡片
本页题目与参考回答
9.1 怎样判断一段代码是否线程安全
出现公司:拼多多
考察重点
- 找出哪些状态被多个线程共享,以及哪些访问会修改它们。
- 说明哪些字段或操作共同维护业务不变量,并据此确定临界区。
- 区分
volatile的单次访问保证与完整状态转换的要求。
参考回答
判断标准是多个线程访问对象时,所有操作仍能保持业务不变量。
这个判断要覆盖共享状态、原子性和可见性:
- 共享状态包括多个线程访问的对象,也包括局部引用指向的共享对象。
- 原子性要求保护完整的状态转换,例如余额检查和扣减,以及多个字段的共同变化。
- 可见性要求所有访问遵守同一同步规则,并能用 happens-before 证明写入对读者可见。
状态只在任务内使用、正确发布后保持不可变,或由单一线程负责时,可以减少加锁需求。需要共享修改时,再按不变量选择锁、原子操作或并发容器。压力测试用于寻找反例,正确性仍需上述推导。
9.2 多个线程安全方法组合后,整体一定线程安全吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明单个方法为什么安全,以及多个调用之间的空隙怎样破坏业务条件。
相关内容:线程安全操作的组合。
参考回答
多个线程安全方法组合后,整体仍可能存在竞态。
比如,两个线程都通过 containsKey 检查,再分别执行 put,后一次写入就会覆盖前一次结果。
若要求首次写入生效,应使用 putIfAbsent,将检查与插入合成一次原子操作。如果业务条件跨越多个键,则还要为这些键设计共同的同步规则。