线程安全问题从哪里产生
多个线程访问同一份可变状态,并且至少一个线程会修改它时,程序就需要明确同步规则。线程安全问题通常表现为丢失更新、读到不一致状态,或者根据过期数据作出决定。
1. 一条语句也可能包含多个步骤
下面的计数器会丢失更新:
public final class Counter {
private int value;
public void increment() {
value++;
}
public int get() {
return value;
}
}
value++ 至少包含读取旧值、计算新值和写回三个动作。两个线程都读到 10 时,可能分别写回 11,其中一次更新就消失了。
这类结果取决于线程交错顺序,属于竞态条件。如果两个相互冲突的普通内存访问没有被 happens-before 排序,其中至少一个是写入,则还构成 Java 内存模型所说的数据竞争。
2. 原子性、可见性和有序性分别回答什么
| 性质 | 要回答的问题 | 常见错误 |
|---|---|---|
| 原子性 | 一组状态变化能否被其他线程观察到中间结果 | 检查后执行、复合更新被穿插 |
| 可见性 | 一个线程的写入何时必须被另一个线程看见 | 停止标记长期读不到新值 |
| 有序性 | 跨线程观察时,哪些动作必须保持先后关系 | 对象引用已经可见,字段仍未正确发布 |
三者不能互相替代。volatile 可以为单次读写建立可见性和顺序约束,却不能把 value++ 合并成一个不可分割的更新。
3. 不变量决定临界区的范围
假设账户要求余额不能为负:
if (balance >= amount) {
balance -= amount;
}
只把赋值换成原子变量仍然不够,因为“检查余额”和“扣减余额”共同维护一个不变量。同步范围应覆盖整个状态转换,或者将它改写为带条件的 CAS 循环。
当多个字段共同描述一个状态时也一样。例如 lower <= upper 不能只靠两个独立的 volatile 字段保证。
4. 先减少共享,再选择同步工具
常见策略按约束方式可以分为:
- 不共享:局部变量、线程封闭和任务内状态。
- 不修改:不可变对象和不可变快照。
- 单一所有者:一个线程负责修改,其他线程通过消息交互。
- 受控共享:使用锁、原子变量或并发集合维护明确的不变量。
同步工具应服从状态模型。一个计数值适合 AtomicLong;多个字段的事务式变更通常更适合锁;事件流可以交给队列和单一消费者。
5. synchronized 修复的是整条状态转换
public final class Counter {
private int value;
public synchronized void increment() {
value++;
}
public synchronized int get() {
return value;
}
}
同一个 monitor 同时提供互斥和 happens-before:一次解锁之前的动作,对随后成功获取同一 monitor 的线程可见。若写方法加锁而读方法不使用同一同步协议,读线程仍没有相应保证。
6. 常见问题
6.1 方法里只有局部变量就一定线程安全吗
局部变量本身属于一次调用,但它可能引用共享对象。对共享 List 调用 add,风险来自列表,而不是保存引用的局部变量。
6.2 代码在压力测试中一直正确,能证明线程安全吗
不能。线程调度、JIT 优化和硬件都可能改变交错方式。测试可以发现错误,正确性仍要由同步协议和 happens-before 关系证明。
7. 面试题
7.1 怎样判断一段代码是否线程安全
出现公司:拼多多、腾讯
考察重点
- 共享、可变和并发访问三个条件。
- 复合不变量与临界区。
- 不能只按变量是否使用
volatile判断。
相关内容:第 1 节“一条语句也可能包含多个步骤”至第 4 节“先减少共享,再选择同步工具”。
参考回答
先找出可能被多个线程访问的状态,再确认哪些路径会修改它,以及多个字段或多个步骤共同维护什么不变量。然后检查所有访问是否遵守同一套同步协议,并用 happens-before 验证写入能否对读取可见。
如果状态不共享、不可变或只有一个所有者,可以不加锁;需要共享修改时,再根据不变量选择锁、原子操作或并发容器。一次测试没有复现竞态,不能代替这套证明。