内存模型
从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Momory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编辑器优化。
Java 内存模型带来的问题
可见性问题
左边 CPU 中运行的线程从主内存中Copy共享对象 obj 到它的 CPU 缓存,把对象 obj的 count 变量改为 2 。但这个变更对运行在右边的CPU 中线程不可见,因为这个改变该没有 flush 到主内存中。
在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要把工作内存中读取该变量即可。通样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。但是什么时候最新的值被刷新到主内存中是不太确定,一般来说会很快,但具体时间不知。
要解决共享变量可见性这个问题,我们可以使用volatile 关键字或者是加锁。
竞争问题
线程A和线程B共享一个对象obj。假设线程A从内存读取Obj.count 变量到自己的CPU缓存,同时,线程 B 也读取了Obj.count 变量到它的CPU缓存,并且这两个线程都对 Obj.count 做了 +1 操作,此时 Obj.count +1操作被执行了两次,不过都在不同的CPU缓存中。
按照写代码的逻辑如果这两个 +1 操作时串行执行的,那么 Obj.count 变量会在原始值上 +2 最终内存中的 Obj.count 的值会是 3 ,然后图中两个 +1 操作时并行的,不管是线程 A 还是线程 B 先 flush 计算结果到主存,最终主存中的 Obj.count 只会增加 +1 变成 2 ,尽快一共有两次 +1 操作。要解决上面的问题就需要锁了,比如说 Synchronized 代码块
重排序
- 重排序类型
除了共享内存和工作内存带来的问题,还存在重排序的问题:在执行程序时,为了提高性能,编辑器和处理器常常会对指令做重排序。重排序分 3 种类型。
1)编辑器优化的重排序。编辑器在不改变单线程程序的语义的前提下,可以安排语句的执行顺序
2)指令集并行的重排序。现代处理器采用了指令集并行技术(ILP)来将多条指令重叠执行,如果不存在数据关系,处理器可以改变语句对应机器执行的执行顺序
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行
- 数据依赖性
如果两个操作同时访问一个变量,且这两个操作中有一个为写操作,此时两个数据旧存在了依赖性,数据依赖分为下列 3 种类型,上面三种情况,直接情况,只要重排序两个操作的执行顺序,程序的执行结果就会改变。
例如:
很明显, A 和 C存在数据依赖,B 和 C也存在数据依赖, 而 A 和 B 之间不存在数据依赖,如果重排序了 A 和 C 或者 B 和 C的执行顺序,程序的执行结果就会被改变。很明显,不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念。
- as-if-serial
as-if-serial语义的意思是:不管怎样重排序(编译器和处理器为了提高并行读),(单线程)程序的执行结果不会被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编辑器和处理器不会对存在内存依赖的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖仅针对单个处理器执行指令序列和单个线程中执行的操作,不同的处理器之间的不同线程之间的数据依赖性不会编译器和处理器考虑)但是,如果操作之间不存在数据依赖关系,这些操作依然可以被编译器和处理器重排序。
A 和 C 之间存在数据依赖关系,同时 B 和 C 也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排 到 A 和 B 的前面,程序的结果将会被改变)。但A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器可以让我们感觉到:单线程程序看起来是按程序的顺序来执行的。asif-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
-
控制依赖性
上述代码中,flag变量是个标记,用来标识变量a是否已被写入,在use方法中变量i的赋值依赖if (flag)的判断,这里就叫控制依赖,如果发生了重排序,结果就不对了。
考察代码,我们可以看见,操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。操作3和操作4则存在所谓控制依赖关系。
在程序中,当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。猜测执行实质上对操作3和4做了重排序,问题在于这时候,a的值还没被线程A赋值。
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因)。
但是对多线程来说就完全不同了:这里假设有两个线程A和B,A首先执行init ()方法,随后B线程接着执行use ()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?答案是:不一定能看到。
让我们先来看看,当操作1和操作2重排序,操作3和操作4重排序时,可能会产生什么效果?操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,这时就会发生错误!
所以在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
内存屏障
Java编译器在生成该指令序列的在适当位置插入内容屏障指令来禁止特定类型的处理器重排序,从而让程序按照我们预想的流程去执行。
- 保证特定操作的执行顺序
- 影响某些数据(或则是某条指令的执行结果)的内存可见性
编译器和CPU 能够重排序的指令,保证最终相同的结果,尝试优化性能。插入一条 Memory Barrier 会告诉编译器和 CPU:不管什么指令都不能和这条 Momory Barrier 指令重排序。
Momory Barrier所做的另外一件事是强制刷出各种 CPU cache ,入一个 Write-Barrier (写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此, 任何CPU 上的线程都能读取到这些数据的最新版本。
JMM把内存屏障指令分为4类
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。
临界区
JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得多线程在这两个时间点按某种顺序执行。
临界区内的代码则可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
想一下,为啥线程安全的单例模式中一般的双重检查不能保证真正的线程安全?