8.jmm内存模型
jmm:java内存模型,它的提出是为了解决多线程情况下的一些问题
1:内存可见性
在一个CPU的4核下,L1、L2、L3三级缓存与主内存的布局。
每个核上面有L1、L2缓存,L3缓存为所有核共用,通过L3向主内存(内存条)同步,L1,L2,L3都是在cpu中,L1.L2,通过L3实现同步,这种情况下,内存是可见的。
image.png
因为存在CPU缓存一致性协议,例如MESI,多个CPU核心之间缓存不会出现不同步的问题,不会有
“内存可见性”问题。
缓存一致性协议对性能有很大损耗,为了解决这个问题,又进行了各种优化。例如,在计算单元和
L1之间加了Store Buffer、Load Buffer
image.png
L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和L1之间却是异步的。向内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入L1中,同时同步写入主内存中。所以buffer和L1、L2、L3、主内存之间不是同步的,就存在内存可见性的问题。
对应到java中,就是jvm抽象内存模型,如图
2.重排序和内存可见性的关系
重排序类型:
1. 编译器重排序。
对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
2. CPU指令重排序。
在指令级别,让没有依赖关系的多条指令并行。
3. CPU内存重排序。
CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。
其中第三类是java中的内存可见性问题的主因。*
例如:
线程1:
X=1
a=Y
线程2:
Y=1
b=X
线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的结果可能是:
- a=0,b=1
- a=1,b=0
- a=1,b=1
但实际可能是a=0,b=0。两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0,b=0。原因是线程1先执行X=1,后执行a=Y,但此时X=1还在自己的Store Buffer里面,没有及时写入主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。指令没有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题
内存屏障
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。
- 编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
- CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
- 内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了。
happen-before
使用happen-before描述两个操作之间的内存可见性。
在多线程中,一方面,要让编译器和CPU可以灵活地重排序;
另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。
如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。
JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。
happen-before的传递性:
即若A happen-before B,B happen-before C,则A happen-before C。
如果一个变量不是volatile变量,当一个线程读取、一个线程写入时可能有问题。那岂不是说,在多
线程程序中,我们要么加锁,要么必须把所有变量都声明为volatile变量?这显然不可能,而这就得归功于happen-before的传递性。只需要在操作中给其中关键操作的变量声明即可。