8. 线程安全问题:count++ 为什么不安全
发布于 • 阅读量 0
前面几节一直在处理“怎么启动线程、怎么等待线程、怎么分配任务”。
这些属于并发的执行流程问题。
从这一节开始,要碰到另一个更关键的问题:线程安全。
多线程最麻烦的地方,不只是多个线程同时跑,而是多个线程同时操作同一个数据时,结果可能不是我以为的那样。
最经典的例子就是:
count++;
这行代码看起来非常简单,但放到多线程里,它并不安全。
先写一个不安全的例子
新建类:
com.succos.thread.ThreadUnsafeDemo
代码如下:
package com.succos.thread;
public class ThreadUnsafeDemo {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
}
}, "thread-1");
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
}
}, "thread-2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("最终结果:" + count);
}
}
按正常理解,两个线程分别执行 100000 次 count++。
所以我可能会觉得结果应该是:
200000
但实际运行时,很可能不是 200000。
有时候可能是:
168324
也可能是:
175891
每次运行结果还可能不一样。
这就是线程安全问题。
为什么 count++ 会出问题?
问题就在这行:
count++;
它看起来是一行代码,但底层并不是一步完成的。
可以简单理解成三个步骤:
1. 读取 count 当前的值;
2. 在这个值的基础上加 1;
3. 把新值写回 count。
比如当前 count = 10。
单线程情况下没问题:
读取 count,得到 10;
计算 10 + 1,得到 11;
写回 count = 11。
但是多线程同时执行时,就可能出现问题。
两个线程同时执行会发生什么?
假设现在 count = 10。
thread-1 和 thread-2 同时执行 count++。
可能出现这样的过程:
thread-1 读取 count,得到 10;
thread-2 读取 count,也得到 10;
thread-1 计算 10 + 1,得到 11;
thread-2 计算 10 + 1,也得到 11;
thread-1 把 11 写回 count;
thread-2 也把 11 写回 count。
这时候两个线程都执行了一次 count++,理论上应该加 2。
但是最终结果只从 10 变成了 11。
少加了一次。
这就是所谓的“丢失更新”。
问题不是线程没执行
这里很容易误解。
结果不对,不是因为某个线程没有执行。
两个线程都执行了。
只是它们在操作同一个共享变量时,中间步骤交叉了,导致其中一次更新被覆盖掉了。
所以问题不是:
线程没跑。
而是:
多个线程同时读写同一个变量,读、改、写这几个步骤没有被保护起来。
这个点我觉得很重要。
因为很多并发问题不是代码没有执行,而是执行顺序和我想象的不一样。
什么是共享变量?
在这个例子里:
private static int count = 0;
count 是一个静态变量,两个线程都能访问它。
所以它是共享变量。
只要多个线程同时读写同一个共享变量,就要开始考虑线程安全。
比如下面这些都属于共享状态:
static 变量;
对象的成员变量;
集合对象,比如 ArrayList、HashMap;
缓存数据;
全局计数器;
Spring 单例 Bean 里的可变成员变量。
当然,不是所有共享变量都会出问题。
如果只是多个线程读,不修改,一般问题不大。
真正危险的是:
多个线程同时修改同一个数据。
局部变量为什么一般没问题?
和共享变量对应的是局部变量。
比如:
public void process() {
int localCount = 0;
localCount++;
}
localCount 是方法里的局部变量。
每个线程调用这个方法时,都会有自己的方法栈帧。也就是说,每个线程都有自己的一份 localCount。
所以它们互不影响。
这也是为什么在 PDF 水印项目里,如果我在方法内部创建:
FileItemContext fileItemContext = new FileItemContext();
一般是安全的。
因为每个线程执行方法时,都会创建自己的局部变量。
但如果我把它放成成员变量,比如:
private FileItemContext fileItemContext;
多个线程都去改这个成员变量,那就危险了。
回到 PDF 项目里看线程安全
在 PDF 水印项目里,最容易出问题的地方不是 count++,而是共享状态。
比如下面这种写法就不太好:
public class PdfWatermarkService {
private String sourcePath;
private String targetPath;
public void addWatermark(FileItemContext fileItemContext) {
this.sourcePath = fileItemContext.getSourcePath();
this.targetPath = fileItemContext.getTargetPath();
// 后面根据 sourcePath 和 targetPath 处理 PDF
}
}
如果这个 PdfWatermarkService 被多个线程共享,比如在 Spring Boot 里它是一个单例 Bean。
那么多个请求同时进来时,就可能出现:
线程1 设置 sourcePath = a.pdf;
线程2 设置 sourcePath = b.pdf;
线程1 继续往下执行时,发现 sourcePath 已经变成 b.pdf。
这种问题会非常隐蔽。
所以我更倾向于把这些任务参数放在方法局部变量里,而不是放到成员变量里。
比如:
public void addWatermark(FileItemContext fileItemContext) {
String sourcePath = fileItemContext.getSourcePath();
String targetPath = fileItemContext.getTargetPath();
// 后面都使用局部变量
}
这样每个线程都有自己的 sourcePath、targetPath,互不影响。
线程安全问题的本质
我现在理解线程安全,会抓住三个点:
1. 有没有共享数据;
2. 有没有多个线程同时访问;
3. 有没有修改操作。
如果只是局部变量,一般不用太紧张。
如果是共享变量,但只读不写,问题也不大。
真正要小心的是:
共享变量 + 多线程 + 修改操作。
这三个条件凑在一起,就要考虑线程安全了。
count++ 就是典型例子。
它满足这三个条件:
count 是共享变量;
thread-1 和 thread-2 同时访问;
两个线程都在修改 count。
所以结果可能不对。
volatile 能不能解决 count++?
这里顺便提一下 volatile。
如果我把代码改成:
private static volatile int count = 0;
它能不能解决 count++ 的问题?
答案是:不能。
volatile 能保证变量的可见性,也就是一个线程改了值,其他线程能及时看到。
但它不能保证 count++ 这三个步骤变成一个不可拆分的整体。
count++ 还是:
读;
加;
写。
多个线程仍然可能交叉执行。
所以 volatile 不适合解决这种复合操作的原子性问题。
后面要解决 count++,可以用:
synchronized;
ReentrantLock;
AtomicInteger。
下一节会先用最直接的 synchronized 来解决。
怎么判断一段代码有没有线程安全风险?
我现在一般会这样看。
如果一段代码只使用方法内部的局部变量,而且每个线程处理自己的数据,那通常没什么问题。
比如:
public void process(File file) {
String fileName = file.getName();
String targetPath = "output/" + fileName;
}
这些变量都是当前方法内部的。
但如果一段代码会修改共享对象,就要谨慎。
比如:
list.add(fileName);
map.put(key, value);
count++;
this.currentFile = file;
这些代码在多线程环境下都要多想一步。
不是说一定不能写,而是要知道它们是否会被多个线程同时执行。
这一节小结
这一节我主要记住几点:
1. count++ 看起来是一行代码,但实际包含读、改、写三个步骤;
2. 多个线程同时执行 count++,可能出现丢失更新;
3. 线程安全问题通常出现在共享变量被多个线程同时修改时;
4. 局部变量一般比较安全,因为每个线程有自己的方法栈;
5. volatile 不能解决 count++ 的原子性问题;
6. 判断线程安全时,可以看:是否共享、是否多线程、是否修改。
用一句话总结:
线程安全问题不是线程没执行,而是多个线程同时修改共享数据时,执行顺序失控了。
下一节开始用 synchronized 解决这个问题。
它的作用就是把某一段关键代码保护起来,让同一时间只能有一个线程进去执行。