CodeGym /课程 /JAVA 25 SELF /竞态条件(race condition)

竞态条件(race condition)

JAVA 25 SELF
第 51 级 , 课程 4
可用

1. 认识竞态条件(race condition)

让我们回顾一下什么是竞态条件(race condition)——当程序的运行结果取决于线程获取共享数据或资源的先后顺序时。如果执行顺序发生变化,结果就会变得不可预测。这就像你和朋友同时编辑同一个文档:谁打字更快,谁就覆盖对方,最终的文本可能非常奇怪。

在 Java(以及任何支持多线程的语言)中,race condition 出现在多个线程同时读取和/或修改同一个变量而没有适当同步的时候。

为什么会发生 race condition?

Java 线程并行运行。如果两个线程同时访问同一个变量(例如递增一个共享计数器),它们可能会相互“打断”。即使某个操作看起来是原子的(例如 counter++),实际上并不是!

如何工作 counter++?

自增操作包含几个步骤:

  1. 从内存中读取变量的当前值。
  2. 将该值加一。
  3. 把新值写回内存。

如果此时另一个线程也执行 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) 或其他“魔法”暂停。这样的做法并不能解决问题,只是掩盖它。真正的解决方案是同步或原子操作。

1
调查/小测验
多线程第 51 级,课程 4
不可用
多线程
多线程基础
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION