Java内存模型的相关探究

介绍

  • JMM概念,目标,Java内存模型图等
  • CPU和编译器的乱序(重排序)
  • 内存屏障,类型,规则
  • 缓存一致协议,缓存行概念
  • JMM定义的8种基本操作和8种规则
  • happends-before法则
  • volatile关键字底层实现,所提供的功能,使用条件
  • synchronized关键字

这里分享整理笔记,之前也是很困惑这块,花了时间去查资料理解。下面是我根据网上的资料和自己的一点理解,对Java内存模型方面的知识进行一个大致整合,方便后续回忆,如有错误,感谢指出;(当然了,还没有整理完全,像final的作用,synchronized的原理等等。。。)

下面每个章节前的引用都有链接,若想探究可以查看;

wiki

JMM(Java Memory Model,Java内存模型)描述了,Java编程语言中的线程如何通过内存进行交互(interact)。和描述单线程执行代码一起,JMM提供了Java编程语言的一些语义(semantics)。

这里解释一下语义,语义和语法的区分。语法是定义句子的文法结构,也就是结构正确;语义则规定表达的意义。一般来说,语法会在编译时就会报错,因为编译器会检查你写的是否符合规则;而语义则是属于逻辑错误,就是没有达到预期的效果。例如,不能对负数开平方,结果你给sqrt函数传了一个负数。

背景

Java编程语言提供了线程功能。对于开发人员而言,线程之间的同步非常困难,而又因为Java应用程序可以在各种处理器和操作系统上运行,再次加重了其复杂程度。为了能够得出程序行为是怎么样的,Java的设计者必须清楚地定义所有Java程序的可能行为,换句话说,就是所有事情都得自己来做。

在多处理器体系结构中,各个处理器拥有自己的缓存,而缓存和内存的不同步就有可能会导致看到相同共享数据的不同值。但是一般来说,不希望线程之间完全保持同步,因为从性能的角度来看,代价太高。

JMM定义

详情可以看博文:https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html

Java内存模型的主要目标是:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。简单来说:对于赋值a=3,在什么条件下,读取变量的线程可以看到这个值(引用Java并发编程实战的一句)。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。

Java内存模型中规定了所有的共享变量都存储在主内存中,每条线程还有自己的工作内存(可以与处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,线程、主内存和工作内存的交互关系如下图所示。

直接从引用博客中copy图片:
在这里插入图片描述CacheCoherence,缓存一致协议

JMM屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果,这是设计的目的。

CPU和编译器的乱序

详情可以看博文:http://blog.sina.com.cn/s/blog_4adc4b090102vt2n.html

一般来说目前的cpu是采用流水线来执行指令,而一个指令的执行也会被分为多个不同的段(阶段):取值(IF),译码(ID),访存(MEM),执行(EX),写回(WB)5个段。而当第一条指令完成IF后,第二条指令就可以开始IF了,重复利用使得多条指令同时执行,大大提高效率。和流水线相对的就是串行了,就是要等前一个指令的5个段都完成才开始下一个指令。

而指令重排序(乱序)的目的,是为了优化流水线,提高效率。而流水线阻塞的情况有三种:

  1. 结构相关:资源冲突,如都要使用某个部件。
  2. 数据相关:后一个指令需要前一个指令的执行结果。
  3. 控制相关:涉及到跳转,分支指令。

其中指令重排序是数据相关解决方法之一:

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),分为两类:

  1. 编译器Memory Barrier
  2. CPU Memory Barrier

在硬件层又分为读写屏障。

很多时候,编译器和 CPU 引起内存乱序访问不会带来什么问题,但一些特殊情况下,程序逻辑的正确性依赖于内存访问顺序,这时候内存乱序访问会带来逻辑上的错误,如同上面例子所示。

内存屏障主要提供三个功能:

  1. 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
  2. 强制将对缓存的修改操作立即写入主存,利用缓存一致性机制,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据
  3. 如果是写操作,它会导致其他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屏障的开销是四种屏障中最大的。

Java volatile内存屏障规则:

详情可以看博文:https://blog.csdn.net/onroad0612/article/details/81382032

  1. 在每一个volatile写操作前面插入一个StoreStore屏障。这确保了在进行volatile写之前,前面的所有普通的写操作都已经刷新到了内存。
  2. 在每一个volatile写操作后面插入一个StoreLoad屏障。这样可以避免volatile写操作与后面可能存在的volatile读写操作发生重排序。
  3. 在每一个volatile读操作后面插入一个LoadLoad屏障。这样可以避免volatile读操作和后面普通的读操作进行重排序。
  4. 在每一个volatile读操作后面插入一个LoadStore屏障。这样可以避免volatile读操作和后面普通的写操作进行重排序。

缓存一致性协议和MESI

缓存一致性协议有多种,但是通常使用的是:嗅探(snooping)协议。

该协议的基本思想:

  1. 所有内存的传输都发生在一条共享的总线上,而所有的处理器都可以看见这些总线
  2. 缓存本身是独立的,而内存是共享的,所有的内存访问都要经过仲裁(在一个指令周期中,只有一个CPU缓存可以读写内存)
  3. CPU缓存不仅仅只是在做内存传输的时候才和总线打交道,而是在不停地在嗅探总线上所发生的数据交换,跟踪其他缓存在做什么。正因为如此,当一个缓存代表其所属CPU去读写内存时,其他处理器都会得到通知,一次来实现缓存之间的同步。
  4. 只要当某一个处理器一写内存,其他处理器马上知道这块内存在他们的缓存段中已经失效。

MESI

MESI协议是目前主流的缓存一致性协议,在该协议中,每个缓存行有四个状态,可用2bit表示,分别为:

  1. M(Modified,修改):这行数据有效,数据被修改了,和内存中的不一致,数据只存在于本Cache,一段时间写回内存中;
  2. E(Exclusive,独占):这行数据有效,和内存中的一致,数据只存在于本Cache;
  3. S(share,共享):这行数据有效,和内存中的一致,数据存在于很多Cache中;
  4. I(Invalid,失效):这行数据无效。

只有当状态为M/E的时候,CPU才能去写这个缓存行。换句话说,就是只有在这两个状态下,CPU是独占这个缓存行的。

CPU会先向总线发送请求,要求独占,获取独占权后,就会通知其他处理器,将他们拥有相同缓存行的状态置为失效。也就是说,只有获取了独占权,CPU才能开始修改数据,并且该缓存行只有一份拷贝(在我这里),所以不会和其他的有冲突。

若是其他处理器想要读取这个缓存行(通过嗅探总线得知消息),如果是独占状态,那么缓存行必须要先回到共享;如果是修改状态,那么缓存行必须要先写回内存,然后再转为共享。

详细描述如下:

  1. 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回内存。
  2. 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独占该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
  3. 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
  4. 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
  5. 当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M,表示缓存行已经修改。

也就是说处于M状态,并不会马上回写,而是等待有新的读取操作才会回写;

S状态下,只有当其他处理器进行回写的时候才会设置为失效,重新从内存中读取;

缓存行

  • 缓存行是分段的,一个段对应一块存储空间,我们称之为缓存行,它是CPU缓存中可分配的最小存储单元(Cache由很多缓存行构成),大小32B,64B,128B不等(和CPU架构有关),通常是64B。

当CPU看见一条读取内存的指令时,它会把内存地址传给一级数据缓存,若没有,就会去内存或更高一级的缓存中加载整个缓存段。

下面解决方法中,硬件使用LOCK#来锁总线,效率太低,所以最好能做到,使用多组缓存,但是它们的行为看起来就像是一组一样,所以缓存一致性协议就是为了保持多组缓存一致而设计的

缓存一致性问题

缓存一致性问题的原因就是因为多核处理器的缓存和主存不同的问题。

解决方法,硬件上:

  1. 通过发出Lock#信号在总线加锁的方式,这种方式会导致其他CPU无法访问内存,效率低。所以后来的处理器度采用锁缓存来带锁总线。
  2. 缓存一致性协议:最出名的是Intel的MESI协议,该协议保证了每个缓存中使用的共享变量的副本是一致的。
    1. 思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

JMM定义的内存交互操作

8种基本操作

详情可以看博文:https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html

Java内存模型定义了以下八种操作来完成,将一个变量从主内存拷贝到工作内存以及从工作内存同步到主内存的实现细节:

  1. lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  2. unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  8. write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

从这8个操作可以看出,若要从主存取数据到工作内存,需要按顺序执行read,load操作(并不需要连续执行)。反之,则需要按顺序执行store和write操作。

执行操作所要满足的8个规则

  1. 不允许read和load、store和write操作之一单独出现
  2. 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。也就是对一个变量进行use和store操作之前,必须先执行过了assign和load操作
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  7. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量
  8. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

我看见有人画了一个图,有助于理解:https://www.processon.com/view/5d2821ffe4b0aad4c92f23c0

happends-before法则

JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。

  • 程序顺序规则:一个线程中的每个操作happens-before于该线程中的任意后续操作
  • 监视器锁(同步)规则:对于一个监视器的解锁,happens-before于随后对这个监视器的加锁
  • volatile变量规则:对一个volatile变量的写操作happen—before后面对该变量的读操作
  • 线程启动规则:Thread对象的start()方法happen—before与此线程中的每一个动作
  • 线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则:对线程interrupt()方法的调用happen—before发生于被中断线程的代码检测到中断时事件的发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始
  • 传递性:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C。

而在多线程的环境下,可以通过synchronized,volatile,final,java.util.concurrent包实现这些原则。

A happends-before B的含义

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指令前缀

通过观察汇编代码,会发现volatile关键字修饰的变量会多了#lock前缀(我自己当然没有观察了)

该指令做了两件事情:

  • Lock前缀指令会引起处理器缓存会写到内存

lock前缀指令相当于一个内存屏障(或称为内存栅栏),它主要实现三个功能,参照上面内容。

  • 一个处理器的缓存回写到内存会导致其他处理器的缓存失效

volatile写-读的内存语义

  • 当写入一个volatile变量时,会锁缓存行,同时让其他cache的缓存行失效(M状态)。修改完成之后,若有其他CPU需要用到该变量,在这之前需要把缓存行写入内存中,然后状态置为(S)
  • 当读一个volatile变量时,如果本地cache的地址已经无效(I),则需要从主内存中读取所有的共享变量

使用volatile的场景

必须具备以下两个条件(其实就是先保证原子性):

  1. 对变量的写不依赖当前值(比如++操作):因为volatile++并不是原子操作,需要经过一组操作序列完成,读取到工作内存,修改副本,写回内存。而volatile不能提供原子特性。
  2. 该变量没有包含在具有其他变量的不等式中

对于不等式: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 类型是无法实现这一目的的。

发布了33 篇原创文章 · 获赞 7 · 访问量 3万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章