JMM(Java内存模型)详解
# JMM(Java内存模型)详解
# 1、JMM(Java内存模型)简介
# 简介
Java内存模型(Java Memory Model,JMM)是Java虚拟机规范的一部分,也就是说JMM本质是一系列的规范,这些规范用于定义Java程序中多线程之间共享内存的行为。它描述了变量(包括实例字段、静态字段和数组元素)在内存中的存储和读取方式,以及在多线程环境中如何确保可见性和有序性。
# Java为什么要制定内存模型规范?
主要有以下几个方面的原因:
①、跨平台一致性
不同平台的硬件和操作系统对内存和线程管理有不同的实现(例如WIndow系统和Linux系统都有自己的一套内存模型)。JMM通过提供一个统一的内存模型,屏蔽了底层硬件和操作系统的差异,确保Java程序在不同平台上运行的一致性和正确性。②、安全性保证
JMM通过定义可见性和有序性规则,防止了由于指令重排序和内存不可见性导致的并发安全问题。这样,开发者在编写并发代码时,可以更方便地保证程序的正确性和安全性。例如Java提供了一些基本原则和工具,如volatile关键字、synchronized关键字、happens-before
规则等,帮助开发者在编写并发程序时更容易地控制线程间的交互。③、性能优化
JMM规范了在确保多线程运行正确性的前提下,允许编译器和处理器进行必要的优化,从而提高程序的执行效率。开发者可以利用JMM的规则,在确保线程安全的同时,编写出性能更高的并发程序。JMM通过定义“happens-before”关系,规范了这些优化的边界条件,确保在多线程环境下程序执行的正确性。
# 2、原子性、有序性、可见性
在Java并发编程基础知识点 (opens new window)这篇文章中已经简单介绍了这三个特性。
下面结合JMM再来详细了解下。
# ①、原子性
原子性指的是一个操作或一组操作要么全部执行并且中间不被打断,要么全部不执行。在多线程环境中,原子操作是不可分割的,即在一个操作完成之前,其他线程不能访问和修改相同的资源。
# 什么原因导致操作不具备原子性?
我们知道要保证线程安全需要保证原子性,那到底是什么原因造成的一个操作或一组操作不具备原子性了呢?
下面是两个比较重要的原因
时间片轮转和上下文切换: 操作系统的分时复用这种资源管理技术是一个非常重要的原因,我在Java并发编程基础知识点 (opens new window)这篇文章中关于线程上下文切换的原因里面提到过一点,时间片到期:操作系统采用时间片轮转调度,每个线程只能占用CPU一个时间片,到期后进行上下文切换。在上下文切换的过程中,当前线程的操作可能未完成,导致多个线程对共享资源的操作变得不可预测和非原子性。
操作本身的分解性:
在Java代码中,i++
这种看似一个简单的操作,实际上包含3个步骤
读取变量 i 的值; // 将变量 i 从内存中读取到 CPU寄存器中;
增加变量 i 的值; // 在CPU寄存器中执行 i + 1 操作;
将增加后的值写回变量 i; // 将计算结果i写入内存(CPU缓存机制可能会导致主存和CPU缓存的不一致)
在多线程环境下,这些步骤可能被多个线程交错执行,导致最终结果不正确。
比如: 线程1执行了读取变量 i 的值;
后,就发生了线程上下文切换,切换到了线程2执行,假如线程2连续执行了这三条指令后,再切换到线程1执行后续两条指令 增加变量 i 的值; 将增加后的值写回变量 i;
,那么最终写入主存的值就是2,而我们希望得到的正确结果其实是3。
代码示例:
下面的代码中我们没有编写线程安全相关的操作,期望得到20000这个结果,但是实际上很难得到20000这个结果。
import java.util.concurrent.CountDownLatch;
public class TestA {
private static int count;
public static void main(String[] args) throws Exception {
CountDownLatch startSignal = new CountDownLatch(1);
Thread t1 = new Thread(() -> {
try {
startSignal.await();
for (int i = 0; i < 10000; i++) {
count++;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
Thread t2 = new Thread(() -> {
try {
startSignal.await();
for (int i = 0; i < 10000; i++) {
count++;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t2");
t1.start();
t2.start();
// 让t1,t2 同时开始
startSignal.countDown();
t1.join();
t2.join();
System.out.println(count);
}
}
执行结果:11384 (也可能是其他大于0且小于等于20000的整数)
# ②、可见性
可见性指的是一个线程对共享变量的修改能够及时对其他线程可见。
# 什么原因导致共享变量不具备可见性?
CPU缓存。
现代计算机系统为了提高性能,通常在CPU和主内存之间引入了多级缓存(如L1、L2、L3缓存)。这些缓存的目的是加快数据访问速度,因为访问缓存的速度远快于访问主内存。
就和我们在Java应用和数据库之间加一层Redis或者JVM缓存的目的类似。都是为了提高访问数据的速度。
在多线程环境下,每个线程可能运行在不同的CPU核心上,每个核心都有自己的缓存。线程对共享变量的修改首先在自己的缓存中(线程的工作内存)进行,而不是立即写回到主内存。因此,其他线程读取共享变量时,可能从自己的缓存中获取到过期的数据,导致共享变量的修改对其他线程不可见。
代码示例:
public class TestA {
private static boolean flag = false;
static int i = 0;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1 修改了flag 为 true");
flag = true;
}, "t1").start();
while (!flag) {
i++;
}
System.out.println("循环了 " + i + "次");
System.out.println(flag);
// ============= 把线程休眠时间变长 ===============
flag = false;
i = 0;
new Thread(() -> {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程2 修改了flag 为 true");
flag = true;
}, "t2").start();
while (!flag) {
i++;
}
System.out.println("循环了 " + i + "次");
System.out.println(flag);
}
}
运行结果:
注意:如果你尝试运行这段代码,如果没有出现死循环,可以尝试把t2线程的休眠时间调大些,比如调整到100ms或者更高,就会出现死循环。(前提是你使用默认的JVM启动参数)
上面代码有两个问题 :
private static boolean flag = false;
flag变量是共享变量。 且没有被volatile修饰。
其中程t1休眠了了1ms,主线程循环了206500次后,读取到了线程t1对flag变量值的修改,结束了循环。
然而线程变量t2休眠了10m后的确把flag改为了true,但是,主线程这次却没有读取到flag值的变化。 导致了死循环。
这个例子比较有意思的点在于,我不确定到底是内存可见性问题,还是JIT编译器的问题。
我尝试过给flag变量增加了一个volatile修饰,结果两次循环都能正常结束。
还有一种方式,不用volatile修饰flag变量,但是在JVM启动的时候禁用JIT编译器优化。
在JVM的启动参数添加 -Xint
禁用任何即时编译优化。
同样两次循环都能正常结束。
执行结果:
线程1 修改了flag 为 true
循环了 83538次
true
线程2 修改了flag 为 true
循环了 703209次
true
所以这个例子的第二个死循环到底是内存可见性问题导致的还是JIT优化导致的,我也没太搞清楚,如果你有什么见解,欢迎留言!
# JIT简介
为了对上面的问题进行更深入的探究,我有查了一些JIT相关的资料。简单总结下JIT然后再对上面的问题分析下。
JIT(Just-In-Time)编译器是Java虚拟机(JVM)中的一个关键组件,它负责在程序运行过程中动态地将字节码(即Java编译后的.class文件中的代码)转换为特定于平台的机器代码。这一过程旨在提高程序的执行效率,因为它能够针对运行时的数据和实际使用模式进行优化,而不仅仅是基于静态代码分析。
JIT工作原理
- 加载与解释执行:当Java程序启动时,JVM首先加载类文件并由解释器执行字节码。这时,程序的运行速度可能较慢,因为解释器逐条解释执行字节码。
- 热点检测:JVM内置的监控系统会识别出那些被频繁执行的代码区域,这些被称为“热点代码”。热点检测是通过计数器来实现的,比如方法调用次数、回边(循环体)执行次数等(我怀疑上面第二次循环就是循环执行次数够多,导致JIT进行了某种优化,导致读取的flag一直是false)。
- 编译与优化:一旦检测到热点代码,JIT编译器便开始工作。它将这些频繁执行的字节码编译成本地机器代码,并且在此过程中应用多种优化技术,如方法内联、循环展开、消除公共子表达式、类型推测等,以减少运行时的开销,提升执行速度(JIT可能在上面第二次循环,直接把
while(!flag)
优化成了while(true)
)。 - 代码缓存:编译后的本地代码会被存储在代码缓存中,后续遇到同样的代码路径时,可以直接从缓存中执行优化过的机器代码,避免了解释执行的开销。(一直使用缓存的
while(true)
导致第二次的死循环这是我的猜测)
JIT的优点 性能提升:通过针对运行时上下文进行优化,JIT能够显著提高Java程序的执行效率。 适应性:能够根据程序的实际运行情况动态调整优化策略,对不同类型的工作负载做出响应。 透明性:开发者无需关心编译细节,JVM自动管理整个编译过程。
缺点 启动延迟:因为需要收集运行时信息并进行编译,所以程序启动初期可能不如直接执行机器代码的语言快。 内存占用:JIT编译产生的机器代码和相关数据结构会占用额外的内存空间。
结合JIT的一些资料再总结下上面代码的运行结果:
对第一次循环正常结束的分析: 最终分析问题本质还是内存可见性问题,虽然第一个循环成功结束了,并且也读到了flag的最新值true,但是这可能是一种不确定的行为,因为flag没有使用volatile修饰,本质上就无法保证可见性,但是也存在这种能读取到最新值的可能。但是具体什么情况下能读取到主存的最新值,我找了很多资料回答的都很模糊。可能还需要更深入的理解操作系统和JVM的知识才能更好的解释这个情况。
对第二次出现死循环的分析: 第二个循环,将线程休眠时间增加到10毫秒。这可能导致了一种情况,即主线程在检测 flag 变量的值时,由于指令重排或JIT优化等原因,可能不会重新从主内存中读取 flag 变量的最新值。相反,它可能仅仅在线程的工作内存中进行读取,而该工作内存中的值仍然是 false。因此,主线程将陷入无限循环,因为它无法检测到 flag 变为 true。
使用+Xint
参数第二次循环可以正常结束的分析:
在使用+Xint
参数启动后,在解释器模式下,由于不涉及 JIT 编译器对代码进行优化,可能会出现更频繁的内存刷新操作,或者更频繁地从主内存中重新读取变量的值。这种行为可能会掩盖掉由于缺乏 volatile 关键字导致的内存可见性问题。所以使用+Xint
参数启动后即使不加volatile 关键字也能结束第二次循环。
我上面的分析用了很多可能之类的模糊性词汇。因为多线程有很多不确定的因素,我写了一些最可能的情况。 如果你有更好的解释,欢迎留言!
# CPU缓存、线程工作内存、主存三者的关系
现代计算机系统为了提高性能,通常在CPU和主内存之间引入了多级缓存(如L1、L2、L3缓存),这就是CPU缓存。
在多核处理器的系统中,每个 CPU 核心都有自己的高速缓存(CPU Cache),这些缓存用于存储被频繁访问的数据,以提高访问速度。每个线程在执行过程中,会将其所需的变量存储在自己所在 CPU 核心的缓存中,这部分称为线程的工作内存。
主内存: 主内存是所有线程共享的内存区域,它存储了所有的共享变量的真实值。当一个线程修改了共享变量的值时,这个修改首先发生在该线程的工作内存中,然后通过某种机制(如内存屏障)将最新值刷新到主内存中,使得其他线程可以看到这个修改。
其中线程的工作内存和主内存都是JMM抽象出来的概念。
关于线程工作内存和主内存的关系: 线程的工作内存: 每个线程的工作内存包含了它们需要访问的变量的副本。这些变量的修改首先发生在工作内存中。 画个图帮助理解:
多核处理器的影响: 在多核处理器系统中,每个 CPU 核心有自己的缓存。这就意味着,如果一个线程在一个 CPU 核心上修改了共享变量,并且其他线程在不同的 CPU 核心上运行,那么其他线程可能不会立即看到这个修改,因为它们读取的是各自 CPU 核心的缓存中的值,而不是主内存中最新的值。可以结合上图进行理解。
这种情况下,为了确保所有线程能够看到共享变量的最新值,必须使用适当的同步机制(如 volatile 关键字、锁、或者使用并发工具类),或者通过合适的内存屏障来保证数据的正确同步和可见性,这些操作都在Java内存模型(JMM)的规范之内。
# ③、有序性
有序性指的是程序执行的顺序与代码的顺序一致。
# 什么原因导致有序性问题?
指令重排序。
在多线程环境中,编译器和处理器可能会对指令进行重排序,以提高性能,但这种重排序的前提是不能改变单线程程序的语义。
代码示例:
public class TestA {
private static int a, b, c, d;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 500000; i++) {
a = 0;
b = 0;
c = 0;
d = 0;
Thread t1 = new Thread(() -> {
a = 1;
b = 1;
c = 1;
d = 1;
}, "t1");
Thread t2 = new Thread(() -> {
if (b == 1 && a == 0) {
System.out.println("b == 1 && a == 0");
System.out.println("发生了指令重排序");
}
if (c == 1 && (b == 0 || a == 0)) {
System.out.println("c == 1 && (b == 0 || a == 0)");
System.out.println("发生了指令重排序");
}
if (d == 1 && (a == 0 || b == 0 || c == 0)) {
System.out.println("d == 1 && (a == 0 || b == 0 || c == 0)");
System.out.println("发生了指令重排序");
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
}
}
}
运行结果: 多运行几次就能发现会有指令重排序的情况出现
c == 1 && (b == 0 || a == 0)
发生了指令重排序
# 指令重排序从哪些方面提升性能?
①、提高指令级并行度(Instruction-Level Parallelism, ILP):
指令重排序能够让更多的指令同时执行。处理器可以在执行一条指令时,同时准备和执行其他不相关的指令,从而提高整体的指令吞吐量。
通过重排序,处理器可以找到更多没有数据依赖关系的指令,使得这些指令能够并行执行。②、减少流水线停顿: 流水线处理器依靠指令的连续执行来提高性能。当某条指令需要等待之前的指令完成时,会导致流水线停顿,影响性能。 重排序可以让处理器先执行那些不需要等待的指令,从而减少停顿,提高流水线的利用率。
③、隐藏内存访问延迟: 内存访问通常比处理器执行指令要慢得多。重排序可以让处理器在等待内存访问完成的同时执行其他指令,从而隐藏内存访问的延迟。 这样可以避免处理器因为等待内存而闲置,提高整体执行效率。
④、更好地利用处理器资源: 处理器中有多个执行单元(如算术逻辑单元、加载/存储单元等)。通过重排序,处理器可以更好地分配指令到不同的执行单元,使得这些单元都能够高效地工作。避免某些执行单元闲置,同时其他执行单元在等待的情况。
# Java 源代码到最终CPU执行的指令序列会经历哪些重排序?
重排序类型 | 描述 |
---|---|
编译器优化 | 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。 |
指令级并行 | 现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 |
内存系统优化 | 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。 |
Java 源代码到最终CPU执行的指令序列可能会顺序经历以下重排序:
Java 源代码 -> 编译器优化 -> 指令级并行 -> 内存系统优化 -> 最终CPU执行的指令序列
总结:
上面说的 编译器优化
属于编译器的指令重排序,指令级并行和内存系统优化
属于处理器的指令重排序。
在不改变单线程程序语义的前提下,编译器可以重新安排语句的执行顺序。但是编译器指令排序不会保证多线程下的语义正确性。 多线程下的语义正确性应该由开发者自己通过代码方式保证。
有哪几种常见的数据依赖性形式?
类型 | 描述 |
---|---|
读后写 (RAW) | 一个指令需要读取另一个指令写入的数据。如果读指令在写指令之前执行,可能读取到错误的数据。 |
写后写 (WAW) | 两个指令都试图写入同一位置的数据。如果执行顺序改变,可能导致数据写入的结果与期望不符。 |
写后读 (WAR) | 一个指令试图写入一个位置的数据,而另一个指令尝试在此之前读取相同位置的数据。可能导致读取到不正确的数据。 |
# 3、JMM如何保证原子性、有序性、可见性
上面已经详细介绍了并发编程的三个重要性质原子性、有序性、可见性并演示了可能出现的问题。
那么JMM是如何解决这些问题的呢?我们继续往下看。
①、JMM保证原子性的方式:
synchronized 关键字:使用 synchronized 关键字可以确保代码块或方法在同一时刻只能被一个线程执行,避免多线程并发访问导致的数据竞争问题,从而保证操作的原子性。 原子类:Java 提供了一些原子类,例如 AtomicInteger、AtomicLong 等,它们使用了特殊的机器指令来保证操作的原子性,如 CAS(Compare-And-Swap)操作。②、JMM保证有序性的方式:
volatile 关键字:volatile 关键字修饰的变量,对该变量的读写操作都会直接在主内存中进行,不会使用线程的本地缓存,从而保证了变量的可见性和有序性。
happens-before 原则:JMM 定义了 happens-before 的规则,确保前一个操作的结果对后续操作是可见的。例如,对一个变量的写操作 happens-before 后续对该变量的读操作。③、JMM保证可见性的方式:
volatile 关键字:volatile 关键字不仅保证了禁止指令重排序,还保证了对 volatile 变量的写操作会立即刷新到主内存,而读操作会从主内存中读取最新的值,因此可以保证可见性。
锁机制:使用锁(如 synchronized、ReentrantLock 等)也可以保证一段同步代码块的可见性,当一个线程获取了锁,它会将工作内存中的共享变量刷新到主内存,其他线程再去获取锁时会从主内存中重新读取最新的值。
实际上还有一个比较容易被忽略的关键字 final ,final在并发编程里也是有其独特的作用的。
在并发编程中,final 关键字修饰的字段在构造函数中一旦初始化完成,且构造函数没有溢出(即构造函数在对象对其他线程可见前完成),其他线程就能看到初始化后的最终状态,而不会看到未初始化的状态。
说人话就是:
final 关键字在并发编程中的作用是确保在对象被共享给其他线程之前,该对象的 final 字段已经完全初始化。
如果一个对象被构造出来,并且它的 final 字段被赋值,那么其他线程在看到这个对象时,一定会看到这些 final 字段的正确值,而不是未初始化的状态。
举个栗子:
public class TestA {
public static void main(String[] args) {
MyClass obj = new MyClass(1,2);
// 假设这里有多线程并发访问 obj
System.out.println(obj.x); // 这里 x 可能会是 1,也可能是一个未初始化的状态(例如 0)
System.out.println(obj.y); // 这里 y 一定是 2
}
}
class MyClass {
int x;
final int y;
public MyClass(int x,int y) {
this.x = x;
this.y =y;
}
}
# 4、简单介绍volatile 、synchronized 、final关键字
关键字 | 作用 | 特点 | 使用场景 |
---|---|---|---|
volatile | 保证变量在多个线程间的可见性,保证对变量的读写操作不被重排序 | 可见性、禁止指令重排序 | 状态标记变量,简单的变量值变化场景 |
synchronized | 确保方法或代码块在同一时刻只能被一个线程执行 | 同时保证原子性、可见性 、有序性 | 需要确保方法或代码块在同一时刻只能被一个线程执行的场景 |
final | 确保变量在初始化后不可改变,并在并发编程中保证可见性 | 不可变性,确保构造函数完成后对所有线程可见 | 常量值,不希望被重写的方法,不希望被继承的类,构造函数完成后对所有线程可见的变量 |
# 5、happens-before规则
happens-before
是 Java 内存模型 (Java Memory Model, JMM) 中的一个关键概念,用于定义两个操作之间的内存可见性关系。
一个操作的结果对另一个操作可见,必须有 happens-before
关系约束。
以下是一些常见的 happens-before
规则:
规则 | 描述 |
---|---|
程序次序规则 | 在一个线程中,按照程序代码顺序,前面的操作 happens-before 于后面的操作。 |
监视器锁规则 | 一个解锁操作 happens-before 于后面对同一个锁的加锁操作。 |
volatile 变量规则 | 对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。 |
传递性 | 如果操作 A happens-before 操作 B,且操作 B happens-before 操作 C,那么操作 A happens-before 操作 C。 |
线程启动规则 | 如果线程 A 启动线程 B,那么线程 A 中的操作 happens-before 于线程 B 中的操作。 |
线程终止规则 | 线程 A 的所有操作 happens-before 于线程 A 检查线程 B 是否已经终止的操作。 |
线程中断规则 | 对线程的中断操作 happens-before 于被中断线程检测到中断事件的操作。 |
对象的构造规则 | 对象的构造函数执行结束 happens-before 于该对象的 finalizer 方法。 |
注意happens-before
定义的是操作之间的内存可见性关系。并非字面意义上的顺序关系。
下面是对happens-before规则的演示:
- ①、程序次序规则
在一个线程中,按照程序代码顺序,前面的操作happens-before
于后面的操作。
public void example() {
int a = 10; // 操作 A
int b = a + 100; // 操作 B
// 操作 A happens-before 操作 B
}
操作 A happens-before 操作 B ,那么对于操作B来说 操作A的结果对于操作B是可见的。
- ②、监视器锁规则
public class TestA {
private int value;
public synchronized void doA() {
value = 100; // 解锁操作
}
public synchronized void doB() {
int temp = value; // 加锁操作
}
// doA() 的解锁操作 happens-before doB() 的加锁操作
}
- ③、volatile 变量规则
对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。
public class TestA {
private volatile boolean flag = false;
public void doA() {
flag = true; // 写操作
}
public void doB() {
if (flag) { // 读操作
// flag 的写操作 happens-before flag 的读操作
}
}
}
- ④、传递性
如果操作 A happens-before 操作 B,且操作 B happens-before 操作 C,那么操作 A happens-before 操作 C。
// A happens-before B, B happens-before C, 能推出 A happens-before C
- ⑤、线程启动规则
如果线程 A 启动线程 B,那么线程 A 中的操作 happens-before 于线程 B 中的操作。
public class TestA {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
// 线程 B 的操作
});
// 线程 A 的操作
thread.start();
// 线程 A 的 thread.start()操作 happens-before 线程 B 的操作
}
}
- ⑥、线程终止规则
线程 A 的所有操作 happens-before 于线程 A 检查线程 B 是否已经终止的操作。
public class TestA {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
// 线程 B 的操作
});
thread.start();
thread.join(); // 检查线程 B 是否已经终止
// 线程 B 的所有操作 happens-before 线程 A 的 join 操作
}
}
- ⑦、线程中断规则
对线程的中断操作 happens-before 于被中断线程检测到中断事件的操作。
public class TerstA {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 检测中断事件
}
// 中断操作 happens-before 检测中断事件
});
thread.start();
thread.interrupt(); // 中断操作
}
}
- ⑧、对象的构造规则
对象的构造函数执行结束,happens-before
于该对象的 finalizer 方法。
public class FinalizerExample {
@Override
protected void finalize() throws Throwable {
super.finalize();
// 构造函数执行结束 happens-before finalize 方法
}
}
# happens-before
规则对于程序员和编译器、处理器来说意味着什么
对于程序员来说,编写Java的多线程程序,只要按照happens-before
规则来,就能保证变量的可见性和有序性。
- 可见性:如果一个操作 A happens-before 另一个操作 B,那么操作 A 的结果对于操作 B 是可见的。这意味着一个线程对共享变量的修改在另一个线程中是可以看到的,从而避免了脏读和其他并发问题。
- 有序性:happens-before 关系保证了在一个线程内的操作顺序不会被重排序到另一个线程所看到的顺序之外,从而保持程序的正确性。使得多个线程之间能够正确地协同工作
对于编译器、处理器来说,happens-before
规则,允许编译器、处理器在规则内对代码和指令做最大限度的优化来提升性能,同时保证多线程下程序的正确性。
- 编译器优化:编译器可以在不违反 happens-before 规则的前提下重新排序指令,以生成更高效的机器代码。
- 处理器优化:处理器可以在执行指令时进行乱序执行、指令并行等优化,只要最终的执行结果不违反 happens-before 规则。
总结一句话就是happens-before
规则为程序员编写正确的多线程程序提供了理论基础,同时为编译器和处理器提供了优化的自由度。
# 6、区分JMM对内存规则的描述和JVM的内存区域
JMM中 对于内存的规则描述 规定 所有变量都存储在主内存中,每个线程还有自己的工作内存,线程对变量的所有操作都在工作内存中进行,不能直接读写主内存中的变量。
JMM(Java Memory Model,Java内存模型)中描述的“主内存”是一个抽象概念,它涵盖了JVM中所有线程共享的数据区域,这包括但不限于堆内存中的对象实例和数组元素,还包括了“元空间(JDK8用元空间替换了方法区)”等,根据不同的JVM实现有所差异)中静态变量和常量池等。简而言之,JMM(Java Memory Model,Java内存模型)中描述的“主内存”是个抽象的概念,如果非要把这个概念和JVM中的内存区域对上号,主内存大致上可以理解成是线程间共享数据的存储区域。
而“工作内存”则是JMM为了理解多线程程序执行时每个线程内部的一个抽象概念。它并不是真实存在的物理内存区域,而是为了描述每个线程在执行代码时,为了提高效率,会将主内存中的变量副本存放在自己私有的内存区域中,这个区域就称为工作内存。
工作内存可以类比为CPU的高速缓存,每个线程都有自己的工作内存,线程对变量的操作(读取、赋值等)都是在工作内存中完成的,之后才按需与主内存同步。因此,工作内存更多是从逻辑层面描述了每个线程操作变量的一种模型,而不是直接对应JVM中的某个具体内存区域。它是JMM为了规范多线程环境下内存访问的规则而引入的概念。
总结下:
JMM的主内存是一个抽象概念。大致上可以理解成是线程间共享数据的存储区域。
包括但不限于堆内存中的对象实例和数组元素,还包括了“元空间(JDK8用元空间替换了方法区)”等,根据不同的JVM实现有所差异)中静态变量和常量池等。
JMM的工作内存同样是一个抽象概念。
大致可以关联到JVM内存区域的以下几个部分:
程序计数器(Program Counter Register):虽然它不直接存储变量,但它记录了当前线程执行的字节码位置,间接与线程的执行上下文相关。
虚拟机栈(Java Virtual Machine Stack):每个线程有自己的栈,栈帧中存储了局部变量表、操作数栈等,局部变量(非static、非final变量)的存储和操作实际上发生在这里。
本地方法栈(Native Method Stack):用于支持native方法的执行,虽然通常不直接涉及Java变量,但也是线程私有的。
JVM的内存区域 JVM的内存区域或者直接叫Java的内存区域,可以清晰地划分为几个不同的部分,这些部分各自承担着不同的职责。(后续总结JVM知识点的时候会详细介绍),下面用表格简单介绍一下。
JVM内存区域 | 描述 | 特点 |
---|---|---|
程序计数器 | 存储当前线程所执行的字节码的行号指示器 | 线程私有,唯一没有内存溢出风险的区域 |
虚拟机栈 | 存储Java方法执行时的局部变量、操作数栈等信息 | 线程私有,生命周期与线程相同,每个方法执行时都会创建一个栈帧 |
本地方法栈 | 存储Native方法执行时的数据和信息 | 线程私有,用于支持Native方法的执行 |
堆 | 存储对象实例和数组,是垃圾收集器的主要工作区域 | 线程共享,是JVM中最大的一块内存区域,可细分为新生代和老年代 |
方法区/元空间 (JDK8+) | 存储类信息、常量、静态变量等数据(JDK8前为永久代,后为元空间) | 线程共享,JDK8后使用元空间替代永久代,使用本地内存并动态调整大小 |
直接内存 | 不是JVM运行时数据区的一部分,但可能会被NIO等使用 | 不受JVM垃圾回收器管理,由操作系统管理,需注意内存溢出问题 |
注意:上面只是大致的关联,因为JMM中对于内存规则的描述是抽象的概念,而JVM内存区域是具象且实际存在的对内存区域的具体划分。