Java并发编程实战 学习笔记 Day7

并发编程是比较进阶的知识,涉及到很多底层的东西,学习起来是比较困难的。并发编程的bug更多的是偶发性的,很难复现,排查起来也很困难,要想快速解决问题,就要理解并发编程的本质,追本溯源,深入分析bug的源头。

从这篇文章开始,我将记录我学习Java并发编程的所有笔记,这是第一篇,希望可以帮助到更多需要的朋友。

我们都知道CPU、内存、I/O设备三者的速度差异,程序在运行过程中,大部分都要访问内存,有些也要访问I/O,程序的整体性能取决于最慢的I/O设备,为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序都有贡献

  • CPU增加了缓存,以平衡和内存的速度差异;
  • 操作系统增加了进程、线程,来分时复用CPU,进而均衡CPU与I/O设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理利用
源头之一:缓存导致的可见性问题

单核时代,所有的线程都是在一颗CPU上执行,CPU缓存和内存的数据一致性很容易保证,因为所有的线程操作的都是同一个CPU缓存,一个线程对缓存的操在对其他线程来说是一定可见的。

多核时代,每颗CPU都有自己的缓存,这是CPU缓存与内存的数据一致性就不容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。

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

Java并发编程都是基于多线程的,自然也会涉及到任务切换。任务切换的时机大多数都是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令完成,例如代码:count += 1,至少三条CPU指令。

  • 1.首先,需要把变量count从内存加载到CPU寄存器
  • 2.之后,在寄存器中执行+1操作
  • 3.将结构写入内存。

操作系统做任务切换,可以发生在任何一条CPU指令执行完,对于上面的三条指令来说,我们假设count=0,如果线程A在指令1执行完成后线程切换,线程B开始执行,线程B把三条指令都执行完成,再切换回线程A继续执行,此时线程A执行指令2,3,最终得到的结果还是1,而不是我们期望的2

image

造成这个问题的原因是因为我们以为count+=1这个操作是一个整体,其实再多线程执行的时候,线程的切换会发生在count+=1之前也可能是之后,就不会发生在中间。我们把一个或多个操作在CPU执行的过程中不被中断的特性称为原子性

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

编译器在百衲衣程序的时候,为了优化性能,会改变语句中的先后顺序,绝大部分情况下都是没有问题的,不会影响程序的最终结果,不过有时候编译器及解释器优化导致的bug也是改到头皮发麻。

以Java中的双重检查创建单例对象代码为例:

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 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

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

看上去问题不大,实际上还是存在问题的,问题就出在new操作不是原子的,我们以为的new操作:

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

实际经过编译器优化之后的执行路径可能是:1-3-2

优化之后,假设线程A执行到第2步的是,发生了线程切换,切换到线程B,线程B也执行getInstance()方法,那么线程B执行,发现instance != null,直接返回使用,此时instance还未进行初始化,此时就可能发生空指针异常。

学好并发编程,就要深刻理解可见性、原子性、有序性在并发场景下的原理,在面对很多问题的时候就会迎刃而解。

学习来源:极客时间

猜你喜欢

转载自blog.csdn.net/qq_38862257/article/details/128666676