竞态条件与临界区:一个经典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读取A余额:100
- 线程2读取A余额:100
- 线程1判断:100 >= 80,扣款,A余额变20
- 线程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方法。一个线程执行完,另一个才能进。竞态条件就消失了。
避坑指南:我曾经犯过一个错——只给「扣款」加锁,没给「检查余额」加锁。结果呢?检查时读到的是旧值,锁了个寂寞。记住:临界区要整体保护,不能只保护一半。
知识体系图
下面这张图帮你理清本章的核心逻辑:
总结一下
竞态条件和临界区,说白了就是多线程编程的「第一道坎」。跨过去,后面的路就好走了。
我个人习惯在写多线程代码前,先画个图,标出所有共享变量和它们被访问的位置。这样临界区一目了然,竞态条件也无处藏身。
记住一句话:没有共享,就没有伤害。能用局部变量就别用全局变量,能用不可变对象就别用可变对象。这是我从无数次踩坑中总结出来的血泪教训。
好了,今天就聊到这儿。下一章咱们深入聊聊锁的底层原理,看看synchronized到底是怎么工作的。