跳到主要内容
约 6 分钟2 张卡

线程安全

线程安全是指多个线程同时访问对象时,对象仍能满足约定的正确性要求。本文从丢失更新的例子入手,说明竞态怎样发生,再介绍如何确定同步范围、选择同步方式。

多个线程共享可变状态时,需要约定读写规则。否则,程序会丢失更新、读到不一致状态,或根据过期数据作出决定。

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 压力测试一直通过,能证明线程安全吗?​

9 · 面试题卡片​

本页题目与参考回答

9.1 怎样判断一段代码是否线程安全​

出现公司:拼多多

考察重点

  • 找出哪些状态被多个线程共享,以及哪些访问会修改它们。
  • 说明哪些字段或操作共同维护业务不变量,并据此确定临界区。
  • 区分 volatile 的单次访问保证与完整状态转换的要求。

相关内容:竞态条件至同步策略。

参考回答

判断标准是多个线程访问对象时,所有操作仍能保持业务不变量。

这个判断要覆盖共享状态、原子性和可见性:

  • 共享状态包括多个线程访问的对象,也包括局部引用指向的共享对象。
  • 原子性要求保护完整的状态转换,例如余额检查和扣减,以及多个字段的共同变化。
  • 可见性要求所有访问遵守同一同步规则,并能用 happens-before 证明写入对读者可见。

状态只在任务内使用、正确发布后保持不可变,或由单一线程负责时,可以减少加锁需求。需要共享修改时,再按不变量选择锁、原子操作或并发容器。压力测试用于寻找反例,正确性仍需上述推导。

9.2 多个线程安全方法组合后,整体一定线程安全吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明单个方法为什么安全,以及多个调用之间的空隙怎样破坏业务条件。

相关内容:线程安全操作的组合。

参考回答

多个线程安全方法组合后,整体仍可能存在竞态。

比如,两个线程都通过 containsKey 检查,再分别执行 put,后一次写入就会覆盖前一次结果。

若要求首次写入生效,应使用 putIfAbsent,将检查与插入合成一次原子操作。如果业务条件跨越多个键,则还要为这些键设计共同的同步规则。

10. 参考资料​