0%

Java 虚拟机 2:Java内存模型-主内存与工作内存的交互协议

一、概述

问题背景

1.为了解决物理内存读写速度慢,与处理器运算速度不匹配的问题,现代操作系统在处理器和物理内存之间加入了高速缓存:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,从而让处理器无需等待缓慢的内存读写。

这种基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但也引发了一个新问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,这将可能导致不同处理器缓存的数据不一致的问题,这就需要一种协议来规范并保证内存与高速缓存之间的交互与访问操作。

2.计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令进行指令重排优化,在多线程环境中,程序的顺序性并不能靠代码的先后顺序来保证。这些重排优化可能会导致程序出现内存可见性问题。

3.并发编程下如何保证程序执行的原子性、可见性、有序性?

原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store、write。除了 long 型字段和 double 型字段外,java 内存模型确保访问任意类型字段所对应的内存单元都是原子的。这包括引用其它对象的引用类型的字段。此外,volatile long 和 volatile double 也具有原子性 。(虽然 java 内存模型不保证 non-volatile long 和 non-volatile double 的原子性,但它们在某些场合也具有原子性。)(non-volatile long 在64位 JVM,OS,CPU 下具有原子性)

原子性可以确保获取到的结果值所对应的所有bit位,全部都是由单个线程写入,但不能确保你获得的是任意线程写入之后的最新值。

可见性

可见性是指当一个线程修改了共享变量的值,其他线程但在多线程环境中可就不一定了,由于线程对共享变量的操作都是线程拷贝到各自的工作内见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改后的最新值。对于串行程序来说,可见性是不存存进行操作后才写回到主内存中的,这就可能存在一个线程 A 修改了共享变量 i 的值,还未写回主内存时,另外一个线程 B 又对主内存中同一个共享变量 i 进行操作,但此时 A 线程工作内存中共享变量 i 对线程 B 来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外在多线程环境下,指令重排优化确实会导致程序乱序执行的问题,从而也就导致可见性问题。

有序性

如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。最主要的乱序执行问题主要表现在读写操作和赋值语句的相互执行顺序上。

二、Java内存模型的目的(解决的问题)

1.屏蔽各种硬件和操作系统的差异,以实现让 Java 程序在各平台下都能达到一致的访问效果。
2.定义程序中各个变量的访问规则,定义了在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。通过这些规则可以解决缓存一致性问题。
3.定义了一套规则帮助在并发过程中保证程序执行的 原子性、可见性、有序性这3个特性,帮助解决并发编程可能出现的线程安全问题。

三、Java内存模型提供的解决方案

在 Java 内存模型中都提供一套解决方案供 Java 工程师在开发过程使用,如原子性问题,除了 JVM 自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用 synchronized 关键字或者重入锁(ReentrantLock)保证程序执行的原子性;而工作内存与主内存同步延迟现象导致的可见性问题,可以使用 synchronized 关键字或者 volatile 关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见;对于指令重排导致的可见性问题和有序性问题,则可以利用 volatile 关键字解决,因为 volatile 的另外一个作用就是禁止重排序优化;除了靠 sychronized 和 volatile 关键字来保证原子性、可见性以及有序性外,JMM 内部还定义一套 happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性,通过这个原则,可以判断数据是否存在竞争、线程是否安全。

四、Java内存模型的包含了哪些内容

1.主内存与工作内存

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

2.主内存与工作内存间的交互协议

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

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

  • load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。

  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中一个变量的值传送到主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行(如:read a,read b,load b, load a 是允许的)。

Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许 read 和 load、store 和 write 操作之一单独出现

  • 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。

  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。

  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lock 和 unlock 必须成对出现。

  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值

  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。

  • 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

3.先行发生原则(happens-before)

若在程序开发中,仅靠 sychronized 和 volatile 关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,在 Java 内存模型中,还提供了 happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据。

该规则定义了 Java 多线程操作的有序性和可见性,防止了编译器重排序对程序结果的影响。按照官方的说法:

当一个变量被多个线程读取并且至少被一个线程写入时,如果读操作和写操作没有 HB 关系,则会产生数据竞争问题。 要想保证操作 B 的线程看到操作 A 的结果(无论 A 和 B 是否在一个线程),那么在 A 和 B 之间必须满足 HB 原则,如果没有,将有可能导致重排序。 当缺少 HB 关系时,就可能出现重排序问题。

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

  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作(注意:必须是同一个锁才适用)
    ,在监视器锁上的解锁操作必须在同一个监视器上的加锁操作之前执行;

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

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

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

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

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

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

深刻的理解 happen-before,理解某些规则下上一个操作对下一个操作的有序性和操作结果的可见性。同时,通过灵活的使用传递性规则,再对规则进行组合,就可以在不使用 volatile 和 synchronized 的情况下载两个线程间实现变量共享。

4.同步机制之voliate关键字

volatile 是 Java 虚拟机提供的最轻量级的同步机制。volatile 关键字有如下两个作用:

  • 被 volatile 修饰的变量对所有线程可见,当一条线程修改了一个被 volatile 变量的值,新值对于其他线程来说是可以立即得知的。
  • 禁止指令重排序优化。

那么 JMM 是如何保证 volatile 变量的可见性的?通过一下两点:

  1. 线程对 volatile 变量写入后,在执行后续的内存访问之前,线程必须把新值刷新到内存中。

  2. 每次使用 volatile 变量之前,都要从内存重新装载变量的值。

正确使用 volatile 保证线程安全的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class VolatileTest {

volatile boolean isOnline;

public void isUserOnline(){
isOnline=true;
}

public void doSayHello(){

if(isOnline){
System.out.println("hello");
}
}
}

由于对于 boolean 变量的操作属于原子性操作,因此可以通过使用 volatile 修饰,使用该变量对其他线程立即可见,从而达到线程安全的目的。

错误使用 volatile 的示例:

1
2
3
4
5
6
7
public class VolatileTest {
public static volatile int i =0;

public static void increase(){
i++;
}
}

需要注意的是以上代码不是线程安全的,java里面运算并非原子操作,比如 i++,是分成取值,添加分布执行的,当在执行添加的时候可能其他线程已经改变了i的值,所以这种情况下是无法用 volatile 来保证线程的安全性的。

使用 volatile 必须要满足以下条件:

  • 对字段的写操作不依赖于当前值(即运算结果不依赖当前变量的值),或者确保只有单一线程会修改改变了的值。
  • 变量不需要与其他的状态变量共同参与不变约束(即读取操作不依赖于其它非 volatile 字段的值)。

下面再通过一个例子看下 volatile 在禁止指令重排序的作用

以下程序不是线程安全的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

class VolatileExample {
int x = 0;
boolean v = false;
public void writer() {
x = 100;
v = true;
}

public void read() {
if (v == true) {
System.out.println("x"=x);
}
}
}

写两个线程,一个进行 write(叫 writer),一个进行 read(叫 reader),运行程序会发现输出 x 的值有时是100,有时却是0。因为在 writer 线程中,编译器可能会在 writer 线程中进行重排序写入操作,这就导致可能会出现 v= true 时,x 扔为0的情况。从而引起线程的不安全。 这种情况下用 volatile 修饰变量 v, 就可以禁止编译器进行指令重排序优化,这样 v=true,时,就可以确保 x=100 已经被执行, 从而保证了线程的安全性。

volatile 的原理和实现机制
在 x86处理器下通过工具获取 JIT 编译器生成的汇编指令来看看对 volatile 进行写操作 CPU 会做什么事情

1
2
Java代码: instance = new Singleton();  //instance是volatile变量
汇编代码: 0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令。lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
(2)它会强制将对缓存的修改操作立即写入主内存;
(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

如果对声明了 volatile 变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

如何考虑选用 synchronized 还是 volatile
大多数场景下 volatile 的总开销要比 synchronized 低 ,我们再 volatile 中选择的唯一判断依据就是 volatile 的语义能否满足使用场景的需求,即如果用 volatile 就能解决线程安全的问题,那就选用 volatile,否则才选用 synchronized 。需要注意的是,使用 volatile 的使用必须满足以下两个条件:

  • 对字段的写操作不依赖于当前值(即运算结果不依赖当前变量的值),或者确保只有单一线程会修改改变了的值,
  • 变量不需要与其他的状态变量共同参与不变约束(即读取操作不依赖于其它非volatile字段的值)

五、参考文献

《深入理解Java虚拟机》 – 周志明 第十二章
同步和Java内存模型
Java内存模型FAQ
全面理解Java内存模型(JMM)及volatile关键字