一切并发编程问题的源头:可见性、原子性、有序性

目录

一、硬件带来的矛盾

  • CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异,CPU的运行速度>>内存的速度>>IO设备的速度。程序里大部分语句都要访问内存,有些还要访问 I/O,程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。
  • 为了合理利用 CPU 的高性能平衡这三者的速度差异1.CPU 增加了缓存,以均衡与内存的速度差异2.操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异3.编译程序优化指令执行次序,使得缓存能够得到更加合理地利用
  • 总之:CPU硬件增加缓存、软件增加线程、编译程序优化指令顺序

二、矛盾衍生的问题

2.1 缓存导致的可见性问题

可见性:一个线程对共享变量的修改,另一个线程能立刻看到

  • 单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。例如在下面的图中,线程 A 和线程 B 都是操作同一个 CPU里面的缓存,所以线程 A 更新了变量 V 的值,那么线程 B 之后再访问变量 V,得到的一定是 V 的最新值(线程 A 写过的值)。
    在这里插入图片描述
  • 多核时代每颗 CPU 都有自己的缓存(多核时代,目前每个CPU都是L3结构,即一级缓存(L1P|L1D)、二级缓存(L2)、三级缓存(L3),L1、L2 CPU私有,L3 CPU共享,内存中值与缓存中值遵循MESI),这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。
    在这里插入图片描述

2.2 线程切换导致的原子性问题

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

  • 早期的操作系统基于进程来调度 CPU不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。
  • Java 并发程序都是基于多线程的,自然也会涉及到任务切换。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
    指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
    指令 2:之后,在寄存器中执行 +1 操作;
    指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
  • 操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
  • CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

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

  • 在 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 变量。但是实际上优化后的执行路径却是这样的1.分配一块内存 M;2.将 M 的地址赋值给 instance 变量;3.最后在内存 M 上初始化 Singleton 对象。优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
    在这里插入图片描述

  • 补充重点
    1)对于双重锁的问题,线程A进入第二个判空条件,进行初始化时,发生了时间片切换,即使没有释放锁,线程B刚要进入第一个判空条件时,发现条件不成立,直接返回instance引用,不用去获取锁。如果对instance进行volatile语义声明,就可以禁止指令重排序,避免该情况发生。
    2)CPU缓存不存在于内存中的它是一块比内存更小、读写速度更快的芯片,至于什么时候把数据从缓存写到内存,没有固定的时间,同样地,对于有volatile语义声明的变量,线程A执行完后会强制将值刷新到内存中,线程B进行相关操作时会强制重新把内存中的内容写入到自己的缓存,这就涉及到了volatile的写入屏障问题,当然也就是所谓happen-before问题。

三、问题总结

------可见性问题------

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

并发问题往往都是综合证,这里即使是单核CPU,只要出现线程切换就会有原子性问题。我们可以把线程对变量的读可写都看作时原子操作,也就是cpu对变量的操作中间状态不可见,这样就能更加理解什么是可见性了

------CPU缓存刷新到内存的时机------
cpu将缓存写入内存的时机是不确定的。除非你调用cpu相关指令强刷,并且cpu缓存不是在内存上,而是一块芯片硬件上

------双重锁问题------
如果A线程与B线程如果同时进入第一个分支,那么这个程序就没有问题,如果A线程先获取锁并出现指令重排序时,B线程未进入第一个分支,那么就可能出现空指针问题,这里说可能出现问题是因为当把内存地址赋值给共享变量后,CPU将数据写回缓存的时机是随机的

------ synchronized------
上面的双重锁问题中,线程在synchronized块中,发生线程切换,锁是不会释放的。

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

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值