下图为x86架构下CPU缓存的布局,即在⼀个CPU 4核下, L1、 L2、 L3三级缓存与主内存的布局。每个核上⾯有L1、 L2缓存, L3缓存为所有核共⽤。
由上图我们可以知道,CPU中每个核在执行线程时,都会创建一个本地缓存来保存主内存中的数据。在修改后,再异步写会给主内存。多个线程间对于变量的交互,是通过主内存来进行的。那么这个过程中,就存在一个多个线程间本地缓存同步的问题。比如说,现在对于一个主内存中的变量x=3,线程A将该值修改为x=4,但是,在线程A将数据同步到主内存前,线程B中就读取了数据。那么线程B中就讲讲本该x=4的值读成了x=3,造成了线程间同步的错误。
上述的例子中,涉及到了多线程间缓存的一致性问题,同时,线程B的读线程先于线程A的写线程执行,这种错误也叫指令重排序的问题。接下来,这对这几张问题,我们进行详尽的讨论和分析。
上述例子中,指令的执行顺序和写入主内存的顺序不完全一致的问题,是重排序的一种,也成为内存重排序。除此之外,还有编译器和CPU的指令重排序。
重排序类型:
在三种重排序中,第三类就是造成上述“内存可见性”问题的主因,如下案例:
线程1:
X=1
a=Y
线程2:
Y=1
b=X
假设X、 Y是两个全局变量,初始的时候, X=0, Y=0。请问,这两个线程执完毕之后, a、 b的正确结果应该是什么?
很显然,线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的结果可能是:
1. a=0,b=1
2. a=1,b=0
3. a=1,b=1
也就是不管谁先谁后,执行结果应该是这三种场景中的⼀种。但实际可能是a=0, b=0。
两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0, b=0。原因是线程1先执行X=1,后执行=Y,但此时X=1还在自己的本地缓存里面,没有及时写⼊主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。
虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来, a=Y和X=1顺序却是颠倒的。指令没有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题。
在解决上述重排序的问题前,先来了解一下CPU的指令重排序。
我们知道,当代码被编译执行之后,CPU为了提高执行效率,会对代码进行一定程度的重排序。比如,对于代码中,两个互相不影响的部分,如果CPU执行重排序,由顺序执行重排序为并行执行,就会在一定程度上提高执行效率。
那么一个问题就是:重排序的原则是什么?什么场景下可以重排序,什么场景下不能重排序呢?
⽆论什么语⾔,站在编译器和CPU的⻆度来说,不管怎么重排序,单线程程序的执⾏结果不能改变,这就是单线程程序的重排序规则。
即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地⼀行行从头执行到尾,这也就是as-if-serial语义。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可⻅性问题。
编译器和CPU的这⼀行为对于单线程程序没有影响,但对多线程程序却有影响。
对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。
编译器和CPU只能保证每个线程的as-if-serial语义。
线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。
因此,对于多线程下的重排序来说,需要设计方案来告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。
使用happen-before描述两个操作之间的内存可见性。
如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。
A happen before B不代表A⼀定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。
happen-before 只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了⼀系列重排序的约束。
针对上述的规范,java定义了一套自己的内存模型规范:内存模型(JMM),在多线程中
根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。
基于happen-before的这种描述⽅法, JMM对开发者做出了⼀系列承诺:
JMM对编译器和CPU 来说, volatile 变量不能重排序;非volatile 变量可以任意重排序。
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理 。
内存屏障就是一类同步屏障指令,是CPU或者编译器在对内存随机访问的操作中的一个同步点,只有在此点之前的所有读写操作都执行后才可以执行此点之后的操作。
编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
内存屏障是很底层的概念,对于 Java 开发者来说,⼀般用volatile 关键字就足够了。 后面会详细讲解volatile关键使用内存屏障解决内存可见性问题的原理。
在理论层⾯,可以把基本的CPU内存屏障分成四种:
StoreLoad同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。
有了内存屏障,针对上述案例中,线程A和线程B的可见性问题和内存重排序问题,就可以在线程A的写和线程B的读之间加一层StoreLoad内存屏障,来禁止写和读的重排序。也就是线程B的读一定在线程A的写之后执行,从而让两个线程之间代码的执行有了正确的顺序性。
volatile关键字解决内存可见性问题是利用内存屏障来实现的。
下面我们来看看 volatile 读 / 写时是如何插入内存屏障的。volatile关键字的两层语义
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰后,那么就具备了两层语义
Volatile关键字的作用:
Volatile关键字的实现原理
由于不同的CPU架构的缓存体系不⼀样,重排序的策略不⼀样,所提供的内存屏障指令也就有差异。
这⾥只探讨为了实现volatile关键字的语义的⼀种参考做法:
具体到x86平台上,其实不会有LoadLoad、 LoadStore和StoreStore重排序,只有StoreLoad⼀种重排序(内存屏障),也就是只需要在volatile写操作后⾯加上StoreLoad屏障。