本部分介绍Java是如何利用JMM解决并发中的有序性问题的。
由于CPU技术的发展,CPU会优化待执行的指令序列,使指令执行顺序和代码顺序略有不同,可能会导致代码执行出现有序性问题。
内存屏障
内存屏障(Memory Barrier)又称内存栅栏(Memory Fences),是一系列的CPU指令,用来保证特定操作的执行顺序。
重排序
为了提高性能,编译器和CPU常常会对指令进行重排序,包括:
- 编译器重排序:编译器在不改变单线程程序语义(as-if-serial)的前提下,可以重新安排语句的执行顺序。
- CPU重排序:为了CPU执行效率,流水线(Pipeline)都是并行处理的,处理次序和程序次序允许不一致。包括两类:
- 指令集重排序:现在处理器采用指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统重排序:由于处理器使用了主存和高速缓存,这使得加载和存储操作会乱序执行。
As-if-Serial 规则
As-if-Serial 规则内容为:无论如何重排序,都必须保证代码在单线程下运行正确。为了遵循 As-if-Serial 规则,编译器和CPU不会对存在数据依赖关系的操作进行重排序。
As-if-Serial 规则只能保障单内核指令重排序之后的执行结果正确,不能保障多内核以及跨CPU指令重排序之后的执行结果正确。
硬件层面的内存屏障
内存屏障可以保障跨CPU指令重排序之后的程序结果正确。
硬件层内存屏障的定义
硬件层常用的内存屏障有以下三种:
- 读屏障(Load Barrier)。在指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主存加载数据。并且,读屏障会告诉CPU和编译器,先于这个屏障的指令必须先执行。读屏障对应着X86处理器上的ifence指令。
- 写屏障(Store Barrier)。在指令后插入写屏障指令能让高速缓存中的最新数据更新到主存,让其他线程可见。并且,写屏障会告诉CPU和编译器,后于这个屏障的指令必须后执行。写屏障对应X86处理器上的sfence指令。
- 全屏障(Full Barrier)。具备读屏障和写屏障的能力,对应X86处理器上的mfence指令。
X86处理器上的lock前缀指令也具有内存全屏障功能。
硬件层的内存屏障的作用
作用有以下两点:
- 阻止屏障两侧的指令重排序。告诉CPU和编译器先于这个屏障的指令必须先执行,后于这个屏障的指令必须后执行。
- 强制让高速缓存的数据失效。强制吧高速缓存中的最新数据写回主存,让高速缓存中相应的脏数据失效。
JMM 详解
JMM 介绍
JMM(Java Memory Model,Java内存模型)定义了一组规则和规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程是可见的。实际上,JMM提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。
JMM 的另一大价值在于能屏蔽各种硬件和操作系统的访问差异,保证Java程序在各种平台下对内存的访问最终都是一致的。
Java内存模型定义了两个概念:
- 主存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主存中,无论该实例对象是成员变量还是方法的本地变量,还包括共享的类信息、常量、静态变量。由于是共享数据区域,因此多线程访问同一变量会出现线程安全问题。
- 工作内存:主要存储当前方法的所有变量信息(工作内存中存储着主存中的变量副本)。线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
JMM的规定如下:
- 所有变量存储在主存中。
- 每个线程都有自己的工作内存,且对变量的操作都是在工作内存中进行的。
- 不同线程之间无法直接访问彼此工作内存的变量,要想访问你只能通过主存来传递。
在JMM中,Java线程,工作内存、主存之间的关系大致如下图所示。
JMM 与 JVM 物理内存的区别
JMM 属于概念和规范的模型,是一个参考性质的模型。虽然 JVM 也是一个概念和规范维度的模型,但是大家常常将JVM理解为实体的、实现维度的虚拟机,通常是指 HotSpot VM。
Java 代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所有管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。在《Java虚拟机规范(Java SE 8)》中描述了JVM运行时内存区域的结构,如下图所示。
JVM比较复杂,后续计划专门学习,这里就不详细展开了。
JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。
JMM 的8个操作
JMM 定义了一套自己的主存和工作内存之间的交互协议,其中包含8种操作,并且要求JVM具体实现必须保证都是原子的。具体如下表:
操作 | 作用对象 | 说明 |
---|---|---|
Read(读取) | 主存 | Read操作把一个变量的值从主存传输到工作内存中 |
Load(载入) | 工作内存 | Load操作把Read从主存中得到的变量载入工作内存的变量副本中 |
Use(使用) | 工作内存 | Use操作把工作内存中的一个变量的值传递给执行引擎 |
Assign(赋值) | 工作内存 | 执行引擎通过Assign操作给工作内存变量赋值 |
Store(存储) | 工作内存 | 把工作内存中的一个变量的值传递到主存中 |
Write(写入) | 主存 | 把Store操作从工作内存中得到的变量值放入主存的变量中 |
Lock(锁定) | 主存 | 把一个变量标识为某个线程独占状态 |
Unlock(解锁) | 主存 | 把一个处于锁定状态的变量释放出来,释放后可以被其它线程锁定 |
8个操作之间的关系可以参考下图。
JMM 还规定了执行上述8中操作时必须满足如下规则:
不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。
一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
JMM如何解决有序性问题
JMM 提供了自己的内存屏障指令,要求JVM编译器实现这些指令。
JMM 内存屏障主要有 Load 和 Store 两类,具体如下:
- Load Barrier(读屏障)。在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主存家在数据。
- Store Barrier(写屏障)。在写指令之后插入写屏障,能让写入缓存的最新数据写回主存。
在实际使用时,会对以上两类屏障两两组合:
LoadLoad(LL)屏障。在执行预加载(或支持乱序处理)的指令序列中,通常需要显式地声明LoadLoad屏障,因为这些Load指令可能会依赖其他CPU执行的Load指令的结果。
StoreStore(SS)屏障。通常情况下,如果CPU不能保证从高速缓存向主存或其它CPU按顺序刷新数据,那么它需要使用StoreStore屏障。
LoadStore(LS)屏障。该屏障用于在数据写入操作执行前确保完成数据的读取。
StoreLoad(SL)屏障。该屏障用于在数据读取操作执行前,确保完成数据的写入。该屏障开销最大,但它是一个全能型屏障。
volatile 语义中的内存屏障
Java 代码中,volatile 关键字主要有两层语义:
- 不同线程对 volatile 变量的值具有内存可见性,即一个线程修改了某个 volatile 变量的值,该值对其它线程立即可见。
- 禁止进行指令重排序。
基于保守策略的 volatile 操作的内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
这是JMM建议的策略,不同的处理器有不同的“松紧度”的处理器内存模型。
Happens-Before规则
JMM定义了 Happens-Before(先行发生)规则,并且确保只要两个Java语句之间必须存在Happens-Before关系,JMM尽量确保这两个Java语句之间的内存可见性和指令有序性。
Happens-Before 规则主要包括:
- 程序顺序执行规则(as-if-serial规则)。在同一个线程中,有依赖关系的操作按照先后顺序,前一个操作必须先行发生于后一个操作。
- volatile变量规则。对volatile变量的写操作必须先行发生于对volatile变量的读操作。
- 传递性规则。如果A操作先于B操作,而B操作又先行于C操作,那么A操作先于C操作。
- 监视锁规则(Monitor Lock Rule)。对一个监视锁的解锁操作先行发生于后续对这个监视锁的加锁操作。
- start规则。对线程的start操作先行于这个线程内部的其它任何操作。
- join规则。如果线程A执行了B.join()操作并成功返回,那么线程B中的任意操作先行发生于线程A所执行的B.join()操作。