[关闭]
@Catyee 2021-08-14T17:38:15.000000Z 字数 7181 阅读 490

Java内存模型

面试

Java内存模型是共享内存的并发模型,目的是定义程序中各种变量的访问规则,即线程的工作内存和主内存之间的变量存取方式以及线程与线程之间的通信方式。

一、JAVA内存模型

Java内存模型是共享内存的并发模型,目的是定义程序中各种变量的访问规则,即线程的工作内存和主内存之间的变量存取方式以及线程与线程之间的通信方式

这里要注意Java内存模型和Java运行时内存区域区分开来,两者不是一个层次的概念,Java内存模型描述的是变量存取的方式,以及线程之间的通信方式;而Java运行时内存区域描述的是对内存的使用方式,即每块内存存储什么内容,如何分配等问题。
Java内存模型规定了所有的变量都存储在主内存,每个线程有自己的工作内存,线程的工作内存保存了线程所需的变量在主内存中的副本,不同线程之间无法直接访问对方工作内存中的变量,需要采用共享变量的方式来完成隐式通信。(另一种线程通信方式是消息传递,不过Java没有采用这种方式)。

从更基础的层次讲,主内存直接对应于物理硬件的内存(不是机械硬盘,而是内存条,相对于寄存器和高速缓存来说读写效率低,但是容量大),工作内存对应于寄存器和高速缓存中的内存(读写效率极高,但是容量小),需要将主内存、工作内存和Java运行时数据区中的Java堆和虚拟机栈区分开来。如果硬要对应,也可以认为Java堆中的内存使用的时物理硬件的内存,而java虚拟机栈中的内存使用的是高速缓存中的内存。

二、原子性、可见性、有序性

Java内存模型围绕着并发过程中如何处理原子性、可见性和顺序性这三个特征来设计的。我们总是提到线程的安全性,什么是线程安全性,实际上指的是线程对共享变量的读写操作的安全性,这里的操作可能是单个操作,也可能是一系列操作,线程对共享变量的操作只有同时满足原子性、可见性和有序性才被认为是线程安全的

2.1 原子性

指一系列操作的不可分割性,要么所有操作不间断地全部被执行,要么一个也没有执行。Java内存模型中定义了8种原子性操作,包括lock(锁定)unlock(解锁)read(读取)load(载入)use(使用)assign(赋值)store(存储)write(写入)。如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。后来在JSR-133文档中已经削减为四种,但仅仅只是语言描述上的等价化简,java内存模型并没有改变。

Java语言提供了synchronized关键字来保证操作的原子性。

2.2 可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。除了volatile之外,Java还有两个关键字提供可见性的语义,它们是synchronized和final。

2.3 有序性

如果程序编译出来的所有指令的所有步骤都是严格按顺序执行的,是不会存在有序性这个问题的。但是编译器和处理器(CPU)为了提高性能,会进行指令的重排序,所以程序执行最终并不是严格按顺序执行的,这就是之所以会出现有序性这个问题的原因。
Java程序的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

三、指令重排序

程序执行过程中,为了性能考虑,编译器和处理器(CPU)可能会对指令重新排序。

3.1 编译器指令重排序:

编译器的工作就是将人可读的源码转化成cpu可读的机器语言。这个过程有很大一部分操作就是在不改变程序行为的情况下重新排列指令,以达到更优的性能。但是编译器不知道什么样的代码需要线程安全,所以编译器假设我们的代码都是单线程执行的,在这个基础上进行指令重排优化。

3.2 处理器指令重排序:

一条汇编指令的执行可以分为很多步,每一步都占一个时钟,如果每次都执行完一条汇编指令再执行下一条效率就太低了,因为执行最后一个步骤的时候,前4个时钟都被浪费了,可以像工厂流水线那样错位流水执行。但是指令之间有可能有依赖关系,这个时候某一条指令的某一步需要进行等待,等待会浪费时钟(称为气泡),那怎么来消减气泡呢?答案就是执行其它毫无相干的指令,也就是cpu的指令重排序了(详见:https://www.cnblogs.com/xdecode/p/8948277.html

3.3 如何保证指令重排的正确性

编译器和处理器会遵守as-if-serial规则来保证单线程下指令重排序的正确性,as-if-serial的语义就是“不管怎么进行指令重排序,单线程内的执行结果总是正确的”,为了实现这个语义,编译器和处理器都不会对存在数据依赖关系的操作做重排序。

但是as-if-serial规则并不能保证指令重排序之后在多线程环境下执行结果依然正确。那如何保证多线程环境下的正确性呢?处理器提供了内存屏障来用于在合适的位置禁止指令重排序,这就是volatile关键字出现的原因,Java程序员可以用volatile显示的告诉编译器和处理器什么位置不能进行指令重排序。

四、先行发生原则(Happen-before原则)

我们如何知道在什么地方需要使用内存屏障呢?或者说我们怎么知道一段代码经过指令重排序之后在多线程环境下的运行结果是否符合预期呢?

java提供了Happen-before原则来让程序员可以在不运行程序就能推断出代码在多线程环境下是否会符合预期,不管一个java程序员了不了解happen-before原则,在他写代码的时候都已经在使用部分或全部的happen-before原则了。
happen-before原则也是编译器和处理器在做指令重排序的时候需要遵守的原则,但是需要程序员提供相关的语义才可以,编译器和处理器只有在发现了这些语义之后才会调整排序来保证这些语义(否则编译器和处理器可以随意排序)。举个例子,某一个变量需要加上volatile关键字,但是编译器和处理器是不知道该不该加的,只能由程序员自己去加。程序员或许熟知happen-before原则或许不了解但是凭借经验觉得应该加上这个关键字,总之一旦加上了volatile关键字,也就向编译器和处理器提供了Happen-before原则中的volatile变量规则的语义,编译器和处理器便知道在指令重排序的时候需要保证这种语义,而不是随意重排序。

由此可见happen-before和as-if-serial规则有着很大的不同,as-if-serial规则是编译器和处理器要遵守的规则,程序员可以随意编写代码(符合语法即可),单线程环境下总能获取幂等的结果,程序员不用为了as-if-serial规则去做额外的事情。但是happen-before原则不一样,happen-before原则
需要程序员的参与,只有程序员显示提供了相关语义的指令,编译器和处理器才会去保证相关语义。

4.1 先行发生原则的作用

先行发生原则是判断数据是否存在竞争,线程是否安全最重要的依据,如果两个操作之间的关系不能用先行发生原则推导出来,那它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

4.2 先行发生是什么

先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到

  1. // 以下操作在线程A中执行
  2. i = 1;
  3. // 以下操作在线程B中执行
  4. j = i;
  5. // 以下操作在线程C中执行
  6. i = 2;

假设线程A中的操作“i=1”先行发生于线程B的操作“j=i”,那我们就可以确定在线程B的操作执行后,变量j的值一定是等于1,得出这个结论的依据有两个:一是根据先行发生原则,“i=1”的结果可以被观察到;二是线程C还没登场,线程A操作结束之后没有其他线程会修改变量i的值。
现在再来考虑线程C,我们依然保持线程A和B之间的先行发生关系,而C出现在线程A和B的操作之间,但是C与B没有先行发生关系,那j的值会是多少呢?答案是不确定!1和2都有可能,因为线程C对变量i的影响可能会被线程B观察到,也可能不会,这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。

4.3 有哪些无需同步器协助的“天然”的先行发生原则

以下是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在写代码时直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序,如果不希望进行随意的重排序,就需要由程序员额外提供一些语义,比如volatile、synchronized来显示告诉编译器不允许重排序

Java语言无须任何同步手段就能成立的先行发生规则有且只有下面这些:

4.4 先行发生规则分析举例

  1. private int value = 0;
  2. pubilc void setValue(int value) {
  3. this.value = value;
  4. }
  5. public int getValue() {
  6. return value;
  7. }

假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?
由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步代码块,自然就不会发生lock和unlock操作,所以锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以传递性也无从谈起,因此我们可以判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,可能是0也可能是1,换句话说,这里面的操作不是线程安全的。

通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”。那如果一个操作“先行发生”,是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的

一个典型的例子就是多次提到的“指令重排序”。

  1. // 以下操作在同一个线程中执行
  2. int i = 1;
  3. int j = 2;

两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行 发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性, 因为我们在这条线程之中没有办法感知到这一点。

上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准

五、volatile关键字

当我们用volatile修饰一个变量的时候,这个变量就具有了可见性和防止指令重排序的语义。

5.1 volatile的可见性语义

volatile可以保证可见性,如果一个变量被volatile关键字修饰意味着:
这个变量每次被修改时它的值会立刻同步回主内存;每次读这个变量时都需要从主内存重新读取值。

5.1.1 volatile可见性语义的实现方式

用volatile变量修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令,其功能如下:

5.2 volatile的防止指令重排序语义

如果一个变量被volatile关键字修饰还意味着不会对这个变量进行部分指令重排序:
防止指令重排序是通过内存屏障来实现的:
对volatile变量进行写操作的时候,会在写操作的前面插入一个StoreStore屏障,在写操作的后面插入一个StoreLoad屏障:

读取volatile变量时,会在读操作后面插入一个LoadLoad屏障和一个LoadStore屏障:

用一个经典的列子来说明volatile禁止指令重排序的作用,以下是正确的双重检测单例写法:

  1. public class Singleton {
  2. // 双重检测单例关键点:volatile关键字
  3. private volatile static Singleton instance = null;
  4. // 双重检测单例关键点:私有构造器
  5. private Singleton() {
  6. }
  7. public static Singleton getInstance() {
  8. // 双重检测单例关键点:双重检测
  9. if (instance == null) {
  10. // 双重检测单例关键点:synchronized关键字
  11. synchronized (Singleton.class) {
  12. if (instance == null) {
  13. instance = new Singleton();
  14. }
  15. }
  16. }
  17. return instance;
  18. }
  19. }

正确的双重检测单例有四个关键点,这里只讲第一个关键点,也就是volatile关键字:
为什么使用volatile,关键在于instance = new Singleton()这一行代码,这行代码并不是原子指令,使用javap -c命令可以快速查看字节码文件,如图:

@双重检测单例volatile指令

从字节码中可以看到instance = new Singleton()这行代码分了三步:

虚拟机实际运行的时候,以上指令可能发生重排序。具体来说b、c可能发生重排序,但是a不会。因为b、c指令需要依托a指令的执行结果。单线程的情况下,发生重排序对结果不会造成影响,但是在多线程环境下就会出现一些问题,假如经过指令重排序之后顺序变为:

假如线程1已经执行到了b,但是还没执行到c,这个时候instance引用已经有值了,但是还没初始化完成。另外一个线程执行到了第10行,发现instance已经有值了,会直接跳到第18行,返回对象并退出方法,之后会去使用这个对象,假如线程1仍然在执行初始化,那第二个线程使用对象就会出现问题,所以需要使用volatile来禁止重排序。(在早期的jdk版本不加volatile很容易复现这个问题,但是在1.7及以上已经不容易出现这个问题了)

六、long和double的非原子性协定:

Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”。

如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种读取到“半个变量”的情况是非常罕见的。我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注