update: 2020年10月29日23:22:08

本篇文章是对之前笔记的重构,放弃了摘抄一遍文章同时混入自己想法的结构,而是反过来,大部分结构自己来组织,然后对课程重点内容进行提炼,然后再进行先关的知识迁移与补充。


问题:

  • 并发是什么?

  • 并发带来的问题?

  • 可见性问题是什么?以及底层原因

  • 有序性问题是什么?以及底层原因

  • 原子性问题是什么?以及底层原因


计算机发展历史:

从裸机到操作系统的演化:

  1. 无操作系统时代:整个计算机运行一个应用程序。
  2. 早期操作系统出现: 以进程为单位分时复用 CPU 时间片。
  3. 现代操作系统:以进程中的线程为执行单位,划分更加精细,效率更加高。

计算机内部的核心矛盾

随着时代发展,计算机速度越来越快,可是有一个 核心矛盾 没有改变:

  • CPU内存 、I/O 设备之间的速度差异

程序需要访问内存,有些情况需要访问 I/O , 这里的 I/O 可能是本机的硬盘存储,也可能是网络 I/O ,不管具体是什么表现形式, I/O 操作的速度与 CPU内存相比都差了几个数量级。

而根据 木桶理论 :程序的效率取决于最慢的操作,于是 I/O 成了主要的瓶颈。=

为了效率与利用率,并发出现了

问题: I/O 操作速度慢, 拉低了整体运行速度。

并发出现的原因

  • 提升性能
  • 增加硬件利用率(在等待 I/O 的时候,CPU 相当于在空转,没有负载)

并发技术演化过程: 从最初的以 进程 为单位,到现在的以 线程 为单位,对资源的控制越来越精细。

原子性、有序性、可见性的定义

对这三个问题下个定义:

  • 原子性: 一个或者多个操作在 CPU 执行的过程中**不被中断** 。
  • 有序性: 程序按照代码的编写顺序依次执行。
  • 可见性: 一个线程对 共享变量修改,另一个线程能立刻看到。

引发这三个问题的常见场景:

  • 原子性: 多线程环境下对共享变量进行修改最终数据异常的问题。
  • 有序性: 使用双重检查创建单例对象可能导致的构造函数逸出问题。
  • 可见性: 多线程环境下 多个线程不同CPU核心操作共享变量 ,导致的数据异常。

并发的利与弊

任何技术的引入都会带来问题,并发技术的初衷是提高效率,和利用率,那么它会带来怎样的问题?

硬件层面 ---> 可见性问题:CPU 缓存

CPU 在发展过程中不仅时钟频率在增加,核心数 也在不断增加,这样就导致了不同 CPU 之间的缓存互相不可见,具体到并发编程这个领域中, CPU 缓存带来了 可见性 问题。

单核时代多线程修改同一个缓存变量:

两个线程在同一个 CPU 缓存中,所以变量的值被修改后另一个线程可以立即看到最新的值,最终将值写入内存。

多核时代多个 CPU 之间缓存互相不可见导致的问题:

此时相当于 CPU-1CPU-2 各自读取了一份 V 的值,修改后写入内存,就会丢失另一半操作。

多核场景下带来的并发问题演示代码:

  • 定义一个变量 count 作为计数器
  • 定义一个方法对变量 coount 进行修改,调用一次预期 count 加 10k。
  • 定义一个方法,启动 2 个 线程同时执行修改 add10k 的方法
  • 主线程内调用10次 calc 并打印每次调用 calc 的结果,最终 count 的期望值应该是 20000
@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();
        // 创建两个线程,执行 add10K 方法
        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);
        }
    }
}

执行结果: 可以看到10次中就有5次数据产生了异常。

image-20201029181426135

问题:

回过头来再看作者给出的例子,我感觉并不能完全说明就是因为多个线程在不同 CPU 上执行,CPU 缓存不一致而导致的结果错误。

因为 count += 1 本身就不是原子操作,这里因为原子性问题也会导致结果数据错误。

所以我认为作者举的这个例子并不严谨,确实存在并发问题,但是并不能确定是缓存带来的并发问题。

于是我引入了一个新的变量,使用原子类,这样就可以抛出原子性问题带来的影响:

private static AtomicLong atomicCount = new AtomicLong(0);

 private   void add10K() {
        int idx = 0;
        while (idx++ < 10000) {
            count += 1;
            atomicCount.incrementAndGet();
        }
 }

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

每次执行完打印普通变量 count 和 原子类变量 atomicCount

可以看到,再使用 原子变量 之后就解决了数据异常的问题,所以我认为这实在无法说服导致数据异常是 CPU 缓存方面的问题。

操作系统层面 ---> 原子性问题:线程切换

操作系统层面线程切换 导致了一个操作可能没有执行完线程就失去了 CPU时间片 ,**另一个线程**如果对相同变量进行操作就会导致 原子性 问题 。

Java 并发程序 是基于 多线程 编写的,线程切换会带来原子性问题。当某个线程对变量的修改还没有彻底完成时,线程的CPU使用权被分配给了其他线程,之后就可能会带来数据异常问题。

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

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

线程切换可能发生在这三个指令的任意一条中,因为 count += 1 虽然在 Java 中是一条语句,但是对应了多个 CPU 指令,所以是一个非原子操作。

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

编程语言层面 ---> 有序性问题:编译器优化

编程语言层面:源代码 在 编译 过程中,编译器 会在不改变最终结果的情况下对语言的执行顺序进行改变,但这种优化在 并发环境 中会带来 有序性 问题。

为什么导致有序性问题的发生:

  • 编译器对于代码的重排序。

什么是重排序?

  • 在不影响程序最终结果的前提下,编译器在编译时调整了代码语句的顺序。

经典案例:

  • 使用 双重检查 创建单例对象:
// 一个单例类,会在创建实例前判断2次实例是否存在
public class Singleton {
    static Singleton instance;
    static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这个单例类看上去挺严谨的

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

但是在并发环境下可能会导致出现问题,问题就出在 构造方法 上。

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

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

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

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

两者区别就在于 ---> 编译器 优化后的情况:

  • 对象尚未初始化完成 的时候就已经被赋值给栈中的引用,此时就已经可以获得该对象了。

而此时使用引用操作这个尚未初始化完成的对象,就会引发错误,可能引起空指针异常。

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

以上是重新组织后的第一章的内容,但是我认为极客时间的课后留言与作者答复也是非常有价值的一部分,可以占到总价值的至少30%,下面是一些精选的留言,有的是很好的总结,有的正好击中了知识盲区。

高质量留言

关于可见性:

  • 关于 可见性问题, 并发问题一般都是综症,即使是单核CPU,只要线程切换就有会出现原子性问题,上面描述的可见性可以理解为吧线程对变量的读写都看作是原子操作,也就是 cpu 对 变量的操作中间状态不可见,这样可以更好的理解什么是可见性。

关于双重锁

如果线程 A 与 B 同时进入第一个分支,就不会出现问题。

如果线程A 先获取锁并出现指令重排序时线程B没有进入第一个分支,就可能出现空指针问题,出现的情况是:内存地址赋值给共享变量后, CPU 将数据写回缓存的时机是随机的。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。