带你整理面试过程中关于 Java 的内存模型 JMM(Java Memory Model)的相关知识
Posted 南淮北安
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了带你整理面试过程中关于 Java 的内存模型 JMM(Java Memory Model)的相关知识相关的知识,希望对你有一定的参考价值。
文章目录
一、Java 内存模型
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节
Java 内存模型规定了所有的变量都存储在主内存中,每个线程也都有自己的工作内存,工作内存中保存了这个线程使用到的变量的主内存副本拷贝
JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的
二、原子性(Atomicity)
原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
比如,对于一个静态全局变量int i
,两个线程同时对它赋值,线程A给它赋值1,线程B给它赋值为-1。那么不管这两个线程以何种方式、何种步调工作,i的值要么是1,要么是-1。线程A和线程B之间是没有干扰的。
这就是原子性的一个特点,不可被中断。
但如果我们不使用int
型数据而使用long
型数据,可能就没有那么幸运了。
对于32位系统来说,long型数据的读写不是原子性的(因为long型数据有64位)。也就是说,如果两个线程同时对long型数据进行写入(或者读取),则对线程之间的结果是有干扰的。
示例:
上述代码有4个线程对long
型数据t
进行赋值,分别对t
赋值为111、-999、333、444。
然后,有一个读取线程读取这个t
的值。一般来说,t
的值总是这4个数值中的一个。这当然也是我们的期望了。但很不幸,在32位的Java虚拟机中,未必总是这样。如果读取线程ReadT总是读到合理的数据,那么这个程序应该没有任何输出。但是,实际上,这个程序一旦运行,就会大量输出以下信息(再次强调,使用32位虚拟机):
这里截取了部分输出。我们可以看到,读取线程居然读到了两个似乎根本不可能存在的数值。这不是幻觉,在这里,你看到的确实是事实,因为在32位系统中long型数据的读和写都不是原子性的,多线程之间相互干扰了!如果给出这几个数值的二进制表示,大家就会有更清晰的认识了:
上面显示了这几个相关数字的补码形式,也就是在计算机内的真实存储内容。不难发现,这个奇怪的4294966852,其实是111或者333的前32位与-444的后32位夹杂后的数字。而-4294967185只是-999或者-444的前32位与111夹杂后的数字。换句话说,由于并行的关系,数字被写乱了,或者读的时候,读串位了。
三、可见性(Visibility)
可见性是指当一个线程修改了某一个共享变量的值时,其他线程是否能够立即知道这个修改。
显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,在后续的步骤中读取这个变量的值时,读取的一定是修改后的新值。
但是这个问题存在于并行程序中。如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。
图1.14展示了发生可见性问题的一种可能。如果在CPU1和CPU2上各运行了一个线程,它们共享变量t
,由于编译器优化或者硬件优化的缘故,在CPU1上的线程将变量t
进行了优化,将其缓存在cache中或者寄存器里。在这种情况下,如果在CPU2上的某个线程修改了变量t的实际值,那么CPU1上的线程可能无法意识到这个改动,依然会读取cache中或者寄存器里的数据。因此,就产生了可见性问题。
外在表现为:变量t
的值被修改,但是CPU1上的线程依然会读到一个旧值。可见性问题也是并行程序开发中需要重点关注的问题之一。
可见性问题是一个综合性问题。除上面提到的缓存优化或者硬件优化(有些内存读写可能不会立即触发,而会先进入一个硬件队列等待)会导致可见性问题以外,指令重排及编辑器的优化,也有可能导致一个线程的修改不会立即被其他线程察觉。
四、有序性(Ordering)
有序性问题可能是三个问题中最难理解的了。对于一个线程的执行代码而言,我们总是习惯性地认为代码是从前往后依次执行的。这么理解也不能说完全错误,因为就一个线程内而言,确实会表现成这样。但是,在并发时,程序的执行可能就会出现乱序。
给人的直观感觉就是:写在前面的代码,会在后面执行。听起来有些不可思议,是吗?有序性问题的原因是程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。下面来看一个简单的例子:
假设线程A首先执行writer()
方法,接着线程B执行reader()
方法,如果发生指令重排,那么线程B在代码第10行时,不一定能看到a已经被赋值为1了。图1.15展示了两个线程的调用关系。
这确实是一个看起来很奇怪的问题,但是它确实可能存在。
注意:这里说的是可能存在。因为如果指令没有重排,这个问题就不存在了,但是指令是否发生重排、如何重排,恐怕是我们无法预测的。因此,对于这类问题,我认为比较严谨的描述是:线程A的指令执行顺序在线程B看来是没有保证的。如果运气好的话,线程B也许真的可以看到和线程A一样的执行顺序。
不过这里还需要强调一点,对于一个线程来说,它看到的指令执行顺序一定是一致的(否则应用根本无法正常工作)。也就是说指令重排是有一个基本前提的,就是保证串行语义的一致性。指令重排不会使串行的语义逻辑发生问题。因此,在串行代码中,大可不必担心。
指令重排对于提高CPU处理性能是十分必要的。虽然确实带来了乱序的问题,但是这点牺牲是完全值得的
指令重排,在程序执行过程中为了性能考虑,,编译器和 CPU 可能会对指令重新排序。对于并发多线程场景下,指令重排会产生不确定的执行效果
具体的原因可以参考:Java 高并发程序设计 1.5 章节
有序性:如果在本线程内观察,所有操作都是有序的,如果在一个线程观察另一个线程,所有操作都是无序的
Java 语言提供了 volatile 和 synchronized 两个关键字保证线程之间的有序性
- volatile 关键字本身就包含了禁止指令重排序的语义
- synchronized 是一个变量同一时刻只允许一条线程对其 lock 操作,这决定了持有同一个锁的两个同步块只能串行进入
五、哪些指令不能重排:Happen-Before 规则
在前文已经介绍了指令重排,虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并非所有的指令都可以随便改变执行位置,以下罗列了一些基本原则,这些原则是指令重排不可违背的。
- 程序顺序原则:一个线程内保证语义的串行性。
- volatile规则:volatile变量的写先于读发生,这保证了volatile变量的可见性。
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前。
- 传递性:A先于B,B先于C,那么A必然先于C。
- 线程的start()方法先于它的每一个动作。
- 线程的所有操作先于线程的终结(Thread.join())。
- 线程的中断(interrupt())先于被中断线程的代码。
- 对象的构造函数的执行、结束先于finalize()方法。
以程序顺序原则为例,重排后的指令绝对不能改变原有的串行语义,比如:
由于第2条语句依赖第一条语句的执行结果。如果贸然交换两条语句的执行顺序,那么程序的语义就会修改。因此这种情况是绝对不允许发生的,这也是指令重排的一条基本原则。
此外,锁规则强调,unlock操作必然发生在后续的对同一个锁的lock之前。也就是说,如果对一个锁解锁后,再加锁,那么加锁的动作绝对不能重排到解锁的动作之前。很显然,如果这么做,则加锁行为是无法获得这把锁的。
其他几条原则也是类似的,这些原则都是为了保证指令重排不会破坏原有的语义结构。
可参考学习:一篇文章带你搞定 Java 内存模型
六、面试题
(1)并发问题是如何形成的?
Java 内存模型规定了所有的变量都存储在主内存中,每个线程也都有自己的工作内存,工作内存中保存了这个线程使用到的变量的主内存副本拷贝。
因为每个线程都会维护自己的一份内存副本,也就是CPU缓存,所以线程之间一定会存在数据一致性问题。
原子性:一个或多个操作在 CPU 执行的过程中是否会被中断?
可见性:一个线程对共享变量的修改,另一个线程是否可见?
有序性:程序编译后的指令是否会按照代码原本的顺序执行?
(2)可见性和指令重排是怎么回事?(美团)
以上是关于带你整理面试过程中关于 Java 的内存模型 JMM(Java Memory Model)的相关知识的主要内容,如果未能解决你的问题,请参考以下文章
带你整理面试过程中关于JVM 的运行机制多线程和 JVM 的内存区域的相关知识点