线程安全问题 | JavaSE
线程安全问题
一、学习目标
完成本章后,你应该能够:
- 准确定义什么是线程安全问题。
- 识别线程安全问题出现的三个核心条件。
- 理解共享资源(Shared Resource)的含义。
- 理解“检查 → 修改”为什么在多线程环境中可能出现竞争。
- 能够使用银行账户取款案例复现典型线程安全问题。
- 理解线程安全 Bug 为什么具有偶发性和不确定性。
- 初步理解竞态条件(Race Condition)与数据竞争(Data Race)。
- 能够分析一段多线程代码是否存在共享可变数据。
- 明白为什么后续必须学习
synchronized与Lock。
二、核心知识
2.1 从一个银行账户开始
假设:
小明和小红是一对夫妻
两个人拥有:
同一个银行账户
账户余额:
100000 元
现在:
小明取 100000
小红也取 100000
如果按照现实业务规则:
账户只有100000
那么正确结果应该是:
一个人取款成功
另一个人余额不足
绝对不应该:
两个人都成功取到100000
否则银行凭空多吐出了十万元。
问题来了:
如果小明和小红通过两个线程同时取钱,会发生什么?
这就是最经典的:
线程安全问题
2.2 什么是线程安全问题
在本阶段可以这样理解:
多个线程同时操作同一个共享资源,并且存在对共享资源的修改时,程序可能出现不符合业务预期的结果,这就是线程安全问题。
例如:
线程A ──┐
├──→ Account.money
线程B ──┘
如果两个线程都:
读取 money
判断 money
修改 money
就可能出现并发冲突。
2.3 线程安全问题出现的三个核心条件
判断一个场景是否需要高度警惕线程安全,可以检查三个条件:
条件一:
存在多个线程并发执行
条件二:
多个线程访问同一个共享资源
条件三:
至少存在对共享资源的修改
也就是:
多线程
+
共享资源
+
写操作
=
线程安全高风险场景
这三个条件非常重要。
2.4 什么叫共享资源
所谓共享资源,就是:
多个线程能够访问到的同一份数据或对象。
例如:
Account account =
new Account("001", 100000);
然后:
Thread t1 =
new DrawThread(
"小明",
account
);
Thread t2 =
new DrawThread(
"小红",
account
);
注意:
t1 → account
t2 → account
两个线程保存的:
不是两个Account对象
而是:
同一个Account对象
因此:
account.money
就是共享资源。
2.5 如果不是同一个对象呢?
例如:
Account a1 =
new Account("001", 100000);
Account a2 =
new Account("002", 100000);
线程:
t1 → a1
t2 → a2
如果两个线程完全操作不同账户:
没有共同修改同一份money
那么不会产生当前这个账户余额竞争问题。
所以线程安全分析的第一问应该永远是:
到底有没有共享同一份数据?
而不是:
“程序用了 Thread,是不是就一定线程不安全?”
不是。
2.6 只读共享通常为什么风险较低
假设:
String config = "production";
两个线程只是:
线程A:读取config
线程B:读取config
并没有修改:
config
这种场景不会出现:
两个线程争抢着把同一个值改坏
真正需要重点警惕的是:
共享
+
可变
也就是:
Shared Mutable State
共享可变状态。
三、使用方法
3.1 创建账户类
沿用取钱案例:
public class Account {
private String cardId;
private double money;
public Account(
String cardId,
double money
) {
this.cardId = cardId;
this.money = money;
}
public void drawMoney(
double money
) {
String name =
Thread.currentThread()
.getName();
if (this.money >= money) {
System.out.println(
name
+ "准备取出"
+ money
+ "元"
);
this.money -= money;
System.out.println(
name
+ "取钱后余额:"
+ this.money
);
} else {
System.out.println(
name
+ "取钱失败,余额不足"
);
}
}
public double getMoney() {
return money;
}
}
这里存在一个非常关键的业务逻辑:
if (this.money >= money) {
this.money -= money;
}
可以拆成:
第一步:检查余额
第二步:决定是否允许取钱
第三步:修改余额
看起来没问题。
但:
这段代码原本是按照“只有一条执行流程”进行理解的。
现在来了两个线程。
3.2 创建取钱线程
public class DrawThread
extends Thread {
private final Account account;
public DrawThread(
String name,
Account account
) {
super(name);
this.account = account;
}
@Override
public void run() {
account.drawMoney(100000);
}
}
测试:
public class ThreadSafetyDemo {
public static void main(String[] args) {
Account account =
new Account(
"001",
100000
);
Thread t1 =
new DrawThread(
"小明",
account
);
Thread t2 =
new DrawThread(
"小红",
account
);
t1.start();
t2.start();
}
}
现在关系是:
┌── 小明线程
│
Account对象 ←────────┤
money = 100000 │
└── 小红线程
两个线程同时访问:
account.drawMoney(...)
3.3 问题可能怎样发生
假设最开始:
money = 100000
小明先执行:
if (this.money >= 100000)
此时:
100000 >= 100000
结果:
true
但是小明还没来得及执行:
this.money -= 100000;
CPU 切换给小红。
小红也执行:
if (this.money >= 100000)
因为余额还没有被小明修改,所以小红看到:
money = 100000
也判断:
true
于是两个线程都已经认为:
我可以取钱
执行过程可能变成:
初始:
money = 100000
小明:
检查 money >= 100000
→ true
线程切换
小红:
检查 money >= 100000
→ true
小红:
执行扣款
线程切换
小明:
继续执行扣款
最终:
两个线程都进入了
“余额足够”的业务分支
这就是问题核心。
3.4 问题不在 if 本身
很多初学者看到以后会认为:
“if 写错了。”
并不是。
在单线程中:
if (this.money >= money) {
this.money -= money;
}
逻辑完全合理。
问题在于:
检查余额
和:
修改余额
不是一个不可分割的整体。
线程可能在两个操作之间发生切换。
于是产生:
Check
← 线程切换 →
Update
这种“检查完但还没修改”的窗口。
3.5 这是一类非常经典的 Check-Then-Act 问题
执行逻辑:
先检查一个条件
↓
根据检查结果
↓
再执行修改
称为一种典型:
Check-Then-Act
模式。
单线程:
检查
↓
执行
没有其他线程插入。
多线程:
线程A:检查
↓
← 线程B插进来
↓
线程A:执行
条件从:
检查完成
到:
真正执行
之间可能已经失效。
四、原理与进阶
4.1 什么是竞态条件
竞态条件(Race Condition)可以理解为:
程序最终结果依赖多个线程执行操作时的相对时序。
例如:
A先检查还是B先检查?
A什么时候扣款?
B什么时候扣款?
不同调度顺序可能得到不同结果。
于是:
程序结果
依赖
线程“比赛”的时序
因此称为:
Race Condition
4.2 为什么这种 Bug 特别难查
普通 Bug:
点击按钮
↓
每次都报错
很好复现。
并发 Bug 可能:
第一次运行:正常
第二次运行:正常
第三次运行:异常
第四次运行:正常
第100次:又异常
因为线程调度:
不是固定顺序
所以你可能遇到一个非常折磨人的现象:
Debug 的时候不出问题,一取消断点反而出问题。
原因就是:
Debug改变了线程执行时序
并发 Bug 常常属于:
时序相关 Bug
4.3 count++ 为什么也可能不安全
很多人觉得:
count++;
只有一行代码,所以一定安全。
这是错误的。
从逻辑效果上,可以理解成:
读取 count
↓
计算 count + 1
↓
把结果写回 count
例如:
count = 10
线程 A:
读取10
线程 B:
也读取10
A:
写入11
B:
也写入11
结果:
预期:
12
实际:
11
有一次增加:
丢失了
这类现象常称为:
丢失更新
Lost Update
4.4 代码“一行”不等于操作“原子”
这是并发学习的重要思想。
例如:
balance -= money;
源码看起来只有一行。
但是不要因此推断:
从并发角度绝对不可拆分
源代码的一行:
并不天然等于
不可被其他线程干扰的原子操作
这也是为什么后面需要:
同步
锁
原子类
等并发机制。
4.5 什么是原子性
原子性(Atomicity)可以先理解为:
一个操作从并发观察角度,要么完整执行,要么没有执行,中间不能被其他线程看到一个半完成状态。
取钱业务真正希望的是:
检查余额
+
执行扣款
整体看成一个不可被其他取款线程干扰的业务单元。
也就是希望:
线程A:
检查 + 扣款
完成以后
线程B:
再检查 + 扣款
而不是:
A检查
B检查
A扣款
B扣款
4.6 数据竞争与线程安全不是完全相同的词
Java 内存模型中有一个更加严格的术语:
数据竞争(Data Race)
如果两个线程对同一变量存在冲突访问:
至少一个是写操作
并且它们之间没有正确的 happens-before 顺序关系,就可能形成数据竞争。
例如:
线程A:写 count
线程B:读/写 count
如果没有正确同步:
存在数据竞争风险
不过:
Data Race
与:
所有线程安全 Bug
不能机械地画完全等号。
现阶段更重要的是建立工程判断模型:
共享可变数据
+
并发访问
+
缺少正确协调
=
危险
4.7 线程安全不仅仅是“结果别变成负数”
线程安全问题可能表现成很多形式:
余额异常
库存超卖
票号重复
计数丢失
集合数据损坏
订单重复处理
重复发放优惠券
抽奖结果重复
状态前后矛盾
所以真正的问题是:
多线程并发执行以后,程序是否仍然保持业务规则和数据不变量。
4.8 一个典型的库存超卖问题
例如:
if (stock > 0) {
stock--;
}
库存:
1
线程 A:
看到 stock = 1
线程 B:
也看到 stock = 1
于是:
两个订单都成功
但是实际只有:
1件商品
这与前面的:
银行账户取钱
本质完全相同:
检查共享状态
↓
多个线程同时通过
↓
修改共享状态
↓
业务规则被破坏
五、实践应用
5.1 银行取款
共享资源:
Account.money
线程:
小明
小红
修改操作:
drawMoney()
风险:
余额判断与扣款之间产生竞争
5.2 商品库存
共享资源:
stock
线程:
多个下单请求
修改:
stock--
风险:
超卖
5.3 抢票系统
共享资源:
remainingTickets
多个窗口:
窗口1
窗口2
窗口3
共同售票。
如果:
判断还有票
+
扣减票数
没有正确协调,就可能:
重复出票
票数异常
5.4 计数器
多个线程:
count++;
执行很多次。
预期:
200000
实际可能:
小于200000
因为:
多个线程读取同一个旧值
然后覆盖彼此结果
5.5 共享集合
例如多个线程共同:
list.add(...)
list.remove(...)
如果所使用的数据结构本身没有提供对应并发安全保证,又没有外部同步,就可能出现:
结果丢失
状态不一致
异常
因此:
学完集合 API 并不意味着可以随意让多个线程同时修改普通集合。
六、常见问题
6.1 只要用了多线程就一定不安全吗?
不是。
如果线程:
完全不共享数据
或者只是安全地处理自己的局部数据,就不会产生当前这种共享资源竞争问题。
6.2 多个线程读取同一个变量就一定有问题吗?
不一定。
真正需要重点关注:
共享数据
+
至少存在修改
6.3 为什么单线程代码正常,多线程就异常?
因为单线程只有:
一条执行流程
不会有另一个线程在关键操作之间插入。
多线程则可能:
执行到一半
↓
发生调度
↓
另一个线程修改共享数据
6.4 Thread.sleep() 能解决线程安全吗?
不能。
例如:
Thread.sleep(1000);
只是让当前线程暂时停止运行。
它不能保证:
另一个线程不会同时访问共享资源
甚至人为加入 sleep 经常会让竞争更容易暴露。
6.5 设置线程优先级能解决线程安全吗?
不能。
setPriority()
不能提供互斥、原子性或者可靠执行顺序。
6.6 join() 能不能解决所有线程安全问题?
不能。
join() 可以让:
一个线程等待另一个线程结束
如果把所有任务都强制彻底串行:
A结束
↓
B才开始
当然可能避免某些并发冲突。
但是:
并发能力也被直接取消
真正的问题通常是:
只保护共享资源的关键操作,而不是让整个系统失去并发性。
这就是后续同步机制存在的意义。
6.7 把变量改成 static 能解决吗?
通常不能。
很多时候反而意味着:
更多线程共享同一份数据
static 解决的是:
数据属于类还是对象
不是:
线程安全
6.8 多次运行都正常,能证明代码线程安全吗?
不能。
并发问题可能依赖非常特定的执行时序。
运行:
10次正常
100次正常
都不能证明:
不存在竞态条件
应该从:
共享资源
访问方式
同步关系
分析正确性。
七、练习与验收
7.1 知识问答
- 什么是线程安全问题?
- 线程安全问题出现的三个核心条件是什么?
- 什么叫共享资源?
- 什么叫共享可变状态?
- 两个线程分别操作两个 Account 对象,一定存在同一账户竞争吗?
- 多线程只读数据为什么通常比并发写风险低?
- 银行取款案例为什么可能出现异常?
if (money >= amount)本身写错了吗?- 什么叫 Check-Then-Act?
- 什么是竞态条件?
- 什么叫丢失更新?
- 为什么
count++不能简单理解成一个不可分割操作? - 什么是原子性?
- 为什么并发 Bug 经常难以复现?
sleep()能不能解决线程安全?setPriority()能不能解决线程安全?- 为什么多次运行正常不能证明线程安全?
7.2 代码阅读
分析:
class Counter {
int count = 0;
public void increment() {
count++;
}
}
现在:
Counter counter =
new Counter();
Thread t1 =
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter.increment();
}
});
Thread t2 =
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter.increment();
}
});
回答:
- 两个线程是否共享 Counter?
count是否属于共享数据?- 是否存在写操作?
- 理论期望值是多少?
- 为什么实际结果存在小于期望值的风险?
- 这种现象可以怎样解释?
分析:
class User {
private final String name;
public User(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
如果多个线程只调用:
getName()
思考:
与多个线程同时执行
count++相比,风险有什么不同?
7.3 手写代码
从零创建:
Account
DrawThread
ThreadSafetyDemo
要求:
账户余额:100000
小明:取100000
小红:取100000
两个线程共享:
同一个Account对象
先只复现问题。
本题暂时禁止使用:
synchronized
Lock
你的任务不是修复,而是:
找到线程安全问题到底为什么出现。
7.4 Debug
下面代码:
class Account {
private double money = 100000;
public void drawMoney(double amount) {
if (money >= amount) {
money -= amount;
}
}
}
开发者说:
“这里有 if 判断余额,所以绝对不会超额取款。”
判断这句话是否正确。
要求画出两个线程可能的交错执行过程。
另一个开发者提出:
Thread.sleep(1000);
加入取钱方法就能解决线程安全。
判断是否正确并解释。
第三个开发者提出:
thread.setPriority(10);
规定一个线程优先执行,就能解决问题。
判断是否正确。
7.5 综合训练
分析一个电商库存系统:
if (stock > 0) {
createOrder();
stock--;
}
现在同时有:
用户A
用户B
用户C
三个请求线程。
要求回答:
共享资源是什么?
哪些代码属于检查?
哪些代码属于修改?
什么执行顺序可能导致超卖?
为什么单线程没有这个问题?
为什么多线程出现问题?
真正应该保护的核心业务区域是什么?
暂时不要编写锁代码。
最后画出:
线程A
线程B
共享库存
三者之间的关系图。
7.6 本章验收
能够闭卷说出:
线程安全问题三个条件:
1. 多个线程
2. 同一个共享资源
3. 存在修改
并能够独立分析:
银行取钱
库存扣减
计数器++
三个案例。
同时能够解释:
竞态条件
Check-Then-Act
丢失更新
原子性
并回答:
为什么不能用 sleep、优先级或者“多运行几遍没出错”证明线程安全?
即可进入下一章:
synchronized 同步代码块与同步方法