JVM · Java虚拟机原理 · JVM上语言·框架· 生态系统知识图谱

JMM 内存模型简析

2021-03-15  本文已影响0人  zcwfeng

高速缓存

cpu(CPU寄存器)<---> CPU高速缓存 <---> 主内存RAM

缓存一致性问题:
多个处理器的运算任务涉及统一块主内存时,可能导致内存不一致。为此需要个个处理器遵循一定协议,维护一致性。

cpu(CPU寄存器)<---> CPU高速缓存 <---> 缓存一致性协议 <---> 主内存RAM

java 内存模型 JMM(java memory model)

定义程序中各个变量的访问规则, 即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节

变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字 段和构成数值对象的元素,但不包括局部变量与方法参数,

因为后者是线程私有 的。(如果局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线 程共享,但是 reference 本身在 Java 栈的局部变量表中,它是线程私有的)。

工作内存 每条线程都有自己的工作内存(Working Memory,又称本地内 存,可与前面介绍的处理器高速缓存类比),线程的工作内存中保存了该 线程使用到的变量的主内存中的共享变量的副本拷贝。

工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及 其他的硬件和编译器优化。

JMM内存模型.jpg

JVM 内存操作的并发问题

  1. 工作内存数据一致性 各个线程操作数据时会保存使用到的主内存中的 共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,将导致 各自的的共享变量副本不一致,如果真的发生这种情况,数据同步回主内存以谁的副本数据为准? Java 内存模型主要通过一系列的数据同步协 议、规则来保证数据的一致性

  2. 指令重排序优化 Java 中重排序通常是编译器或运行时环境为了优化程 序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类: 编译期重排序和运行期重排序,分别对应编译时和运行时环境。 同样的, 指令重排序不是随意重排序,它需要满足以下两个条件

多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令重排序导致运行结果与预期不同,可以声明用volatile关键字

  1. Java 内存间的交互操作
java内存交互操作.jpg

线程 1 和线程 2 都有主内存中共享变量 x 的副本,初始时,这 3 个内存中 x 的值 都为0。线程1中更新x的值为1之后同步到线程2主要涉及2个步骤:

从整体上看,这 2 个步骤是线程 1 在向线程 2 发消息,这个通信过程必须经过主 内存。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线 程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过 主内存来完成,实现各个线程提供共享变量的可见性。

内存交互的基本操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工 作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了 下面介绍 8 种操作来完成。

虚拟机实现时必须保证下面介绍的每种操作都是原子的,不可再分的(对于 double 和 long 型的变量来说,load、store、read、和 write 操作在某些平台上允 许有例外)

Java同步操作.jpg

内存交互基本操作的 3 个特性
原子性(Atomicity) 即一个操作或者多个操作 要么全部执行并且执行的 过程不会被任何因素打断,要么就都不执行。
可见性(Visibility) 是指当多个线程访问同一个变量时,一个线程修改了这 个变量的值,其他线程能够立即看得到修改的值。依赖主内存 作为传递媒介的方式来实现可见性。
有序性(Ordering) 有序性规则表现在以下两种场景: 线程内和线程间

happens-before 关系
描述下 2 个操作的 内存可见性:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。 happens-before 关系的分析需要分为单线程和多线程的情况:
单线程下的 happens-before 字节码的先后顺序天然包含 happens-before 关系:因为单线程内共享一份工作内存,不存在数据一致性的问题。 在 程序控制流路径中靠前的字节码 happens-before 靠后的字节码,即靠前 的字节码执行完之后操作结果对靠后的字节码可见。然而,这并不意味着 前者一定在后者之前执行。实际上,如果后者不依赖前者的运行结果,那 么它们可能会被重排序。
多线程下的 happens-before 多线程由于每个线程有共享变量的副本,如 果没有对共享变量做同步处理,线程 1 更新执行操作 A 共享变量的值之后, 线程 2 开始执行操作 B,此时操作 A 产生的结果对操作 B 不一定可见。

内存屏障

Java 中如何保证底层操作的有序性和可见性---可以通过内存屏障。

内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重 排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会 使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而 保障可见性。

Store1;
Store2;
Load1;
StoreLoad; //内存屏障 Store3;
Load2;
Load3;

对于上面的一组 CPU 指令(Store 表示写入指令,Load 表示读取指令),StoreLoad 屏障之前的 Store 指令无法与 StoreLoad 屏障之后的 Load 指令进行交换位置,即 重排序。但是 StoreLoad 屏障之前和之后的指令是可以互换位置的,即 Store1 可 以和 Store2 互换,Load2 可以和 Load3 互换。

Java 中对内存屏障的使用在一般的代码中不太容易见到,常见的有 volatile 和 synchronized 关键字修饰的代码块

8 种操作同步的规则
JMM 在执行前面介绍 8 种基本操作时,为了保证内存间数据一致性,JMM 中规
定需要满足以下规则:
 规则 1:如果要把一个变量从主内存中复制到工作内存,就需要按顺序的执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序的执行 store 和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必 须是连续执行。
 规则 2:不允许 read 和 load、store 和 write 操作之一单独出现。
 规则 3:不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了
之后必须同步到主内存中。
 规则 4:不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内
存同步回主内存中。
 规则 5:一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未
被初始化(load 或 assign )的变量。即就是对一个变量实施 use 和 store 操作之
前,必须先执行过了 load 或 assign 操作。
 规则 6:一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作
可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock
操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现。
 规则 7:如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行
引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。
 规则 8:如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;
也不允许去 unlock 一个被其他线程锁定的变量。
 规则 9:对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行
store 和 write 操作)

volatile 型变量的特殊规则

volatile原理.jpg

在每个 volatile 写操作的前面插入一个 StoreStore 屏障。 该屏障除了保证 了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。

在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。 该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见。

在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。 该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存, 使 volatile 变量读取的为最新值。

在每个 volatile 读操作的后面插入一个 LoadStore 屏障。 该屏障除了禁止 了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓 存,使其他线程 volatile 变量的写更新对 volatile 读操作的线程可见。

应用场景
“一次写入,到处读取”,某一线程负责更新变量,其他线程只读 取变量(不更新变量),并根据变量的新值执行相应逻辑。例如状态标志位更新, 观察者模型变量值发布。

扩展,可能不同环境有变化。
long 和 double 型变量的特殊规则
Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 种 操作都具有原子性,但是对于 64 位的数据类型(long 和 double),在模型中特别 定义相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作 分为 2 次 32 位的操作来进行。也就是说虚拟机可选择不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性。由于这种非原子性,有可能导 致其他线程读到同步未完成的“32 位的半个变量”的值。
不过实际开发中,Java 内存模型强烈建议虚拟机把 64 位数据的读写实现为具有 原子性,目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子 操作来对待,因此我们在编写代码时一般不需要把用到的 long 和 double 变量专 门声明为 volatile。

上一篇下一篇

猜你喜欢

热点阅读