跳到正文
hello world

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-1thread-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();

    // 后面都使用局部变量
}

这样每个线程都有自己的 sourcePathtargetPath,互不影响。


线程安全问题的本质

我现在理解线程安全,会抓住三个点:

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 解决这个问题。

它的作用就是把某一段关键代码保护起来,让同一时间只能有一个线程进去执行。

8. 线程安全问题:count++ 为什么不安全 - Java并发之从Thread到CompletableFuture - 上下文网