Java内存模型
- 1. 前言
- 2. 主内存与工作内存
- 3. JMM解决什么问题?
- 4. JMM内存交互
- 5. Happens-Before
- 1. 程序的顺序性规则
- 2. volatile 变量规则
- 3. 管程中锁的规则
- 4. 线程启动规则
- 5. 线程join规则
- 6. 其他规则
1. 前言
-
内存模型
这个概念。我们可以理解为: 在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理计算机可以有不 一样的内存模型,JVM 也有自己的内存模型。 -
JVM 中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序 在各种平台下都能 达到一致的内存访问效果。
-
从开发者角度而言, Java内存模型描述了在多线程代码中哪些行为是合 法的,以及线程如何通过内存进行交互。它描述了“程序中的变量“ 和 ”从内存或 者寄存器获取或存储它们的底层细节”之间的关系。
-
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体 来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及Happens-Before 规则
2. 主内存与工作内存
1.JMM 的主要目标是定义程序中各个变量
的访问规则,即在虚拟机中将变量
存储到内存和从内存中取出变量
这样的底层细节。此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数值对 象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题.。
注意点如下:
- 变量不是我们在代码中·
int i = 0;
这种变量。- 这讲的是共享。不共享的不会出问题。
2.JMM 规定了所有的变量都存储在主内存(Main Memory)中。
3.每条线程还有自己的工作内存(Working Memory),工作内存中保留了 该线程使用到的变量的主内存的副本。工作内存是 JMM 的一个抽象概念,并不 真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
4.线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存 中的变量。不同的线程间也无法直接访问对方工作内存中的变量,线程间变量 值的传递均需要通过主内存来完成。
注意:为了获得较好的执行效能
- JMM 并没有限制执行引擎使用处理器的特定寄存器或缓存来和主 存进行交互
- JMM 也没有限制即时编译器调整指令执行顺序这类优化措施
3. JMM解决什么问题?
-
工作内存数据一致性:可见性问题
- 各个线程操作数据时会使用工作内存中的主内存中共享变量副本,当多个 线程的运算任务都涉及同一个共享变量时,可能导致各自的共享变量副本不一 致。如果真的发生这种情况,数据同步回主内存以谁的副本数据为准?
- Java 内存模型主要通过一系列的数据同步协议、规则来保证数据的一致 性。
-
约束指令重排序优化:有序性问题
Java 中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指 令进行重新排序执行的一种手段。重排序可分为两类:编译期重排序和运行期 重排序(处理器乱序优化),分别对应编译时和运行时环境
同样的,指令重排序不是随意重排序,它需要满足以下几个条件:
- 在单线程环境下不能改变程序运行的结果。即时编译器(和处理器)需 要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情 况下,要给程序一个顺序执行的假象。即使经过重排序后的执行结果要 与顺序执行的结果保持一致。
- 存在数据依赖关系的不允许重排序。
- 多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令 重排序导致运行结果与预期不同。
4. JMM内存交互
1.JMM 定义了 8 个操作来完成主内存和工作内存之间的交互操作。 JVM 实现 时必须保证下面介绍的每种操作都是 原子的(对于 double 和 long 型的变量来 说, load、store、 read、和 write 操作在某些平台上允许有例外 )。
lock
(锁定) - 作用于主内存的变量,它把一个变量标识为一条线程独 占的状态unlock
(解锁) - 作用于主内存的变量,它把一个处于锁定状态的变量 释放出来,释放后的变量才可以被其他线程锁定。read
(读取) - 作用于主内存的变量,它把一个变量的值从主内存传输 到线程的工作内存中,以便随后的 load 动作使用。load
(载入) - 作用于工作内存的变量,它把 read 操作从主内存中得到 的变量值放入工作内存的变量副本中。use
(使用) - 作用于工作内存的变量,它把工作内存中一个变量的值传 递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令 时就会执行这个操作。assign
(赋值) - 作用于工作内存的变量,它把一个从执行引擎接收到 的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。store
(存储) - 作用于工作内存的变量,它把工作内存中一个变量的值 传送到主内存中,以便随后 write 操作使用。write
(写入) - 作用于主内存的变量,它把 store 操作从工作内存中得 到的变量的值放入主内存的变量中。
2.如果要把一个变量从主内存中复制到工作内存,就需要按序执行 read
和 load
操作;如果把变量从工作内存中同步回主内存中,就需要按序执行store
和 write
操作。但 Java 内存模型只要求上述操作必须按顺序执行,而 没有保证必须是连续执行
3.JMM 还规定了上述 8 种基本操作,需要满足以下规则:
read
和load
必须成对出现;store 和 write 必须成对出现。即不允许 一个变量从主内存读取了但工作内存不接受,或从工作内存发起回写了 但主内存不接受的情况出现。- 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改 变了之后必须把变化同步到主内存中。
- 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工 作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个 未被初始化(load 或assign )的变量。换句话说,就是对一个变量实 施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作。
- 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操 作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同 次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对 出现。
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在 执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变 量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操 作,也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中 (执行 store 和 write 操作)
4.整体如下图所示:
5. Happens-Before
1.Java 内存模型里面,最晦涩的部分就是 Happens-Before 规则了,Happens-Before 规则最初是在一篇叫做 Time, Clocks, and the Ordering of Events in a Distributed System 的论文中提出来的,在这篇论文中, Happens- Before 的语义是一种因果关系
2.如何来理解Happens-Before呢?如果就字面意思的话网上很多文章都翻译 称:先行发生, Happens-Before 并不是说前面一个操作发生在后续操作的 面,它真正要表达的是: 前面一个操作的结果对后续操作是可见的。 打个比方: A Happens-Before B,可表明A操作的结果对B是可见的。
3.Happens-Before有一个特性就是传递性:即 A Happens-Before B , B Happens-Before C,则 A Happens-Before C .
4.Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求 编译器优化后一定遵守 Happens-Before 规则,具体的一些规则如下:
1. 程序的顺序性规则
- 这条规则是指在一个线程中,按照程序顺序(可能是重排序后的顺序), 前面的操作 Happens-Before 于后续的任意操作,程序前面对某个变量的修改 一定是对后续操作可见的:
ClassReordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
2. volatile 变量规则
- 这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对 这个 volatile 变量的读操作:
class VolatileExample {
int x = 0;
volatile boolean v = false;
// 线程A 先
public void writer() {
x = 42;
v = true;
}
// 线程B 后
public void reader() {
if (v == true) {
// 这里x会是多少呢?
}
}
}
-
特别注意:
- 我们声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入
- volatile 可以用来解决可见性问题
-
这里有两点:
- 线程B能看到线程A对变量v的写结果
- 结合顺序性规则和传递性特性可知在线程B中仍然能得到x的值为42
-
注意:第二点只有从jdk1.5开始才能满足,因为Java 内存模型在 1.5 版本对 volatile 语义进行了增强(禁止指令重排),1.5以前有可能x的值还为0。
3. 管程中锁的规则
- 对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
1.大致了解一下什么是管程
- 管程(Monitors,也称为监视器),是一种通用的同步原语,能够实现对 共享资源的互斥访问, Java 中指的就是 synchronized ,synchronized 是 Java 里对管程的实现。
- 管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之 前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译 器帮我们实现的
int x = 10;
public void syn() {
synchronized (this) { //此处自动加锁
if (this.x < 12) {
this.x = 12;
}
} //此处自动解锁
}
2.从这个规则我们可以得出,释放锁之后,同步代码块中的操作结果对后续 加锁时是可见的。同时结合前面讲的JMM内存操作可知, unlock时会将变量从 工作内存刷到主内存中,获取锁时会从主内存中去读取变量值到工作内存中, 也能证明锁的解锁 Happens-Before 于后续对这个锁的加锁
4. 线程启动规则
- 它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
static int var = 66;
// 主线程A
public static void t1() {
Thread B = new Thread(()->{
// 主线程调用B.start()之前
// 所有对共享变量的修改,此处皆可见 // 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();
}
5. 线程join规则
- 它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够 看到子线程的操作。当然所谓的“看到” ,指的是对共享变量的操作结果可见。
static int var = 55;
//主线程A
public static void t1() {
Thread B = new Thread(()->{
// 此处对共享变量var修改
var = 66;
});
// 主线程启动子线程
B.start();
//主线程等待子线程B结束
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见 // 此例中,var==66
}
6. 其他规则
- 线程中断规则: 对线程
interrupt()
方法的调用 Happens-Before 被中断线程的代码检测 到中断事件的发生,比如我们可以通过Thread.interrupted()/isInterrupted
方法检测到是否有中断发生。 - 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的 开始。