这里分享整理笔记,之前也是很困惑这块,花了时间去查资料理解。下面是我根据网上的资料和自己的一点理解,对Java内存模型方面的知识进行一个大致整合,方便后续回忆,如有错误,感谢指出;(当然了,还没有整理完全,像final的作用,synchronized的原理等等。。。)
下面每个章节前的引用都有链接,若想探究可以查看;
wiki
JMM(Java Memory Model,Java内存模型)描述了,Java编程语言中的线程如何通过内存进行交互(interact)。和描述单线程执行代码一起,JMM提供了Java编程语言的一些语义(semantics)。
这里解释一下语义,语义和语法的区分。语法是定义句子的文法结构,也就是结构正确;语义则规定表达的意义。一般来说,语法会在编译时就会报错,因为编译器会检查你写的是否符合规则;而语义则是属于逻辑错误,就是没有达到预期的效果。例如,不能对负数开平方,结果你给sqrt函数传了一个负数。
Java编程语言提供了线程功能。对于开发人员而言,线程之间的同步非常困难,而又因为Java应用程序可以在各种处理器和操作系统上运行,再次加重了其复杂程度。为了能够得出程序行为是怎么样的,Java的设计者必须清楚地定义所有Java程序的可能行为,换句话说,就是所有事情都得自己来做。
在多处理器体系结构中,各个处理器拥有自己的缓存,而缓存和内存的不同步就有可能会导致看到相同共享数据的不同值。但是一般来说,不希望线程之间完全保持同步,因为从性能的角度来看,代价太高。
详情可以看博文:https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html
Java内存模型的主要目标是:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。简单来说:对于赋值a=3,在什么条件下,读取变量的线程可以看到这个值(引用Java并发编程实战的一句)。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。
Java内存模型中规定了所有的共享变量都存储在主内存中,每条线程还有自己的工作内存(可以与处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,线程、主内存和工作内存的交互关系如下图所示。
直接从引用博客中copy图片:
CacheCoherence,缓存一致协议
JMM屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果,这是设计的目的。
详情可以看博文:http://blog.sina.com.cn/s/blog_4adc4b090102vt2n.html
一般来说目前的cpu是采用流水线来执行指令,而一个指令的执行也会被分为多个不同的段(阶段):取值(IF),译码(ID),访存(MEM),执行(EX),写回(WB)5个段。而当第一条指令完成IF后,第二条指令就可以开始IF了,重复利用使得多条指令同时执行,大大提高效率。和流水线相对的就是串行了,就是要等前一个指令的5个段都完成才开始下一个指令。
而指令重排序(乱序)的目的,是为了优化流水线,提高效率。而流水线阻塞的情况有三种:
其中指令重排序是数据相关解决方法之一:
int a = 1;
a++;
a = (a*10+2)/4;
int b = 2;
这样子,b的赋值和前面是没有数据相关的,所以这个操作可能在a++之前就完成了。
像这样有依赖关系的指令如果挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久,占用流水线的资源。而CPU的乱序,作为优化的一种手段,则试图通过指令重排将这样的两条指令拉开距离, 以至于后一条指令进入CPU的时候,前一条指令结果已经得到了,那么也就不再需要阻塞等待了。这里的意思是将不相关的指令插入相关指令的中间,以达到减少阻塞时间的目的。
int a = 1;
a++;
int b = 2; //放在中间
a = (a*10+2)/4;
CPU的乱序并不是在指令执行之前去调整,取指令的时候是顺序取的,但是最后执行的时候是乱序,顺序流入,乱序流出
相比于CPU的乱序,编译器的乱序才是真正对指令顺序做了调整,之所以出现编译器乱序优化根本原因在于处理器只能分析一小块指令,但编译器却可以在很大范围内进行代码分析,做出更优策略,充分利用处理器的乱序执行功能。
乱序执行,虽然可以保证显示因果关系不变,但是如果是隐式因果它们就不一定会知道了(在多线程情况下)。
两个线程
A:
a = 1;
isReady = true;
=>>不存在因果
B:
if(isReady)
doSomeThing(a);
乱序:
1.
isReady = true;
2.
if(isReady)
doSomeThing(a);
3.
a = 1;
这样的出来的结果就不会符合预期了。
再来看一个例子,单例模式的DCL机制:
public class Singleton {
private static Singleton instance = null;
private Singleton() { }
public static Singleton getInstance() {
if(instance == null) { //这里可能有问题
synchronzied(Singleton.class) {
if(instance == null) {
instance = new Singleton(); //
}
}
}
return instance;
}
}
上面这段代码,初看没问题,但是在并发模型下,可能会出错,那是因为instance= new Singleton()并非一个原子操作,它实际上下面这三个操作:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory); //2:初始化对象
在多线程场景下,可能A线程执行到了3,B线程发现指针install已经不为空就直接继续执行,这样在没有初始化的情况下执行程序很显然会出错。
对DCL的分析也告诉我们一条经验原则:对引用(包括对象引用和数组引用)的非同步访问,即使得到该引用的最新值,却并不能保证也能得到其成员变量(对数组而言就是每个数组元素)的最新值。
内存屏障主要解决了两个问题:单处理器下的乱序问题和多处理器下的内存同步问题。
内存屏障(Memory Barrier):
内存屏障(Memory Barrier),分为两类:
在硬件层又分为读写屏障。
很多时候,编译器和 CPU 引起内存乱序访问不会带来什么问题,但一些特殊情况下,程序逻辑的正确性依赖于内存访问顺序,这时候内存乱序访问会带来逻辑上的错误,如同上面例子所示。
内存屏障主要提供三个功能:
而需要注意的是,内存屏障保证的是:一个CPU的多个操作的顺序(被另一个CPU所观察到的顺序),而不保证"两个CPU的操作顺序"(多线程环境下)。
详情可以看博文:https://blog.csdn.net/butterBallj/article/details/82425939
在JSR规范中定义了4种内存屏障
LoadLoad屏障,例如
Load1;LoadLoad;Load2
Load1和Load2代表两条读取指令。在Load2要读取的数据被访问前,保证Load1读取的数据被读取完毕。
StoreStore屏障,例如
Store1; StoreStore; Store2
Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见
LoadStore屏障,例如
Load1; LoadStore; Store2
在Store2被写入前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障,例如
Store1; StoreLoad; Load2
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。
详情可以看博文:https://blog.csdn.net/onroad0612/article/details/81382032
缓存一致性协议有多种,但是通常使用的是:嗅探(snooping)协议。
该协议的基本思想:
MESI协议是目前主流的缓存一致性协议,在该协议中,每个缓存行有四个状态,可用2bit表示,分别为:
只有当状态为M/E的时候,CPU才能去写这个缓存行。换句话说,就是只有在这两个状态下,CPU是独占这个缓存行的。
CPU会先向总线发送请求,要求独占,获取独占权后,就会通知其他处理器,将他们拥有相同缓存行的状态置为失效。也就是说,只有获取了独占权,CPU才能开始修改数据,并且该缓存行只有一份拷贝(在我这里),所以不会和其他的有冲突。
若是其他处理器想要读取这个缓存行(通过嗅探总线得知消息),如果是独占状态,那么缓存行必须要先回到共享;如果是修改状态,那么缓存行必须要先写回内存,然后再转为共享。
详细描述如下:
也就是说处于M状态,并不会马上回写,而是等待有新的读取操作才会回写;
S状态下,只有当其他处理器进行回写的时候才会设置为失效,重新从内存中读取;
当CPU看见一条读取内存的指令时,它会把内存地址传给一级数据缓存,若没有,就会去内存或更高一级的缓存中加载整个缓存段。
下面解决方法中,硬件使用LOCK#来锁总线,效率太低,所以最好能做到,使用多组缓存,但是它们的行为看起来就像是一组一样,所以缓存一致性协议就是为了保持多组缓存一致而设计的。
缓存一致性问题的原因就是因为多核处理器的缓存和主存不同的问题。
解决方法,硬件上:
详情可以看博文:https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html
Java内存模型定义了以下八种操作来完成,将一个变量从主内存拷贝到工作内存以及从工作内存同步到主内存的实现细节:
从这8个操作可以看出,若要从主存取数据到工作内存,需要按顺序执行read,load操作(并不需要连续执行)。反之,则需要按顺序执行store和write操作。
我看见有人画了一个图,有助于理解:https://www.processon.com/view/5d2821ffe4b0aad4c92f23c0
JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
而在多线程的环境下,可以通过synchronized,volatile,final,java.util.concurrent包实现这些原则。
public double rectangleArea(double length , double width){
double leng;
double wid;
leng=length;//A
wid=width;//B
double area=leng*wid;//C
return area;
}
在这样一个程序中,我们可以说A happends-before B,B happends-before C,所以A happends-before C,但是不能说三者执行的顺序是A->B->C,因为重排序。
因为A happends-before B,所以A操作产生的结果leng要对B可见,但是我们可以看见B中并没有使用该变量,所以A和B可以重排序。
那么A和C可以重排序吗?不能,因为C中使用了A中所操作的变量,若重排序,那leng=0->area=0,最后导致达不到预期效果,所以重排序也并不是乱排,而是在保证前后因果的情况进行排序以达到优化的目的。
所以,我们可以说:一个操作时间上先发生于另一个操作,并不代表一个操作happen—before另一个操作,反之亦然
通过观察汇编代码,会发现volatile关键字修饰的变量会多了#lock前缀(我自己当然没有观察了)
该指令做了两件事情:
lock前缀指令相当于一个内存屏障(或称为内存栅栏),它主要实现三个功能,参照上面内容。
必须具备以下两个条件(其实就是先保证原子性):
对于不等式:https://blog.csdn.net/francisshi/article/details/40379055
public class NumberRange {
private int lower, upper;
public int getLower() { return lower; }
public int getUpper() { return upper; }
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
这种限制了范围的状态变量,即使将 lower 和 upper 字段定义为 volatile 类型同样不能够充分实现类的线程安全;
所以仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。
例如,如果初始状态是(0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) —— 一个无效值。
所以我们需要使 setLower()和 setUpper() 操作原子化 —— 而将字段定义为 volatile 类型是无法实现这一目的的。