重新解读JMM与volatile

最近面试面的有些自闭,问得越来越深入了,所以决定重新读一下《Java并发编程的艺术》,同时结合一些其他的文章,来深入地解读一下JMM与volatile

一、现代计算机的内存模型

在这里插入图片描述

其实早期计算机中CPU和内存的速度是差不多的,但在现代计算机中,CPU的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲

将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)

在这里插入图片描述

我们现在用的CPU,通常都是多核的的。每一个CPU核里面,都有独立属于自己的L1、L2的Cache,然后再有多个CPU核共用的L3的Cache、主内存。因为CPU Cache的访问速度要比主内存快很多,而在CPU Cache里面,L1/L2的Cache也要比L3的Cache快

二、Java内存模型

在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响

JMM本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

在这里插入图片描述

三、volatile

1、volatile的特性

volatile是Java提供的轻量级的同步机制

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排序

2、CPU缓存一致性问题

在这里插入图片描述

volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,Lock前缀的指令在多核处理器下会引发以下两件事情:

  • 将当前处理器缓存行的数据写回到系统内存
  • 这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后在进行操作,但写回操作不知道这个更改何时回写到内存,但是对变量使用volatile进行写操作时,JVM就会向处理器发送一条lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存

在多处理器下,为保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里

3、指令重排问题

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序

在这里插入图片描述

一般重排序可以分为如下三种:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的

as-if-serial:不管怎么重排序,单线程下的执行结果不能被改变

编译器在生成字节码时,会在指令序列中插入内存屏障(内存屏障是一组处理器指令,用于实现对内存操作顺序的限制)来禁止特定类型的处理器重排序

内存屏障类型

在这里插入图片描述

volatile写

在这里插入图片描述

在每个volatile写操作的前面插入一个StoreStore屏障,可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存

在每个volatile写操作的后面插入一个StoreLoad屏障,其作用是避免volatile写与后面可能有的volatile读/写操作重排序(也对应了对一个volatile变量的写操作happens-before后面对这个变量的读操作)

volatile读

在这里插入图片描述

在每个volatile读操作的后面插入一个LoadLoad屏障,用来禁止处理器把上面的volatile读与下面的普通读重排序

在每个volatile读操作的后面插入一个LoadStore屏障,用来禁止处理器把上面的volatile读与下面的普通写重排序

四、原子操作的实现原理

1、使用总线锁保证原子性

总线锁使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存

但总线锁把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大

2、使用缓存锁保证原子性

当某个处理器想要更新主存中的变量的值时,如果该变量在CPU的缓存行中,执行写回主存操作时,CPU通过缓存一致性协议,通知其它处理器使其它处理器上的缓存失效并重新从主存读取,以此来保证原子性

五、MESI协议

MESI协议是一种叫作写失效的协议。在写失效协议里,只有一个CPU核心负责写入数据,其他的核心只是同步读取到这个写入。在这个CPU核心写入Cache之后,它会去广播一个失效请求告诉所有其他的CPU核心。其他的CPU核心只是去判断自己是否也有一个失效版本的Cache Line,然后把这个也标记成失效的就好了

MSEI其实就是四种状态首字母的简写,就代表Cache Line的四种状态,分别是:

状态 描述
M(Modified,已修改) 该Cache Line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中
E(Exclusive,独占) 该Cache Line有效,数据和内存中的数据一致,数据只存在于本Cache中
S(Shared,共享) 该Cache Line有效,数据和内存中的数据一致,数据存在于很多Cache中
I(Invalidated,无效) 该Cache Line无效

在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。这个共享状态是因为,这个时候,另外一个CPU核心,也把对应的数据从内存里面加载到了自己的Cache里来

在共享状态下,因为同样的数据在多个CPU核心的Cache里都有。所以,当想要更新Cache里面的数据的时候,不能直接修改,而是要先向所有的其他CPU核心广播一个请求,要求先把其他CPU核心里面的Cache,都变成无效的状态,然后再更新当前Cache里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是获取当前对应数据的所有权

六、happens-before

1、happens-before的7个规则

1)程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构

2)管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序

3)volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序

4)线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作

5)线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行

6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生

7)对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

2、happens-before的1个特性:传递性

A happens-before B,B happens-before C,可以推出A happens-before C

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/108558685