线程安全问题 | JavaSE

线程安全问题

一、学习目标

完成本章后,你应该能够:

  • 准确定义什么是线程安全问题。
  • 识别线程安全问题出现的三个核心条件。
  • 理解共享资源(Shared Resource)的含义。
  • 理解“检查 → 修改”为什么在多线程环境中可能出现竞争。
  • 能够使用银行账户取款案例复现典型线程安全问题。
  • 理解线程安全 Bug 为什么具有偶发性和不确定性。
  • 初步理解竞态条件(Race Condition)与数据竞争(Data Race)。
  • 能够分析一段多线程代码是否存在共享可变数据。
  • 明白为什么后续必须学习 synchronizedLock

二、核心知识

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 知识问答

  1. 什么是线程安全问题?
  2. 线程安全问题出现的三个核心条件是什么?
  3. 什么叫共享资源?
  4. 什么叫共享可变状态?
  5. 两个线程分别操作两个 Account 对象,一定存在同一账户竞争吗?
  6. 多线程只读数据为什么通常比并发写风险低?
  7. 银行取款案例为什么可能出现异常?
  8. if (money >= amount) 本身写错了吗?
  9. 什么叫 Check-Then-Act?
  10. 什么是竞态条件?
  11. 什么叫丢失更新?
  12. 为什么 count++ 不能简单理解成一个不可分割操作?
  13. 什么是原子性?
  14. 为什么并发 Bug 经常难以复现?
  15. sleep() 能不能解决线程安全?
  16. setPriority() 能不能解决线程安全?
  17. 为什么多次运行正常不能证明线程安全?

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();
            }

        });

回答:

  1. 两个线程是否共享 Counter?
  2. count 是否属于共享数据?
  3. 是否存在写操作?
  4. 理论期望值是多少?
  5. 为什么实际结果存在小于期望值的风险?
  6. 这种现象可以怎样解释?

分析:

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 同步代码块与同步方法