• 可见性、原子性和有序性问题


    可见性、原子性和有序性问题

    并发编程背景

    核心矛盾

    这些年,我们的 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异
    我形象的描述了一下这三者的速度上的差异:所谓天上一天地上一年(爱因斯坦的相对论是有合理解释的),CPU和内存之间的速度差异就是CPU天上一天,内存地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和I/O设备的速度差异就更大了,内存是天上一天,I/O设备是地上十年。

    木桶原理

    一只水桶能装多少水取决于它最短的那块木块
    程序的大部分语句都要访问内存,有些还要访问I/O,根据木桶原理,程序整体的性能取决于最慢的操作,所以只是单方面的提高CPU的性能是无效的,才出现了一些提高CPU性能使用率的优化

    如何提高CPU的性能?
    1. CPU增加了缓存,以均衡与内存的速度差异(计算机体系结构方面优化)
    2. 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异(操作系统方面优化)
    3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用(编译程序带来的优化)

    很多时候人们总是会很开心享受眼前的好处,却总是忽略了一句话:采用一项技术的同时,一定会产生一个新的问题,并发程序很多的诡异问题的根源也从这里开始

    可见性

    单核时期,所有的线程都是操作同一个CPU的,所以CPU的缓存也是线程之间可见的,如下图所示:
    线程1和线程2都是编辑同一个CPU里面的缓存,所以线程1更新了变量A的值,线程2之后再访问变量A,得到的一定是A的最新值

    一个线程对共享资源的修改,另一个线程能够立刻看到,称之为可见性
    多核时代,每个CPU都有自己单独的缓存,当多个线程在不同的CPU上执行时,这些线程操作的就是不同的CPU缓存了,如下图所示:线程1在CPU-1的缓存上编辑变量A,线程2在CPU-2的缓存上编辑变量A,这个时候线程1对变量A的操作对于线程2来说就不具备可见性

    二话不说,上代码解释是最直接的方式
    下面这段代码创建了两个线程,每个线程都会调用一次updateVar的方法,都会循环10000次的sharedVariable += 1操作,然后打印出共享变量的结果。

    /**
     * 可见性测试Demo
     *
     * @author BO
     * @date 2019-03-25
     */
    public class VisibilityTest {
    
        private long sharedVariable = 0;
    
        private void updateVar() {
            int times = 0;
            while (times++ < 10000) {
                sharedVariable += 1;
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            final VisibilityTest test = new VisibilityTest();
            // 创建两个线程,执行修改方法
            Thread thread1 = new Thread(() -> {
                test.updateVar();
            });
            Thread thread2 = new Thread(() -> {
                test.updateVar();
            });
            // 启动线程
            thread1.start();
            thread2.start();
            // 等待两个线程执行结束
            thread1.join();
            thread2.join();
            System.out.println("执行后共享变量的值为:" + test.sharedVariable);
        }
    }
    

    讲道理,这里应该是要输出执行后共享变量的值为:20000的,因为单线程里调用两次updateVar方法,sharedVariable的值就是20000,但实际上,我执行了n次(手都要点的酸死),执行的结果是10000个到20000之间的随机数。
    我们假设线程1和线程2同时开始执行,那么第一次都会将sharedVariable = 0读取到各自的CPU缓存里,执行了updateVar方法后,各自缓存中的sharedVariable的值都是1,同时写入内存后,内存中的sharedVariable是1而不是我们讲道理的2,这就是缓存的可见性问题
    这里因为两个线程的启动时有时差的,假设两个线程是同时执行的,这里返回的结果应该是10000

    原子性

    操作系统带来的多进程大家都体验过,我们可以一边听歌一边聊天就是多进程带来的好处

    操作系统允许进程执行一段时间,假设100ms,过来100ms操作系统就会重新选择进程来执行,这个就是上面提到的分时服用CPU的原理,操作系统以100ms作为时间片进行任务切换

    在一个时间片内,如果一个进程进行一个IO操作,加入读一个文件,这个时候进程可以把自己标记为“休眠状态”并让出CPU的使用权,等待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权

    这个就是分时复用CPU的过程,这样很好的提高了CPU的使用率

    但是由于进程之间是不能进行内存空间的共享的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了,我们接下来讲的就是对于线程的任务切换,也就是线程切换

    我们现在使用的都是高级编程语言,高级编程语言里一条语句需要多条CPU指令完成。举个简单的列子,上面的代码中,sharedVariable += 1,在操作系统中至少需要三条CPU指令才能完成

    • sharedVariable从内存加载到CPU的寄存器;
    • 在寄存器中执行 +1 操作
    • 把结果写入内存(缓存机制可能导致写入到了CPU的缓存)

    在操作系统中,进行任务切换会发生在任何一条CPU指令执行完,假设任务切换发生在第一步,如下图就是两个线程的执行过程

    这个时候就导致本来是两次 +1 操作,结果到内存的实际值仍然是1

    在化学上我们称化学反应不可再分的基本微粒成为原子,如果我们不熟悉操作系统的执行规则,我们会默认为sharedVariable += 1就是一个原子,可能知道有线程切换会发生在这个操作之前,也或者这个操作之后,就是不会发生在这个操作中间,这就是我们经常忽略的原子性问题

    所谓原子性,我们把一个或者多个操作在CPU执行的过程中不被中断的特性成为原子性,就像化学反应上的原子一样,是最小的单位了,不可分割

    有序性

    编译器为了性能的优化,有时候会改变程序中语句的先后顺序,有序性指的是程序按照代码的先后顺序执行,可能大家很疑惑这个怎么也会导致并发问题呢?请看下面的一个实例

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

    上面这段代码是一个典型的双重检查创建实例对象,在获取实例getInstance方法中,先判断了实例instance == null,如果为空就锁定Singleton.class类,然后再检查实例是否为空,如果还为空就创建一个Singleton实例。

    此时两个线程1和2同时调用getInstance方法,它们会同时都发现了实例为空,于是同时对Singleton.class类进行了加锁,这里JVM保证只有一个线程加锁成功(不要问我为什么,就是这样的),这里假设线程1成功获取到了锁,线程1就会去创建Singleton实例,然后释放锁,线程2检查instance == null时发现不为空,就不会再实例化了,这是不是很完美的代码。

    但实际上,这里会出现一个很隐蔽的问题,问题就在这个new Singleton()中,按照实例化的顺序,new操作是这样的:

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

    但是实际上编译器认为第2步和第3步对结果并没有影响,所以根据实际情况进行了优化,变成了:

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

    这个时候如果线程1获取到锁后,开始实例化Singleton对象,进行第2步操作结束后,操作系统进行了任务切换,此时线程2在进行第一个instance == null检查时,发现instance变量已经不为空,所以就直接返回了这个实例,但实际上这个实例还没有进行初始化,当程序中使用这个实例进行获取这个实例的成员变量时,就会出现NPE异常了,这个就是有序性导致的并发问题

    总结

    只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发Bug都是可以解决、可以诊断的

    实战

    在 32 位的机器上对 long 型变量进行加减操作存在并发隐患,到底是不是这样的呢?
    因为long型变量是64位,在32位CPU上执行写操作,会被分成两次写操作,所以这个操作并不是原子性的,如果线程1在完成第一次写操作后,出现线程切换,线程2将这个变量进行其他操作,就会导致线程1的这次操作存在问题

  • 相关阅读:
    多校省选模拟33 C
    省选测试47 T2宝石(P5401 [CTS2019]珍珠) 题解
    省选测试39
    省选测试38
    省选测试37
    自嗨测试赛4
    自嗨测试赛3
    自嗨测试赛2
    自嗨测试赛1
    树状数组 学习笔记
  • 原文地址:https://www.cnblogs.com/bolbo/p/10593810.html
Copyright © 2020-2023  润新知