Java并发1:并发问题源头


Java并发1:并发问题源头

硬件之间的妥协

程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论,程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的

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

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

缓存导致可见性问题

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

在单核时代,所有的线程都是在一颗 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 而言就不具备可见性了

多核CPU上的线程操作

代码验证
public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行add()操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}

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

循环 10000 次 count+=1 操作如果改为循环 1 亿次,效果更明显,最终 count 的值接近 1 亿,而不是 2 亿。

如果循环 10000 次,count 的值接近 20000,原因是两个线程不是同时启动的,有一个时差。

计算结果

变量count在内存和CPU缓存中的情况

线程切换带来的原子性问题

操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(称为“任务切换”),这个 50 毫秒称为“时间片”。

早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。

现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”

Java 并发程序都是基于多线程的,自然也会涉及到任务切换,这是并发编程里诡异 Bug 的源头之一。

任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成。

例如count += 1,至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,而不是高级语言里的一条语句。

对于上面的三条指令来说,假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么会发现两个线程都执行了 count+=1 的操作,但得到的结果不是期望的 2,而是 1。

非原子操作示意图

一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。

CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背直觉的地方。

因此,很多时候我们需要在高级语言层面保证操作的原子性。

编译优化带来的有序性问题

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

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

在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例getInstance() 的方法中,我们首先判断 instance是否为空,如果为空,则锁定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),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查instance == null时会发现,已经创建过 Singleton实例了,所以线程 B 不会再创建一个 Singleton实例。

但实际上这个getInstance()方法并不完美,问题出在 new 操作上。

我们以为的 new 操作应该是:

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

实际上优化后的执行路径:

  • 分配一块内存 M;
  • 将 M 的地址赋值给 instance 变量;
  • 最后在内存 M 上初始化 Singleton 对象。

假设线程 A 先执行 getInstance() 方法,当执行完指令 2 (将 M 的地址赋值给 instance 变量)时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行getInstance()方法,那么线程 B 在执行第一个判断时会发现 instance != null(只有指针没有实例),所以直接返回instance,而此时的instance 是没有初始化过的,如果这个时候访问 instance 的成员变量就可能触发空指针异常。

双重检查单例执行流程

总结

可见性:由于多核CPU并行处理多线程,并利用各自CPU缓存对共享数据的计算结果导致线程之间修改的共享数据对各自不可见,造成程序执行错误。

单核CPU可以保证可见性,但如果不加锁,对共享数据的操作仍不安全,会出现原子性问题。

原子性:高级语言一行代码由多个CPU指令完成,而任务切换可能发生在任意一条CPU指令完成之后。造成了高级语言的代码不具备原子性。

有序性:编译器为了优化程序执行效率而对代码对应的执行的CPU指令进行顺序优化,导致指令执行过程顺序发生改变,上升到代码层次出现BUG。

思考

32 位的机器上对 long 型变量进行加减操作是否存在并发隐患?

long类型是64位,在32位机器上进行计算要多条指令执行才能完成,会有并发隐患。

别人的总结

如果对instance进行volatile语义声明,就可以禁止指令重排序,避免该情况发生。
CPU缓存不存在于内存中的,它是一块比内存更小、读写速度更快的芯片,至于什么时候把数据从缓存写到内存,没有固定的时间。

对于有volatile语义声明的变量,线程A执行完后会强制将值刷新到内存中,线程B进行相关操作时会强制重新把内存中的内容写入到自己的缓存,这就涉及到了volatile的写入屏障问题,当然也就是所谓happen-before问题。

——Jialin

——CPU缓存刷新到内存的时机——
cpu将缓存写入内存的时机是不确定的。除非调用cpu相关指令强刷

——指令优化——
除了编译优化,有一部分可以通过看汇编代码来看,但是CPU和解释器在运行期也会做一部分优化,所以很多时候都是看不到的,也很难重现。

——JMM模型和物理内存、缓存等关系——
内存、cpu缓存是物理存在,jvm内存是软件存在的。
关于线程的工作内存和寄存器、cpu缓存的关系 大家可以参考这篇文章
https://blog.csdn.net/u013851082/article/details/70314778/

——IO操作——
io操作不占用cpu,读文件,是设备驱动干的事,cpu只管发命令。发完命令,就可以干别的事情了。

——寄存器切换——
寄存器是共用的,A线程切换到B线程的时候,寄存器会把操作A的相关内容会保存到内存里,切换回来的时候,会从内存把内容加载到寄存器。可以理解为每个线程有自己的寄存器。

——别皱眉


文章作者: Wendell
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Wendell !
评论
  目录