JVM系列 之 自底向上理解Java内存模型

🌱JVM系列 之 自底向上理解Java内存模型

Hey 大家好,我是 Shio👋。今天我们将深入探讨Java 内存模型

⚠️ 注意!!!

🚧🚧🚧 本文并不完整, 还未彻底 完成🚧🚧🚧

image-20240105215354498

💾内存模型发展

🚀 单处理器时代的纵向优化

在单处理器计算机时代,程序员只需编写单线程程序,除了算法降低复杂度外, 只能依赖硬件和编译器进行纵向优化

随着硬件发展速度趋于平缓,工程师们开始追求横向拓展,即在单台计算机中使用更多的处理器。

👾 缓存的引入与多处理器时代的问题

image-20240105215702513

为弥补CPU处理速度与内存读写速度的差距,引入了缓存以提高效率。然而,在多处理器环境下,缓存带来了数据不一致的问题。

🚧 缓存一致性协议的设计

image-20240105215925754

为此科学家们设计了缓存一致性协议,解决多个CPU缓存之间的同步问题这些协议, 保证了数据的一致性。

大致分为窥探型和基于目录型

可想而知, 为了保证多个CPU缓存一致性和同步, 协议中一定涉及到等待唤醒等同步步骤

对于CPU这种运算速率极快的组件来说, 任何等待都是对其性能极大的浪费 (CPU A 在读取 D时还需要等待 CPU B 将D写回到内存)

🛠 优化与异步化

image-20240105220954445

为减少性能问题,将CPU同步改为异步。(CPU A 在读取 D时, 注册一个读取D的消息, 然后CPUA就能去做自己的事情, 等CPUB写回数据D后 响应这个注册消息, CPU发现消息被响应后再去读D, 这样就能提高效率)

通过注册消息和响应的方式,提升了数据访问的效率。

然而,这可能带来指令重排序的问题,CPU仍需确保程序执行结果的正确性

image-20240106135011906

具体实现可以参考MESI缓存一致性协议这篇博客, 会对Store Buffer, Store Forwarding, Invaild Queue, 读屏障, 写屏障等相关概念进行讲解

🚀 Java线程模型

image-20240105222249634

随着高级语言的流行,工程师们开始设计编程语言级别的内存模型,以实现一致性的内存视图。

🌐 Java线程模型

硬件内存模型之上,还存在着为编程语言设计的内存模型,比如Java内存模型, 它定义了多个线程之间的交互方式和行为。

Java内存模型屏蔽了各种硬件和操作系统的内存访问差异,提供了一个统一的内存视图给开发者使用, 实现了Java程序能够在各种硬件平台下都能按照预期方式运行 。

Java内存模型的抽象如下图所示:

image-20240105222620913

如上图, 每个工作线程都拥有独占的本地内存, 本地内存中存储的是私有变量以及共享变量的副本, 并且使用一定机制来控制本地内存和贮存之间的读写数据时的同步问题

🗄 本地内存的构成

工作线程的本地内存可以抽象为线程栈(Thread Stack)堆(Heap)

在线程栈中,有两种类型的变量:原始类型的变量(如int、char)总是存储在线程栈上。而对象类型的变量,它的引用或者说指针是存储在线程栈上的,而具体的对象则存储在堆上。

而在堆中,保存了对象本身和由该对象引用的其他对象。

可以理解为,线程栈和堆都是对物理内存的一种抽象。这样的设计使开发者只需要关注自己编写的程序中涉及线程栈和堆的部分,而无需关心底层寄存器、CPU缓存, 内存的实现。

image-20240105222849465

🔗 JVM内存模型与硬件内存映射关系

可以猜测, 线程栈(Thread Stack)大部分情况在读写内存, 对内存读写性能要求高, 所以是在寄存器和缓存来实现

堆(Heap) 需要存储大量的内存, 需要更大的容量, 那么他可能大部分都是使用主存来实现的

这种映射关系使得Java程序在不同的硬件平台上都能够按照一致的方式进行运行,从而提供了强大的可移植性跨平台特性

📡 Java线程通信和指令

Java内存模型通过一些机制来实现主存与工作内存之间的数据传输与同步。

这种数据传递是线程之间的通信方式,主存与工作内存之间通过八个指令来实现数据的读写与同步。这些指令可以根据其作用分为两类:储存相关指令和工作内存相关指令。

作用于内存 作用于工作内存
lock : 锁定 load : 载入
unlock : 解锁 use : 使用
read : 读取 assign : 赋值
write : 写入 store : 存储

主存相关指令用于主存和工作内存的数据传输,包括lockunlockreadwrite指令。工作内存相关指令用于工作内存上的数据读写和同步操作,包括``load useassignstore`指令。

image-20240105224852649

了解这些指令的存在和作用,而无需详细记住每个指令的内容,有助于理解Java内存模型的设计原理。

🔍 可见性与原子性问题

在多线程编程中,可能会遇到可见性原子性问题。

可见性问题指的是,当一个线程将变量刷新到主内存后,其他线程如何获取变量的最新值。

原子性问题指的是,多个线程同时对变量进行操作,结果是否符合预期。

image-20240105225526017

假设本地内存A和本地内存B都保存了变量X的副本,且值都为1。线程A将X修改为2并刷新到主存中,此时线程B想要读取变量X,默认情况下将从本地内存B中读取,而此时本地内存B中的X仍旧是1,所以线程B无法获取到最新的值, 这就是一个可见性问题。

image-20240105225724949

假如线程A和B都从主存中读取了变量X, 此时X是等于1的分别在各自的本地内存中自增了, 以X变为了2 然后再刷新回主存, 这里就有一个问题 实际上总共自增了2次 , X应该是变为3的 , 但是主存中的X却为2, 这就是一个原子性问题。

下文我们将会通过具体代码进行的讲解

📚 并发三要素

当多个线程在并发操作共享数据时呢 可能会引发各种各样的问题 这些问题呢被总结为三个要素

  • 可见性
  • 原则性
  • 有序性

👁 可见性问题

可见性问题指的是当一个线程修改了共享变量的值时,其他线程需要立即知道该变化。

当一个线程在自己的工作内存中修改了某个变量,应该把该变量立即刷新到主内存,并使其他线程感知到这个修改

public class Main {
static int a = 1;

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread( () -> {
while (a != 2) {
}
});

Thread t2 = new Thread( () -> {
a = 2;
}
});
t1.start();
Thread.sleep(1000); // 在启动 t2 之前等待 1 秒
t2.start();
}
}

以上程序将会死循环, 因为对于t1来说, t2 对变量a修改不可见

🔄️有序性问题

线程B 需要读到被修改的变量D, 线程A应该修改, 但因为重排序导致线程没有及时修改变量D(指令重排序导致)

这句话可能有些拗口, 请看以下这个例子, 请问 代码执行到步骤4时, 变量i一定是1吗?

public class Main {
static int a = 0;
static boolean flag = false;

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
a = 1; // 步骤1
flag = true; // 步骤2
});

Thread t2 = new Thread(() -> {
if (flag) { // 步骤3
int i = a; // 步骤4
}
});

t1.start();
t2.start();
}
}

我们上面提到硬件内存模型中存在指令重排序, 而在其上层的Java内存模型也存在指令重排序的现象(为提高效率使用异步通信导致的)

当这种指令重排在单线程环境下,因为处理器会保证结果的一致性,我们并不需要关心这个问题。

但在多线程环境下, 由于指令重排序, 可能会导致运行顺序如下

image-20240106121542500

此时 a = 0;

如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内似表现为串行的语义”,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。

​ 《深入理解Java虚拟机》

⚛ 原子性问题

原子性问题指的是当多个线程对共享变量进行并发操作时,并发操作的结果是否与期望一致。

例如,多个线程同时对变量X进行自增操作,预期X的值应该递增两次,但实际上只递增了一次。

public class Main {
private static final int TOTAL = 10000;

// volatile 关键字解决可见性问题,但并没有解决原子性问题
private volatile static int count;

public static void main(String[] args) throws InterruptedException {
Main main = new Main();

Thread thread1 = new Thread(() -> main.addCount());
Thread thread2 = new Thread(() -> main.addCount());

thread1.start();
thread2.start();

try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
thread1.join();
thread2.join();
System.out.println(count);

}

private void addCount(){
int start = 0;
while (start ++ < TOTAL){
this.count ++;
}
}
}

上述结果将会在 1W ~ 2W之间, 原因是 在count++ 的操作并非原子性的

而是分为

  • 读取主内存值到本地内存

  • 修改本地内存值

  • 写回主内存

image-20240106134500287

这样两个线程总计+2的操作, 写入内存只+1

🔒 volatile与synchronized关键字

synchronized关键字在需要原子性、可见性和有序性这三种特性的时候都可以作为其中一种解决方案,看起来是“万能”的。的确,大部分并发控制操作都能使用synchronized来完成。

​ 《深入理解Java虚拟机》

synchronized

synchronized修饰的代码块及方法,在同一时间,只能被单个线程访问。

synchronized,是Java中用于解决并发情况下数据同步访问的一个很重要的关键字。当我们想要保证一个共享资源在同一时间只会被一个线程访问到时,我们可以在代码中使用synchronized关键字对类或者对象加锁。

原子性

保证原子性,提供了两个高级的字节码指令monitorentermonitorexit

通过monitorentermonitorexit指令,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

可见性

synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。而为了保证可见性,有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。

  • 【进入】synchronized 块的内存语义是把在 synchronized 块内使用的变量从线程的工作内存中清除,从主内存中读取
  • 【退出】synchronized 块的内存语义事把在 synchronized 块内对共享变量的修改刷新到主内存中

所以,synchronized关键字锁住的对象,其值是具有可见性的。

有序性

单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。

as-if-serial语义保证了单线程中,指令重排是有一定的限制的,而只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的。当然,实际上还是有重排的,只不过我们无须关心这种重排的干扰。

由于synchronized修饰的代码,同一时间只能被同一线程访问。

那么也就是单线程执行的。所以,可以保证其有序性(原子性 + as-if-serial语义保证一定程度上的有序性)

⚠️ BUT!!!

经典的双重检查锁实现单例模式, 你有没有想过为什么已经使用synchronized包装了内部代码块, 既然 synchronized 可以保证可见性, 有序性, 原子性, 那么为什么还需要给单例对象添加volatile修饰呢?

感兴趣的同学可以去看这篇文章

阿里面试:Java的synchronized 能防止指令重排序吗?_synchronized语句块后的代码会重排到其前吗-CSDN博客

这里直接上结论

synchnronized的有序性保证, 只能是保证synchnronized整个代码块之间的有序性, 其代码块内部的有序性其实是无法保证的

如果在一个线程中观察另一个线程,另一个线程所有操作都是无序的。

volatile关键字有序性是通过插入内存屏障来保证指令按照顺序执行。

不会存在后面的指令跑到前面的指令之前来执行。是保证编译器优化的时候不会让指令乱序。

volatile

volatile一个强大的功能,那就是他可以禁止指令重排优化。通过禁止指令重排优化,就可以保证代码程序会严格按照代码的先后顺序执行。那么volatile又是如何禁止指令重排的呢?

**内存屏障(Memory Barrier)**是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。下表描述了和volatile有关的指令重排禁止行为:

volatile

从上表我们可以看出:

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

可见性

当一个变量被声明为 volatile 时:

  • 线程在【读取】共享变量时,会先清空本地内存变量值,再从主内存获取最新值
  • 线程在【写入】共享变量时,不会把值缓存在寄存器或其他地方(就是刚刚说的所谓的「工作内存」),而是会把值刷新回主内存

借此, 当多个线程访问同一个变量(共享变量)时,如果在这期间有某个线程修改了该共享变量的值,那么其他线程也能够立即看得到修改后的值。

有序性

造成有序性问题的其中一个原因是处理器的指令重排序

作为一个优化来讲,指令重排序是有其存在的必要性的,但是处理器并不知道什么时候应该禁止这种重排序优化来保证程序执行的正确性,于是将禁用的时机抛给了使用者。

针对两种不同的重排序,编译器的重排序和处理器的重排序,Java 内存模型 统一制定了重排序的管理规则,通过插入内存屏障来标识什么时候是禁止重排序优化的,而插入内存屏障时机,就是volatile修饰的变做读写操作的时候。

由于编译器和处理器都能执行指令重排优化,如果在指令之间插入一条内存屏障则会告诉编译器和cup不管在任何情况下,无论任何指令都不能和这条内存屏障进行指令重排,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。

⚙️Happens-Before原则

image-20240106123204824

我们重点理解前三个即可, 文末我会给出所有Happens-Before原则的定义

🔄 volatile规则

对于一个volatile变量的写操作(修改)总是happens-before于随后对该变量的读操作。

这意味着,当一个线程修改了一个volatile变量的值后,其他线程将立即感知到这个修改。这一特性是通过刷新存储和禁止重排序来实现的。

🤝 锁定规则

锁定规则是原则性的一种体现。它指出,对于一个锁的解锁操作总是happens-before于随后对该锁的加锁操作。

这意味着,在一个线程中,对于同一个锁的解锁操作将确保之后加锁的操作能够读取到最新的值。

📃 程序顺序原则

程序顺序原则是原则性的又一种体现。它指出,在一个线程内部,按照程序代码的书写顺序执行的操作满足happens-before关系。

尽管指令可能被硬件或编译器重排,但Java内存模型会确保程序执行结果的正确性。

这一原则是由于单个线程的执行是按照程序语义需要进行顺序执行的。

  1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说, 如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单 的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当 该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能 够看到该变量的最新值。
  4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的 start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量 的修改对线程B可见
  5. 传递性 A先于B ,B先于C 那么A必然先于C
  6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前 执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法 成功返回后,线程B对共享变量的修改将对线程A可见。
  7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中 断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  8. 对象终结规则 对象的构造函数执行,结束先于finalize()方法