JMM定义了Java虚拟机(JVM )在计算机内存(RAM )中的工作方式。 因为JVM是整个计算机的虚拟模型,所以JMM属于JVM。 抽象地看,JMM定义了线程和主存储器之间的抽象关系。 线程之间的共享变量存储在主存储器(Main Memory )中,每个线程都有一个专用的本地存储器,该线程存储在本地存储器中以读写共享变量的副本。 本地存储器是JMM的抽象概念,实际上是不存在的。 它涵盖缓存、写缓冲区、寄存器以及其他硬件和编译器优化。
java程序的简单调用图
三种性质
JMM是围绕并发程序中的原子性、可见性、有序性三个特征构建的。
原子性
一个操作不能中断。 所有执行成功还是所有执行失败与事务相似。 原子变量操作包括读取、加载、辅助、使用、存储和写入。 Java基本型的数据访问几乎都是原子操作,但long和double的类型为64位,在32位的JVM中,由于64位的数据读写操作分两次用32位进行处理,所以long和double为32位的
可见性:
一个线程对主存储器的更改会立即被其他线程观察到。 可见性问题的根本原因:缓存。
有序性
有序性是指程序按照代码的优先级运行。 有序性的根本原因:指令排序
线程中存在本地工作内存,是线程共享的主内存。 规则:所有线程都不能直接操作主存。 首先需要访问工作存储器,然后访问主存储器。
轻松访问内存
源代码排序CPU级排序(指令级、内存级) )最终执行的指令
在Java内存模型中对指令进行排序并不影响单线程的执行顺序,但会影响多线程并发的准确性,因此必须考虑如何保证并发代码的有序性; Java中可以通过volatile关键字保证一定的有序性,也可以通过synchronized,Lock保证有序性。 synchronized,Lock由于保证了各时刻执行同步代码的线程只有一个,相当于单线程执行,所以当然不存在秩序性的问题; 再加上Java内存模型如果能根据happens-before原则导出两个操作的执行顺序就能先天地保证其有序性,否则就无法保证。 从Java内存模型中可以看出,多线程访问共享变量都要经过从线程工作存储器到主存储器的复制和从主存储器到线程工作存储器的复制操作,所以普通的共享变量不能保证可见性; Java提供volatile修饰符以保证变量的可见性,每次使用volatile变量时,它都会主动从主存中刷新。 否则,同步、锁定和最终将保证变量的可见性。
CPU缓存
CPU/存储器/IO的三种计算速度相差很大,CPU存储器IO; 在计算机中,cpu和内存交换最频繁,与内存相比磁盘读写太慢,内存相当于高速的缓冲区。 随着多核cpu时代的到来,内存的读写速度远远不如cpu。 因此,在cpu中出现了缓存的概念。 cpu有一个内置的高速缓存,用于解决处理器和内存访问速度之间的差异。 在多核cpu中,每个处理器只有一个缓存(L1、L2、L3 )和一个主内存。 结构如下
CPU缓存模型
L1是一级缓存,L1d是数据缓存,L1i是指令缓存
L2是稍大于L1的二级高速缓存
L3是L3缓存,L3缓存是cpu共享缓存,主要目的是进一步降低内存操作的时延问题。
CPU-01读取数据a,数据a被读取到CPU-01的高速缓存中。
CPU-02读取数据a,并将其存储在CPU-02高速缓存中。 这样,CPU1、CPU2的缓存具有相同的数据。
CPU-01修正了数据a。 修改后,数据a已返回到CPU-01缓存行,但尚未写入主存储器。
CPU-02访问了数据a,但由于CPU-01没有将数据a写入主存储器,因此发生了数据不一致的情况。
缓存会带来缓存不一致的问题,包括CPU级别的解决方案、总线锁定和缓存锁定
总线锁定:处理器锁定。 公共汽车被锁了。 一旦锁定,CPU将序列化,从而降低效率。 (CPU与其他部件的通信通过总线进行。 线程a与主存储器通信时,保持锁定。 此时,其他线程无法与主存储器进行通信)
高速缓存锁定:简单地说,如果一个内存区域的数据a同时被两个或多个处理器内核高速缓存,则高速缓存锁定会通过高速缓存一致性机制阻止更改,从而确保操作的原子性。 如果其他处理器回写锁定的高速缓存线中的数据,则高速缓存线将被禁用。
缓存行: CPU缓存中的最小缓存单位
目前主流的CPU Cache的Cache Line大小均为64字节。 假设有一个512字节的l1高速缓存,按64B的高速缓存单位大小计算,该l1高速缓存可以存储512/64=8个高速缓存。
对于多核CPU,每个c
PU都有独立的一级缓存,如何才能保证缓存内部数据的一致?一致性的协议MESI。缓存一致性协议MESI
MESI实现方法是在CPU缓存中保存一个标记位,以此来标记四种状态。另外,每个CPU的缓存控制器不仅知道自己的读写操作,也监听其它CPU的读写操作,就是嗅探(snooping)协议。
缓存状态
MESI 是指4种状态的首字母。每个缓存行有4个状态,可用2个bit表示,它们分别是:
状态
描述
监听任务
M 修改 (Modified)
该缓存行有效,但是数据A被修改了,和内存中的数据A不一致,数据只存在于本CPU中。
缓存行必须时刻监听所有试图读取主存中旧数据A的操作,当数据A写回主存并将状态变成S(共享)状态之后,解除该监听。
E 独享(Exclusive)
该缓存行有效,数据A和内存中的数据A一致,数据A只存在于本CPU中。
缓存行必须监听其它CPU读取主存中该数据A的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared)
该缓存行有效,数据A和内存中的数据A一致,数据A存在于很多CPU中。
缓存行必须监听其它CPU使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid)
该缓存行无效。
无
对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
状态转换
MESI状态转换
本地:本CPU操作
远程:其他CPU操作
本地读:无效状态的更新主存变成独享或共享;其他状态维持本状态。
本地写:M状态保持不变;共享状态、独享状态变M状态;无效状态更新主存并修改变为M修改状态。
远程读:独享变共享;共享不变;修改变共享;
远程写:所有状态变无效。
MESI带来的问题
缓存的一致性消息传递是耗时的,CPU切换时会产生延迟。当一个CPU发出缓存数据A的修改消息(缓存行状态修改消息等)时,该CPU会等待其他缓存了该数据A的CPU响应完成。该过程导致阻塞,阻塞会存在各种各样的性能问题和稳定性问题。
阻塞原因
存储缓存-Store Bufferes
为了解决CPU状态切换的阻塞问题,避免CPU资源的浪费,引入Store Bufferes。CPU把它想要写入到主存的值写到Store Bufferes,然后继续去处理其他事情。当其他CPU都确认处理完成时,数据才会最终被提交。
看一下该过程代码演示:
value = 3;
void cpu_01(){
value = 10;//此时cpu_01发出消息,cpu_02变为I状态(store buffer 和 通知其他缓存行失效)(异步)
isFinsh = true; //标记上一步操作发送消息完成,cpu_01修改->M状态,同步value和isFinish到主存;
}
void cpu_02(){
if(isFinsh){
//value一定等于10?!
assert value == 10;
}
}
Store Bufferes的风险:isFinsh的赋值可能在value赋值之前。
这种在可识别的行为中发生的变化称为指令重排序(指令级别的优化)。它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。为了解决这个问题,引入了内存屏障。
失效队列
缓存行状态修改不是一个简单的操作,它需要CPU单独处理,另外,Store Buffers大小有限,所以CPU需要等待状态修改确认处理完成的响应。这两个操作都会使得性能大幅降低。为了解决这个问题,又引入了失效队列。
由于CPU指令优化导致了问题,所以又提供了内存屏障的指令,明确让程序员告诉CPU什么地方的指令不能够优化
指令重排
前提:指令重排只针对单个处理器 和 编译器的单个线程 保证响应结果不变。
分两个层面:编译器和处理器的指令重排。
源代码-》编译器的重排序-〉CPU层面的重排序(指令级,内存级)-》最终执行的指令
看几个概念
1.数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
说明
代码
描述
写后读
a=1 ; b=a ;
写一个变量之后,再读该变量
写后写
a=1 ; a=2 ;
写一个变量之后,再写该变量
读后写
b=a ; a=1 ;
读一个变量之后,再写该变量
2.as-if-serial 语义:不管怎么重排序,(单处理器/单线程)执行结果不变。编译器和处理器都必须遵守。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖性的操作做重排序,因为这种重排序会改变执行结果。
程序顺序规则:先行发生happens- before
重排序需要遵守happens-before规则,不能说你想怎么排就怎么排,如果那样岂不是乱了套。
1.程序顺序规则
程序顺序规则中所说的每个操作happens-before于该线程中的任意后续操作,并不是说前一个操作必须要在后一个操作之前执行,而是指前一个操作的执行结果必须对后一个操作可见,如果不满足这个要求那就不允许这两个操作进行重排序。对于这一点,可能会有疑问。顺序性是指,我们可以按照顺序推演程序的执行结果,但是编译器未必一定会按照这个顺序编译,但是编译器保证结果一定==顺序推演的结果。
2.监视器锁规则
对一个锁的解锁,happens-before于随后对这个锁的加锁。同一时刻只能有一个线程执行锁中的操作,所以锁中的操作被重排序外界是不关心的,只要最终结果能被外界感知到就好。除了重排序,剩下影响变量可见性的就是CPU缓存了。在锁被释放时,A线程会把释放锁之前所有的操作结果同步到主内存中,而在获取锁时,B线程会使自己CPU的缓存失效,重新从主内存中读取变量的值。这样,A线程中的操作结果就会被B线程感知到了。
3.volatile变量规则
对一个volatile域的写,happens-before于任意后续对这个volatile域的读。volatile变量的操作会禁止与其它普通变量的操作进行重排序。volatile变量的写操作就像是一条基准线,到达这条线之后,不管之前的代码有没有重排序,反正到达这条线之后,前面的操作都已完成并生成好结果。
4.传递性规则
A happens- before B;B happens- before C;==》A happens- before C;推导出
5.线程启动规则
如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。线程启动规则可以这样去理解:调用start方法时,会将start方法之前所有操作的结果同步到主内存中,新线程创建好后,需要从主内存获取数据。这样在start方法调用之前的所有操作结果对于新创建的线程都是可见的。
6.线程结束规则
线程中的任何操作都Happens-Before其它线程检测到该线程已经结束。假设两个线程s、t。在线程s中调用t.join()方法。则线程s会被挂起,等待t线程运行结束才能恢复执行。当t.join()成功返回时,s线程就知道t线程已经结束了。所以根据本条原则,在t线程中对共享变量的修改,对s线程都是可见的。类似的还有Thread.isAlive方法也可以检测到一个线程是否结束。可以猜测,当一个线程结束时,会把自己所有操作的结果都同步到主内存。而任何其它线程当发现这个线程已经执行结束了,就会从主内存中重新刷新最新的变量值。所以结束的线程A对共享变量的修改,对于其它检测了A线程是否结束的线程是可见的。
7.中断规则
一个线程在另一个线程上调用interrupt,Happens-Before被中断线程检测到interrupt被调用。
假设两个线程A和B,A先做了一些操作operationA,然后调用B线程的interrupt方法。当B线程感知到自己的中断标识被设置时(通过抛出InterruptedException,或调用interrupted和isInterrupted),operationA中的操作结果对B都是可见的。
8.终结器规则
一个对象的构造函数执行结束Happens-Before它的finalize()方法的开始。
“结束”和“开始”表明在时间上,一个对象的构造函数必须在它的finalize()方法调用时执行完。
根据这条原则,可以确保在对象的finalize方法执行时,该对象的所有field字段值都是可见的。
内存屏障(Memory Barriers)
编译器级别的内存屏障/CPU层面的内存屏障
CPU层面提供了三种屏障:写屏障,读屏障,全屏障
写屏障Store Memory Barrier是一条告诉CPU在执行后续指令之前,需要将该缓存行对应的store buffer中的全部写指令执行完成。
读屏障Load Memory Barrier是一条告诉CPU在执行后续指令之前,需要将该缓存行对应的失效队列中的全部失效指令执行完成。
全屏障Full Memory Barrier 是读屏障和写屏障的合集
void cpu_01() {
value = 10;
//在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
storeMemoryBarrier();
finished = true;
}
void cpu_02() {
while(!finished);
//在读取之前将所有失效队列中关于该数据的指令执行完毕。
loadMemoryBarrier();
assert value == 10;
}
CPU缓存淘汰策略
CPU Cache的淘汰策略。常见的淘汰策略主要有LRU和Random两种。通常意义下LRU对于Cache的命中率会比Random更好,所以CPU Cache的淘汰策略选择的是LRU。当然也有些实验显示在Cache Size较大的时候Random策略会有更高的命中率
参考