关于内存模型和Volatile这块知识点,市面上已经有很多书籍对这块有深入的介绍,今天主要从自己的角度跟大家聊聊这部分内容,希望从不同的视角分析,能给你带来更大的收获。
二.内存模型
Java内存模型是Java Memory Model的缩写,又简称为JMM,是一个抽象的概念。Java内存模型的存在主要是用来屏蔽不同硬件平台访问内存的差异。使它们让Java程序在不同的平台下访问内存达到一致的效果。在JVM内部,我们姑且分为堆和栈两部分。当线程创建的时候,JVM会为其创建一个工作内存来存储线程的私有数据,线程对变量的操作都会先从主内存拷贝一份到自己的工作内存当中,进行一系列的运算,然后再将运算结果更新到主内存当中,不能直接对主内存进行操作。线程间的通信(Thread-A和Thread-B), 必须通过主内存完成,它们之间是无法直接访问对方的工作内存。内存模型与系统内存架构关系如下:
图
通过上图,我们对Java内存模型的工作流程有了一个大致的了解。学过JVM原理的同学可能对Java内存模型跟JVM运行时的数据区搞混,在JVM里内存可能又细分成方法区、虚拟机栈、本地方法栈、程序计数器和堆这五部分,其实它们本质上没什么区别,只是从不同维度上去划分,就像文章开头说的那样,JMM是一个抽象的概念。下面我们再来聊聊,经常听到的并发过程中遇到的几个要处理的特性,原子性、有序性和可见性。
1.原子性
Java中的原子性指的是对基本数据类型的读取和赋值操作是原子操作,这个操作是不可中断和分割,要么全部执行,要么全部不执行。咋一看数据库中的事务还挺像。但值得我们注意的是32位的JVM平台对Long和double两种数据类型的读写不是原子操作,这个我们很容易理解,因为32位的平台,每次读写是32位的存储单元,Long和double占64位,如果是多个线程读写,一个线程读完前32位存储单元,刚好另一个线程读取后32位存储单元,这违背了原子的特性。不过JVM的不断完善,估计这个问题我们可以忽略不计。
2.可见性
可见性指的是多个线程同时访问一个变量的时候,如果一个线程对变量进行了修改,其它的线程可以看到这个变量的改变。当然对于串行的程序来说,这个可见性就可以忽略了,因为任何一个操作修改变量的值,后续的操作都能看到这个变量在变化。对于可见性,Java提供了volatile关键字来保证可见性。关于volatile关键字的讲解,请参照《Java内存模型的理解》一文。
3.有序性
有序性顾名思义就是按照顺序执行,也就是按照代码先后顺序进行执行。
happens-before
关于有序性,除了通过volatile关键字来保证一定"有序性"外,我们还可以用Lock和synchronized来保证有序性,当然Java内存模型本身也可以做到一定"有序性",这就是大家老生常谈的happens-before原则。两个操作,如果如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。举个例子咱们理解下
int x=0; // Thread-A
int y=x; // Thread-B
问题:这里面咱们猜想一下,Thread-A和Thread-B执行后,y一定等于0么?
解答:如果线程Thread-A的操作(int x=0)happens-before线程Thread-B的操作(int y=x),那么y=0,如果Thread-A和Thread-B不满足happens-before原则,那么y不一定等于0。
关于happens-before定义和规则,我这里直接摘录《深入理解Java虚拟机》
happens-before原则定义:
1.如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2.两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
happens-before原则规则:
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
重排序
Java程序在执行的时候,为了提高自身性能并且遵循as-if-serial语义,处理器和编译器会对指令进行重排序,不会对存在数据依赖关系的操作进行重排序,并且单线程环境下不能改变程序运行的结果才能进行重排序。
举个例子:
int x=5; // X
int y=8; // Y
int z=x+y; // Z
关于上面的代码,存在这样关系:X和Z之间存在数据依赖关系,同时Y和Z之间也存在数据依赖关系。为了得到正确的结果,执行指令序列时,Z不能重排序到X和Y的前面。但是X和Y之间并没有数据依赖关系,根据我们上面讲的,没有数据依赖关系处理器和编译器会对指令进行重排序,所以X和Y的执行顺序会被重新调整下面两种情况:
Y-》X-》Z 结果为:13
X-》Y-》Z 结果为:13
上面的代码如果是多线程的情况下进行重排序,会影响程序的运行结果,所以才引发出多线程高并发下数据不一致一系列问题。这里有引出了另一个概念内存屏障。
内存屏障
内存屏障的出现主要是禁止处理器的重排序,并强制把写缓冲区中的脏数据写回主内存,从而让程序按我们预想的流程去执行。内存屏障有些材料又叫内存栅栏,是一个CPU指令,volatile就是基于内存屏障实现的。
内存屏障分Load Barrier(读屏障)和Store Barrier(写屏障)两种。
对于读屏障,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从主内存加载数据;
对于写屏障,在指令后插入Store Barrier,可以让写入缓存中的最新数据更新写入主内存,让其他线程可见。
对于Load和Store实际又分为以下四种
LoadLoad屏障
序列: Load1;Loadload;Load2
说明: 确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。
StoreStore屏障
序列: Store1;StoreStore;Store2
说明: 确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见。
LoadStore屏障
序列: Load1;LoadStore;Store2
说明: 确保Load1的数据在Store2和后续Store指令被刷新之前读取。
StoreLoad屏障
序列: Store1;StoreLoad;Load2
说明: 确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。
以上四种屏障详情访问: http://ifeve.com/jmm-cookbook-mb/
三.参考文献
1.Java并发编程的艺术
2.深入理解Java虚拟机
3.揭秘Java虚拟机-JVM设计原理与实现
个人博客原文:https://www.xiangquba.cn/2018/02/28/java-memory-model/