竞态条件与临界区:一个经典Bug的解剖

大家好,我是你们的老朋友。今天咱们聊聊多线程里最基础、也最容易踩坑的两个概念——竞态条件和临界区。

说实话,我见过太多项目重构翻车,十有八九都跟这俩兄弟有关。你想想看,代码单线程跑得好好的,一上多线程就出诡异问题。嗯,大概率就是竞态条件在作祟。

什么是竞态条件?

竞态条件,英文叫 Race Condition。说白了就是:多个线程同时访问共享数据,最终结果取决于线程执行的「时序」。

为什么会这样?因为线程调度是不可控的。操作系统想停哪个线程就停哪个,想切哪个就切哪个。你的代码逻辑依赖了「A先执行,B后执行」的假设,但实际运行时,B可能先跑完了。

核心定义:竞态条件 = 共享数据 + 非原子操作 + 多线程交错执行

我在项目中遇到过这样一个场景:一个计数器,两个线程各加100万次,结果不是200万,而是180万。当时排查了整整一天,最后发现就是典型的竞态条件。

临界区:你的代码「雷区」

临界区,就是代码里访问共享资源的那一段。这段代码不能同时被多个线程执行,否则就会出问题。

怎么识别临界区?我个人习惯看三个特征:

  • 有共享变量——多个线程都能读写
  • 操作不是原子的——比如「读-改-写」三步走
  • 执行结果依赖时序——谁先谁后影响最终值

你想想看,如果一段代码里没有共享数据,那它天然就是线程安全的。临界区只存在于「共享数据」的周围。

经典Bug:银行转账

好了,咱们来看一个教科书级的例子——银行转账。这个Bug我当年在金融项目里亲手修过,记忆犹新。

public class BankAccount {
    private int balance;

    public void transfer(BankAccount target, int amount) {
        if (this.balance >= amount) {
            this.balance -= amount;
            target.balance += amount;
        }
    }
}

这段代码看起来没问题吧?单线程下完美运行。但两个线程同时转账呢?

假设账户A有100元,线程1给B转80,线程2给C转70。你期望的结果是A扣完80再扣70,最后A欠50。但实际可能这样:

  1. 线程1读取A余额:100
  2. 线程2读取A余额:100
  3. 线程1判断:100 >= 80,扣款,A余额变20
  4. 线程2判断:100 >= 70,扣款,A余额变30

看到了吗?线程2读到的余额是「脏数据」。它本该看到20,却看到了100。最终A余额是30,而不是-50。银行亏了80块。

注意:这个Bug的根源在于「检查余额」和「扣款」不是原子操作。两个线程的检查动作交错执行了。

如何修复?

修复方案其实不复杂。核心思路就是:让临界区变成原子的。

我常用的方式有几种:

方案 原理 适用场景
synchronized 加锁,同一时刻只有一个线程能进 简单场景,性能要求不高
ReentrantLock 更灵活的锁,支持超时、中断 需要精细控制锁行为
原子类(AtomicInteger) CAS操作,无锁并发 单一变量的原子更新

拿synchronized举个例子:

public synchronized void transfer(BankAccount target, int amount) {
    if (this.balance >= amount) {
        this.balance -= amount;
        target.balance += amount;
    }
}

加上synchronized后,两个线程不会同时执行transfer方法。一个线程执行完,另一个才能进。竞态条件就消失了。

避坑指南:我曾经犯过一个错——只给「扣款」加锁,没给「检查余额」加锁。结果呢?检查时读到的是旧值,锁了个寂寞。记住:临界区要整体保护,不能只保护一半。

知识体系图

下面这张图帮你理清本章的核心逻辑:

竞态条件与临界区知识体系 竞态条件 临界区 产生于 共享数据 非原子操作 多线程交错执行 访问共享变量 读-改-写三步 结果依赖时序 解决方案:加锁 / 原子类 经典案例:银行转账Bug

总结一下

竞态条件和临界区,说白了就是多线程编程的「第一道坎」。跨过去,后面的路就好走了。

我个人习惯在写多线程代码前,先画个图,标出所有共享变量和它们被访问的位置。这样临界区一目了然,竞态条件也无处藏身。

记住一句话:没有共享,就没有伤害。能用局部变量就别用全局变量,能用不可变对象就别用可变对象。这是我从无数次踩坑中总结出来的血泪教训。

好了,今天就聊到这儿。下一章咱们深入聊聊锁的底层原理,看看synchronized到底是怎么工作的。

可可精品资料库