Skip to content

极客时间 ——《Java并发编程实战》 01 | 可见性、原子性和有序性问题:并发编程Bug的源头 #36

@funnycoding

Description

@funnycoding

并发程序幕后的故事

CPU内存IO 之间速度存在着很大的差异,根据木桶理论,程序整体性能取决于最慢的设备 —— I/O 设备。

所以为了合理利用 CPU 的高性能,平衡三者的速度差异,计算机体系结构操作系统、**编译程序(编译器)**都做出了贡献,具体体现为:

  1. CPU 增加缓存,以均衡与内存之间的速度差异。
  2. 操作系统增加了进程、线程、以分时复用 CPU,进而均衡了 CPU 与 I/O 设备的速度差异。
  3. 编译程序优化指令执行次数,使得缓存能够得到更加合理地利用。

这些措施增强了程序的性能,却也带来了很多并发的诡异问题。

源头一:缓存导致可见性问题

单核时代,所有线程都在同一个 CPU 上执行, CPU 缓存与 内存数据的一致性容易解决。 因为所有线程都操作的是同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

例如下面的图中,线程A 和 线程B 都操作同一个 CPU 里面的缓存,所以线程A 更新了 变量 V 的值,那么线程B 之后访问变量V 得到的一定是 V 的最新值。(线程A 写入的值)

一个线程对共享变量的修改,另一个线程能立刻看到,称为「可见性」。

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了。

当多个线程在不同 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。 比如下图中,线程A 操作的是 CPU-1 上的缓存,而线程B 操作的是 CPU-2 上的缓存,很明显,这个 「线程A」「变量V」 的操作对于 「线程B」 来说就不具备可见性了。

下面使用一段代码来验证「多核场景」下的 「可见性问题」

下面的代码,每执行一次 add10K() 方法,都会循环 10000count += 1 的操作。 在 calc() 方法中我们创建了两个线程,每个线程调用一次 add10K() 方法,那么 calc() 方法的结果应该是多少?

@Slf4j
public class Test {
    private long count = 0;

    private  void add10K() {
        int idx = 0;
        while (idx++ < 10000) {
            count += 1;
        }
        log.info("add10k()方法执行完毕时Count的值: ---> {}",count);
    }

    public static long calc() throws InterruptedException {
        final Test test = new Test();
        Thread th1 = new Thread(test::add10K);

        Thread th2 = new Thread(test::add10K);

        // 启动 th1,th2 线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束 th1.join(); th2.join();
        th1.join();
        th2.join();
        return test.count;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            long calc = Test.calc();
            log.info("本次返回结果{}",calc);
        }
    }
}




/**
输出
[INFO ]-Thread-0-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10697
[INFO ]-Thread-1-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 12674
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果12674
[INFO ]-Thread-2-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-3-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 14978
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果14978
[INFO ]-Thread-4-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 12777
[INFO ]-Thread-5-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 16389
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果16389
[INFO ]-Thread-6-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10032
[INFO ]-Thread-7-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 14018
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果14018
[INFO ]-Thread-8-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10261
[INFO ]-Thread-9-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 15359
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果15359
[INFO ]-Thread-10-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 12575
[INFO ]-Thread-11-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 19471
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果19471
[INFO ]-Thread-12-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-13-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-15-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 17697
[INFO ]-Thread-14-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 15704
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果17697
[INFO ]-Thread-16-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10027
[INFO ]-Thread-17-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 17544
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果17544
[INFO ]-Thread-18-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-19-[2020-04-09 15:06:31]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:06:31]-[chapter1.Test:43]: 本次返回结果20000

Process finished with exit code 0

*/

这里我给文章中的样例代码加了10次循环,并且增加了log输出,可以非常直观的看到,add10K() 是一个非同步的方法,在并发环境下有多次结果都并不是预想中的 20000

【然后我再给方法加一个内置锁:】

@Slf4j
public class Test {
    private long count = 0;

    private  synchronized void add10K() {
        int idx = 0;
        while (idx++ < 10000) {
            count += 1;
        }
        log.info("add10k()方法执行完毕时Count的值: ---> {}",count);
    }

    public static long calc() throws InterruptedException {
        final Test test = new Test();
        Thread th1 = new Thread(test::add10K);

        Thread th2 = new Thread(test::add10K);

        // 启动 th1,th2 线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束 th1.join(); th2.join();
        th1.join();
        th2.join();
        return test.count;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            long calc = Test.calc();
            log.info("本次返回结果{}",calc);
        }
    }
}

/**
输出
[INFO ]-Thread-0-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-1-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-2-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-3-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-4-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-5-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-6-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-7-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-8-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-9-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:17]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-10-[2020-04-09 15:07:17]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-11-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-12-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-13-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-14-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-15-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-16-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-17-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000
[INFO ]-Thread-18-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 10000
[INFO ]-Thread-19-[2020-04-09 15:07:18]-[chapter1.Test:22]: add10k()方法执行完毕时Count的值: ---> 20000
[INFO ]-main-[2020-04-09 15:07:18]-[chapter1.Test:43]: 本次返回结果20000

*/

可以看到加锁之后的方法,每次执行完成都是 预料中的数字 ---> 20000

这里之前造成 calc() 结果是个 10000 到 20000 之间随机数的原因是:

假设 「线程A」「线程B」 同时开始执行,那么第一次都会将 count = 0 读取到各自的 **「CPU 缓存」**中,执行完 count += 1 之后,各自 「CPU 缓存」 中的值都是1,同时写入内存后,我们会发现内存中的值 是 1 而不是 期望值 2 。 之后由于各自的 CPU 缓存 中都有了 count的值,两个线程都是基于 CPU 缓存里的 count 进行计算,所以导致最终的 count 的值小于 20000,这就是 「缓存可见性」 问题。

如果将循环条件从 10000 次 改为 1亿次,那么结果会更加明显,最终的 count 值更接近1亿 而不是 2亿。 在这个循环 10000 次的例子中,count 接近 10000 是因为两个线程并非同时启动,有一个时差。

将循环条件改为 「1亿次」 之后,结果确实 如作者所说,更接近 1亿。

源头二:线程切换带来的「原子性」 问题

由于 I/O 速度太慢,早期的操作系统就发明了**「多进程」,即便在单核的 CPU 上我们也可以一边听歌一边做别的,给人一种这几件事并行的错觉,其实是CPU在以极小的时间分片进行切换,每个进程执行一个「时间片」**的时间 例如 50ms , 这就是 多进程的功劳。

在一个时间片内,如果一个进程进行一个 I/O 操作,例如读取一个文件,这个时候进程可以把自己标记为 「休眠状态」 并 出让 CPU 的使用权,当文件读取进内存这个 I/O 操作结束后, 操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。

这里的 「进程」 在等待 I/O 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待的时间里可以做别的事情,这样一来,CPU 的使用率就增加了

如果这时另一个进程也在读取文件,读取文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 I/O 的使用率也增加了

虽然这个逻辑看上去很简单,但是支持 「进程分时复用」 在操作系统的发展史上有着 「里程碑」 的意义,Unix 因为解决了这个问题而名噪天下。

「早期的操作系统」基于「进程」 来调度 CPU,不同的进程之间不共享「内存空间」,所以切换任务就需要切换进程,从而切换**「内存映射地址」,而一个进程创建的所有线程,则共享同一个内存空间,所以线程之间的切换成本比进程间切换要低很多。 现代的操作系统都是基于更轻量的线程来调度,所以我们现在提到的「任务切换」** 指的都是 「线程切换」

Java 并发程序都是基于 多线程的,自然也会涉及到 「任务切换」。而同时 任务切换也是 Java 并发中 Bug 的源头之一。

「任务切换」 的时机大多数是在 时间片结束的时候,我们现在使用的基本都是高级编程语言,而高级编程语言中的一行代码往往对应着多个 CPU 指令操作。

比如上面代码中的 count += 1 就对应着 3个操作。

  • 指令1 读取:将 count 内存加载到 CPU 寄存器
  • 指令2 修改:将 count 在寄存器中进行 +1 操作
  • 指令3 写入:将寄存器中的结果写入内存。(缓存机机制导致可能写入的是 CPU 缓存而不是 内存)

操作系统做任务切换,可以发生在任何一条 「CPU 指令」 执行完成的时候,而不是 高级编程语言中的一条语句。 ↑---【这里对原子性的解释要比 jcip 中更具体一些,我理解的原子性指的是一组高级语句中对应的所有 CPU 指令一起执行完成 才发生切换】

对于上面的三条指令来说,假设 count = 0,如果 线程A 在 指令1 执行完成后进行了线程切换,线程A线程B 按照下图的顺序执行,那么我们会发现两个线程都执行了 count += 1 的操作,且 count 的值都是以 0 为起点,最终得到的结果是 1 而不是期望的 2

我们将一个或者多个操作在 CPU 执行的过程中不被中断的特性称为 「原子性」 <---【对原子性的定义】。

CPU 能保证的原子操作需要是 CPU 指令级别的,而不是高级语言的操作符。

这是违背我们直觉的地方,因此很多时候我们需要在**「高级语言层面」**保证操作的原子性。

【所以这一节的内容对于《jcip》 中所讲的 复合操作 带来的线程安全性问题,最终追溯到 Java 中的原子操作是保证线程安全的重要手段】

源头之三:编译优化带来的有序性问题

【也就是重排序问题】

并发编程中还有一个容易导致违背直觉性的诡异Bug的原因就是有序性

有序性指的是 程序按照代码的先后顺序执行。 【<--- 对于有序性的定义】

但是编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如 a=6,b=7 编译后可能变成了 b=7,a=6 这个顺序。 编译器调整了语句的顺序,但是不影响程序的最终结果。

但是有时候编译器和解释器的优化可能导致意想不到的Bug。

Java 中的一个经典的关于有序性问题的按理就是利用 「双重检查」 创建单例对象,例如下面的代码:

在获取实例 getInstnace() 方法中,首先判断 intance 是否为空,如果为空则锁定 Singleton.class 并再次检查 instance 是否为空,如果还未空则创建 Singleton 的一个实例。

// 一个双重检查的单例代码示例
public class Singleton {
    static Singleton instance;

    static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

假设有两个线程A、B 同时调用 getInstance() 方法,则同时发现 instance == null , 于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是 线程A),此时另外一个线程会处于等待状态,线程A 创建一个 Singleton 实例,然后释放锁,锁被释放后 线程B 被唤醒,线程B 尝试加锁 ,本次加锁可以成功,然后线程B 检查 instance == null 发现已经有实例被创建,所以线程B 不会再创建一个 SIngleton 实例。

看上去很完美,但是实际上 getInstance() 方法存在漏洞, 问题出在 new 这个操作符上。

我们以为的使用 new 操作符构造对象的流程:

  1. 分配一块内存 M
  2. 在内存上初始化 Singleton 对象
  3. 将 M 的地址 赋值给 instance 变量

但是实际上被优化后的执行路径却是这样的:

  1. 分配一块n内存 M
  2. 将 M 的地址赋值给 instance 变量
  3. 在 内存 M 上初始化 Singleton 对象。

这样将会导致:

线程A 先执行 getInstance() 方法,当执行完指令2时发生了线程切换,此时线程B 也执行了 getInstance() 方法,进入判断 instance != nulltrue,但是此时 这个对象并没有被成功构建出来,而如果对这个对象进行访问的话,就可能触发 「空指针异常」。

【↑所以这里的B 存在的是可见性的问题,此时它看到的是一个未构建成功的 instance 实例】

【这里说明的是「构造函数」 并不是一个原子操作,未构建完成的对象可能被逸出,导致某些错误的发生,这个是之前我看《Java 并发编程实战》时当时没太理解的一个点,再次看到这个专门看到这个解释我明白了。】

下面是对于这个双重检查单例类的流程图解释:

总结

想写好并发程序,首先要知道并发程序的问题可能出现在哪里,是因为什么导致的。

只要我们可以深刻的理解 可见性原子性有序性 在并发场景下的原理,就可以对 Bug 进行比较准确的诊断。

这里作者特意提到了缓存导致的可见性问题线程切换带来的原子性问题编译优化导致的有序性问题。这些手段的目的都是为了提高性能,但是技术再解决一个问题的同事必然会带来新的问题。

在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。

专栏和书比起来,知识密度少了许多,所以阅读起来比较轻松, 再加上之前已经使用《jcip》 对并发的基础知识进行了一轮学习,所以对于专栏的学习更多的是加深理解,查漏补缺。 同时更多的实践。

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions