您的位置 首页 java

一文带你深入理解Java内存模型,小白也能看得懂!火速收藏

今日分享开始啦,请大家多多指教~

Java内存模型含义?什么是 Java内存模型

Java内存模式即Java Memory Model(简称JMM),屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序中各个线程在各个平台下都达到一致的内存访问效果。

Java内存模型的好处?

要想知道的Java内存模型的好处,就对比没有Java内存模型的情况,在此之前,主流程序语言(C/C++)没有实现自己独立的内存模型,直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上的内存模型的差异,可能导致程序在一套平台上并发正常运行,在另一套平台并发访问出错。

Java自身独立的内存模型使其可以实现在不同平台上正常并发,是其实现跨平台的关键。

主内存与工作内存

Java内存模型的主要目标是

JVM 是对物理计算机的模拟,JMM是JVM内部的一套线程访问内存的规划,是对物理机中处理器访问主存的模拟。JMM是线程访问内存的一种规范,所以,JVM是存在的,JMM是不存在的。

在物理计算机中,处理器CPU要与主内存进行数据交互,但是由于两者(处理器和主内存)之间的速度不匹配的剪刀差,所以现代计算机的解决方式是在处理器和主内存之间加一个高速缓存,缓存和主存之间约定好“缓存一致性协议”即可,运行的时候,对于处理器中需要的数据,先从缓存中找,找不到再到主存中去取,写入到缓存中,处理器再从缓存中取,总之处理器不直接与主存交互,这是学生年代《计算机组成原理》中介绍过的。

工作中使用Java开发时,实际上Java内存模型也借鉴了物理计算机这个模型,由于Java是支持多线程的语言,对于程序中创建的多个 线程 ,需要访问代码中的某个公共变量(即计算机主内存中的变量)时,不直接访问内存(像处理器不直接访问内存一样),而是将主存中的变量放在工作内存中去,线程从自己的工作内存中取,下一次又要读写变量时,直接从工作内存中取,如果工作内存中没有,就再次将主内存的目标变量拷贝到工作内存,再由工作内存提供给Java线程,总之Java线程不直接与主内存交互,和上面(物理计算机)一样。

各个线程对变量读写:各个线程中保存了被该线程使用的变量在主内存的拷贝(就像缓存中保存主存中的数据拷贝一样),线程对变量的读写都必须在工作内存中进行,不能直接访问主内存。

线程间变量值的传递:不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

注意:上下两个图中都有主内存,但是仅有类似对比意义,实际上是不一样的,上图(物理计算机内存模型)中的主内存是指计算机的物理内存条,下图(Java内存模型)中的主内存是指JVM申请的那部分物理内存(即不是全部物理内存,否则整个电脑上就跑一个Java程序,其他应用程序就跑不动了)。

主内存和工作内存数据交互(原子性:八种原子性操作和八条原则)

八种原子性操作

对于 物理机 来说,高速缓存和主内存之间的交互有协议,同样的,Java内存中每个线程的工作内存和JVM占用的主内存的交互是由JVM定义了如下的8种操作来完成的,每种操作必须是原子性的。JVM中主内存和工作内存交互,就是一个变量如何从主内存传输到工作内存中,如何把修改后的变量从工作内存同步回主内存。

1)lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线程独占这个变量

2)unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定

3)read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用(解释:主内存–>工作内存,读取主内存)

4)load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)(解释:主内存–>工作内存,写入工作内存)

5)use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的 字节码 指令时就会执行该操作(解释:工作内存–>执行引擎,变量操作)

6)assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作(解释:执行引擎–>工作内存,变量赋值)

7)store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用(解释:工作内存–>主内存,读取工作内存)

8)write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中(解释:工作内存–>主内存,写入主内存)

对于Java程序中的变量读取语句,要把一个变量从主内存传输到工作内存,就要顺序的执行read和load操作;

对于Java程序中的变量写入语句,要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作。

八条规则

对于普通变量, 虚拟机 只是要求顺序的执行,并没有要求连续的执行,所以如下也是正确的。对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a; 对于这8种操作,虚拟机也规定了一系列规则,在执行这8种操作的时候必须遵循如下的规则:

1)read和load、store和write:对于Java程序中的读取和写入,不允许read和load、store和write操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况

2)assign:对于Java程序中的执行引擎运算返回结果,不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存

3)assign:不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何assign操作,是不允许将该变量的值回写到主内存

4)use-load,store-assign:先后原则,变量只能在主内存中产生,源头必须是主内存,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作,也就是说在执行use、store之前必须对相同的变量执行了load、assign操作

5)lock:一个变量在同一时刻只能被一个线程对其进行lock操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。

6)lock:对变量执行lock操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值

7)unlock:不允许对没有lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作

8)unlock:对一个变量执行unlock之前,必须先把变量同步回主内存中,也就是执行store和write操作

当然,最重要的还是如开始所说,这8个动作必须是原子的,不可分割的。

分解Java程序练习

常量读取零步操作,变量读取一步操作;

常量赋值一步操作,变量赋值两步操作;

常量计算并写入两步操作,变量计算并写入三步操作。

解释(八个原子性操作):

常量读取零步操作,啥都不干

变量读取一步操作,读取变量a,主内存–>工作内存,先读取主内存read,再写入工作内存load,根据下面规则1,两个不能拆开,所以变量读取是原子操作。

常量赋值一步操作,int a=1,工作内存–>主内存,先读取工作内存store,再写入主内存write,根据下面规则1,两个不能拆开,所以常量赋值给变量是原子操作。

变量赋值两步操作,int b=a,先变量a 主内存–>工作内存,然后变量b 工作内存–>主内存,两步操作。

常量计算并写入两步操作,int a=1+1,先 1+1=2 返回结果,执行引擎–>工作内存,使用assign命令,然后变量a 工作内存–>主内存,总共两步操作。

变量计算并写入三步操作,int b=a+1,先变量a 主内存–>工作内存,然后 a+1 返回结果,执行引擎–>工作内存,最后变量b 工作内存–>主内存,三步操作。

注意,先用int,先不考虑long和double,这两个64位的。

long double型变量的特殊规则

Java内存模型要求对主内存和工作内存交换的八个动作是原子的,正如上面所讲,但是对long和double有一些特殊规则。原因是什么呢?

其实,问题倒不是出现在8个动作上,这个8个动作是确实是原子性操作,这一点是毋庸置疑的,问题出在long和double这两种基本数据类型上。

八个动作中lock、unlock、read、load、use、assign、store、write对待32位的基本数据类型都是原子操作,对待long和double这两个64位的数据,java虚拟机规范对java内存模型的规定中特别定义了一条相对宽松的规则:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,也就是允许虚拟机不保证对64位数据的read、load、store和write这4个动作的操作是原子的。

这也就是我们常说的long和double的非原子性协定(Nonautomic Treatment of double and long Variables)。

原子性、可见性与有序性

Java内存模型是围绕着并发过程中如何处理原子性、可见性和有序性3个特征建立的。

1)原子性:

由Java内存模型来直接保证原子性的变量操作包括read、load、use、assign、store、write这6个动作,虽然存在long和double的特例,但基本可以忽略不计,目前虚拟机基本都对其实现了原子性。如果需要更大范围的控制,lock和unlock也可以满足需求。

lock和unlock虽然没有被虚拟机直接开放给用户使用,但是提供了字节码层次的指令monitorenter和monitorexit对应这两个操作,对应到java代码就是 synchronized 关键字,因此在synchronized块之间的代码都具有原子性(这是程序员所熟知的)。

2)可见性:

可见性是指一个线程修改了一个变量的值后,其他线程立即可以感知到这个值的修改。正如前面所说, volatile 类型的变量在修改后会立即同步给主内存,在使用的时候会从主内存重新读取,是依赖主内存为中介来保证多线程下变量对其他线程的可见性的。

除了volatile,synchronized和final也可以实现可见性。synchronized关键字是通过unlock之前必须把变量同步回主内存来实现的,final则是在初始化后就不会更改,所以只要在初始化过程中没有把this指针传递出去也能保证对其他线程的可见性。

3)有序性:

有序性从不同的角度来看是不同的。单纯单线程来看都是有序的,但到了 多线程 就会跟我们预想的不一样。可以这么说:如果在本线程内部观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句说的就是“线程内表现为串行的语义”,后半句指的是“指令重排序”现象和主内存与工作内存之间同步存在延迟的现象。

保证有序性的关键字有volatile和synchronized,volatile禁止了指令重排序,而synchronized则由“一个变量在同一时刻只能被一个线程对其进行lock操作”来保证。

总体来看,synchronized对三种特性(原子性、可见性、有序性)都有支持,虽然简单,但是如果无控制的滥用对性能就会产生较大影响。

synchronized关键字是绝对安全的,因为它可以同时保证原子性、可见性、有序性,但是这并不意味着synchronized关键字可以随意使用,事实上,synchronized是一种重量级锁,对性能的影响还是比较大的,本文第五部分介绍锁优化就是为了解决synchronized重量级锁的性能损耗问题。

有序性:先行发生原则

有序性:八条先行发生原则

Java内存模型具备一些 先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性 ,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

下面就来具体介绍下happens-before原则(先行发生原则):

(1)程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;

(2)锁定规则:一个unLock操作先行发生于后面对同一个锁额 lock 操作;

(3)volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;

(4)传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

(5)线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;

(6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

(7)线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread .join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;

(8)对象终结规则:一个对象的初始化完成先行发生于他的 finalize ()方法的开始。

这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

下面我们来解释一下前4条规则:

第一条规则:对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。

虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果处于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

第四条规则实际上就是体现happens-before原则具备传递性。

时间上先发生与先行发生

时间上先发生:实际运行先发生,实际运行顺序从控制台打印结果就可以看到;

先行发生:指先行发生的操作影响能被后来的观察到,A先行于B发生,A的操作影响能被B观察到。

先行发生实例分析——这里假设A B两个线程分别调用setValue() getValue(),结果线程不安全:

如果有两个线程A和B,A先调用setValue方法,然后B调用getValue方法,那么B线程执行方法返回的结果是什么?是默认值0,还是客户端调用setter设置后的值呢?

我们去对照先行发生原则一个一个对比。首先是程序次序规则,这里是多线程,不在一个线程中,不适用;然后是管程锁定规则,这里没有synchronized,自然不会发生lock和unlock,不适用;后面对于线程启动规则、线程终止规则、线程中断规则也不适用,这里与对象终结规则、传递性规则也没有关系。

使用刚刚上面粗体标记的这句,如果两个操作之间均不满足下列规则,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。这个示例就是这样,不满足所有规则,所以虚拟机可以这个实例程序随意重排序,所以B返回的结果是不确定的,所以这个实例在多线程环境下该操作不是线程安全的。

这里告诉我们,“时间上先发生”(setValue实际顺序先于getValue)不代表操作上“先行发生”(getValue不一定能观察到由于setValue所导致的value值变化)

解决思想:

因为这个实例代码在多线程下是不安全的,返回值是随机的,要使这个程序在多线程下安全,返回值唯一确定,必须满足上面8条规则中其中一条。

解决方式一:加上管程锁定规则,getter/setter方法加上synchronized关键字或者lock锁机制,实现原子操作。

解决方式二:加上volatile变量规则,将value变量上加上volatile关键字,实现所有线程可见。

先行发生实例分析——这里假设同一线程,结果线程安全:

int i = 2;

int j = 1;

这里对i的赋值先行发生于对j赋值的操作,但是代码重排序优化,也有可能是j的赋值先发生,但是这个实例是安全的,因为这里是在同一个线程内,代码重排序不会导致结果发生变化。

这里告诉我们,由于代码重排序优化的存在,“先行发生”(因为这里是假设同一线程,i的设置被j观察到)不代表操作上“时间上先发生”(i不一定比j先赋值,因为代码重排序优化的存在)

所以,综上所述,时间先后顺序与先行发生原则之间基本没有太大关系(这里我们得到的目标定律)。所以,我们衡量并发安全的问题的时候不要受到时间先后顺序的干扰,一切以先行发生原则为准。

今日份分享已结束,请大家多多包涵和指点!

如何获取?

转发分享此文,后台私信小编:“1”即可获取。(注:转发分享,感谢大家)

文章来源:智云一二三科技

文章标题:一文带你深入理解Java内存模型,小白也能看得懂!火速收藏

文章地址:https://www.zhihuclub.com/199213.shtml

关于作者: 智云科技

热门文章

网站地图