跳到主要内容

线程安全问题从哪里产生

多个线程访问同一份可变状态,并且至少一个线程会修改它时,程序就需要明确同步规则。线程安全问题通常表现为丢失更新、读到不一致状态,或者根据过期数据作出决定。

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 验证写入能否对读取可见。

如果状态不共享、不可变或只有一个所有者,可以不加锁;需要共享修改时,再根据不变量选择锁、原子操作或并发容器。一次测试没有复现竞态,不能代替这套证明。