1. 认识竞态条件(race condition)
让我们回顾一下什么是竞态条件(race condition)——当程序的运行结果取决于线程获取共享数据或资源的先后顺序时。如果执行顺序发生变化,结果就会变得不可预测。这就像你和朋友同时编辑同一个文档:谁打字更快,谁就覆盖对方,最终的文本可能非常奇怪。
在 Java(以及任何支持多线程的语言)中,race condition 出现在多个线程同时读取和/或修改同一个变量而没有适当同步的时候。
为什么会发生 race condition?
Java 线程并行运行。如果两个线程同时访问同一个变量(例如递增一个共享计数器),它们可能会相互“打断”。即使某个操作看起来是原子的(例如 counter++),实际上并不是!
如何工作 counter++?
自增操作包含几个步骤:
- 从内存中读取变量的当前值。
- 将该值加一。
- 把新值写回内存。
如果此时另一个线程也执行 counter++,两个线程都可能读到相同的值,都把它加一,并都写回相同的结果——最终会有一次自增“丢失”。
2. 竞态条件示例:递增计数器
我们来写一个简单的程序,启动若干线程,每个线程都把共享计数器加上 1。按理说,如果我们启动了 1000 个线程,计数器的最终值应该是 1000。来验证一下!
public class RaceConditionDemo {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
int threads = 1000;
Thread[] threadArray = new Thread[threads];
for (int i = 0; i < threads; i++) {
threadArray[i] = new Thread(() -> {
counter++; // 危险的操作!
});
threadArray[i].start();
}
// 等待所有线程结束
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("预期:" + threads);
System.out.println("实际:" + counter);
}
}
预期输出:
预期:1000
实际:843
每次运行的值都可能不同:有时是 900,有时是 700,有时也可能是 1000——但非常少见。
为什么会这样?
多个线程同时读取 counter 的值,对其加一并写回。如果两个线程读取了相同的值、都加一并都写回——就会丢失一次自增。结果最终值总是小于预期。
3. 另一个示例:没有同步的银行账户
设想我们有一个银行账户,两个线程同时取钱。
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// 模拟耗时操作
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
public class BankDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(100));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("预期:0 或 100");
System.out.println("实际余额:" + account.getBalance());
}
}
有时两个线程都会看到账户里有 100,并且都去取款。结果余额会变成 -100!(现实中不会这样,但在代码里——非常容易发生。)
4. 有用的细节
race condition 的后果
竞态条件并不只是“奇怪”的结果。它对程序员来说是真正的头痛,因为:
- 错误并不总是出现。 有时程序运行正确,有时却不对。一切取决于线程执行操作的时机。
- 测试并不保证成功。 你可以反复运行很多次——看起来一切正常,但某次却会突然出错。
- 错误很难捕获。 行为取决于处理器速度、系统负载、其他正在运行的程序等。
- 可能导致严重故障: 数据丢失、计算不正确、应用崩溃等。
真实示例
- 金融应用: 余额计算错误、重复扣款。
- 服务器: 消息丢失、请求处理不正确。
- 游戏: 角色“瞬移”、积分计算错误。
为什么测试无法拯救你免于 race condition?
race condition 是一种典型的 “Heisenbug”(当你尝试抓住它时就会消失的 bug)。即使你把测试运行上千次都没看到错误——也不代表它不存在!一切取决于操作系统如何调度线程。有时一切顺利,有时线程会“相撞”,问题就出现了。
如何避免 race condition?
- 同步: 使用关键字 synchronized 来修饰方法或代码块,使得任一时刻只有一个线程可以修改共享数据。
- 原子操作: 使用 java.util.concurrent.atomic 包中的类(例如 AtomicInteger),它们在不显式同步的情况下提供安全的操作。
- 不可变性: 如果对象不可修改,就不存在 race condition。
带同步的示例
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
现在,如果多个线程调用 increment(),任一时刻只有一个线程能够执行该方法。
5. 在多线程中处理共享变量的常见错误
错误 №1:对简单操作的天真自信。
很多人以为 counter++ 是一个操作,不会出问题。实际上,这是三个操作,其他线程可以在它们之间“插队”。
错误 №2:使用普通变量在线程之间交换数据。
如果多个线程在没有同步的情况下读写同一个变量——你好,race condition!
错误 №3:以为错误总会重现。
race condition 可能只在某些时候出现,这使它格外狡猾。不要指望测试里一直正常就万事大吉。
错误 №4:操作集合时忽视同步。
像 ArrayList 这样的普通集合并不是线程安全的。如果多个线程同时添加或删除元素——可能会出现故障,甚至导致程序崩溃。
错误 №5:试图用延迟来“修复” race condition。
例如通过 Thread.sleep(10) 或其他“魔法”暂停。这样的做法并不能解决问题,只是掩盖它。真正的解决方案是同步或原子操作。
GO TO FULL VERSION