JUC Java 内存模型

174 阅读11分钟

Java 内存模型规范了 Java 虚拟机与计算机内存是如何协同工作的。Java 虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为 Java 内存模型。

JMM 中最重要的三点:

  • 指令重排
  • 原子性
  • 内存可见性

1、什么是 Java 内存模型?

Java 内存结构 VS Java 内存模型

  • Java 内存结构和 Java 虚拟机的运行时区域有关;
  • Java 内存模型和 Java 的并发编程有关。

简单介绍一下 Java 内存结构

其实 Java 内存结构就是我们常说的 Java 运行时数据区域。

JVM 在执行 Java 程序的过程中将所管理的内存划分为几个不同的数据区域,这些区域都有各自的用途。由五个部分组成:

(1)程序计数器

(2)Java 虚拟机栈

(3)本地方法栈

(4)Java

(5)方法区

从 Java 代码到 CPU 指令

我们都知道,编写的 Java 代码,最终还是要转化为 CPU 指令才能执行的。

大致流程如下:

(1)Java 源代码文件。

(2)通过 javac 编译成字节码文件,生成后缀名为 .class 的文件。

(3)将字节码文件通过 JVM 解释成机器指令。

(3)将机器指令用 CPU 执行。

为什么需要 JMM

Java 非常需要一个标准,来让 Java 开发者、编译器工程师和 JVM 工程师能够达成一致。

达成一致后,我们就可以很清楚的知道什么样的代码最终可以达到什么样的运行效果,让多线程运行结果可以预期,这个标准就是 JMM,这就是需要 JMM 的原因。

如果不加以规范,那么同样的 Java 代码,完全可能产生不一样的执行效果,那是不可接受的,这也违背了 Java “书写一次、到处运行”的特点。

JMM 是什么

JMM 是规范

JMM 是和多线程相关的一组规范,需要各个 JVM 的实现来遵守 JMM 规范,以便于开发者可以利用这些规范,更方便地开发多线程程序。这样一来,即便同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。

如果没有 JMM 内存模型来规范,那么很可能在经过了不同 JVM 的“翻译”之后,导致在不同的虚拟机上运行的结果不一样,那是很大的问题。

JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。

JMM 是工具类和关键字的原理

volatile、synchronized、Lock 等,其实它们的原理都涉及 JMM。正是 JMM 的参与和帮忙,才让各个同步工具和关键字能够发挥作用,帮我们开发出并发安全的程序。

2、指令重排

什么是重排序

假设我们写了一个 Java 程序,包含一系列的语句,我们会默认期望这些语句的实际运行顺序和写的代码顺序一致。

但实际上,编译器、JVM 或者 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序

重排序的好处:提高处理速度

重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。

重排序的 3 种情况

编译器优化

编译器(包括 JVM、JIT 编译器等)出于优化的目的,在编译的过程中会进行一定程度的重排。

但是重排序并不意味着可以任意排序,它需要需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序早就逻辑混乱了。

CPU 重排序

CPU 同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。

所以即使之前编译器不发生重排,CPU 也可能进行重排,我们在开发中,一定要考虑到重排序带来的后果。

内存的“重排序

内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果。

由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。

3、原子性

什么是原子性和原子操作

具备原子性的操作被称为原子操作。原子操作是指一系列的操作,要么全部发生,要么全部不发生,不会出现执行一半就终止的情况。

比如转账行为就是一个原子操作,该过程包含扣除余额、银行系统生成转账记录、对方余额增加等一系列操作。虽然整个过程包含多个操作,但由于这一系列操作被合并成一个原子操作,所以它们要么全部执行成功,要么全部不执行,不会出现执行一半的情况。

i++ 这一个操作不是一个原子性操作,这一行代码在 CPU 中执行,可能会变成 3 个指令:

  • 读取;
  • 增加;
  • 保存。

Java 中的原子操作有哪些

  • 除了 longdouble 之外的基本类型(int、byte、boolean、short、char、float)的读/写操作,都天然的具备原子性;
  • 所有引用 reference 的读/写操作;
  • 加了 volatile 后,所有变量的读/写操作(包含 longdouble)。这也就意味着 longdouble 加了 volatile 关键字之后,对它们的读写操作同样具备原子性;
  • java.concurrent.Atomic 包中的一部分类的一部分方法是具备原子性的,比如 AtomicIntegerincrementAndGet 方法。

long 和 double 的原子性

longdouble 的值需要占用 64 位的内存空间,而对于 64 位值的写入,可以分为两个 32 位的操作来进行。

本来是一个整体的赋值操作,就可能被拆分为低 32 位和高 32 位的两个操作。如果在这两个操作之间发生了其他线程对这个值的读操作,就可能会读到一个错误、不完整的值。

如果使用 volatile 修饰了 longdouble,那么其读写操作就必须具备原子性了。

原子操作 + 原子操作 != 原子操作

简单地把原子操作组合在一起,并不能保证整体依然具备原子性。

4、内存可见性

可见性问题:假设某个值已经被某个线程修改了,但是其他线程却看不到,也就是此时读取的值还是旧的值,读取不到已经修改过的值。

解决:给变量加上 volatile 修饰,其他的代码不变。

synchronized 不仅保证了原子性,还保证了可见性

synchronized 不仅保证了临界区内最多同时只有一个线程执行操作,同时还保证了在前一个线程释放锁之后,之前所做的所有修改,都能被获得同一个锁的下一个线程所看到,也就是能读取到最新的值。因为如果其他线程看不到之前所做的修改,依然也会发生线程安全问题。

5、主内存和工作内存

CPU 有多级缓存,导致读的数据过期

由于 CPU 的处理速度很快,相比之下,内存的速度就显得很慢,所以为了提高 CPU 的整体运行效率,减少空闲时间,在 CPU 和内存之间会有 cache 层,也就是缓存层的存在。虽然缓存的容量比内存小,但是缓存的速度却比内存的速度要快得多,其中 L1 缓存的速度仅次于寄存器的速度。结构示意图如下所示:

image-20220214195329102

线程间对于共享变量的可见性问题,并不是直接由多核引起的,而是由多级缓存引起的:每个核心在获取数据时,都会将数据从内存一层层往上读取,同样,后续对于数据的修改也是先写入到自己的 L1 缓存中,然后等待时机再逐层往下同步,直到最终刷回内存。

什么是主内存和工作内存

Java 作为高级语言,屏蔽了 L1 缓存、L2 缓存、L3 缓存,也就是多层缓存的这些底层细节,用 JMM 定义了一套读写数据的规范。

image-20220214195642472

每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM 控制的。

主内存和工作内存的关系

JMM 有以下规定:

(1)存储规定。所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;

(2)读写规定。线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;

(3) 通信规定。主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。

6、happens-before 规则

什么是 happens-before

Happens-before 关系是用来描述和可见性相关问题的:如果第一个操作 happens-before 第二个操作(也可以描述为,第一个操作和第二个操作之间满足 happens-before 关系),那么我们就说第一个操作对于第二个操作一定是可见的,也就是第二个操作在执行时就一定能保证看见第一个操作执行的结果。

happens-before 关系的规则

(1)单线程规则

在一个单独的线程中,按照程序代码的顺序,先执行的操作 happen-before 后执行的操作。也就是说,如果操作 x 和操作 y 是同一个线程内的两个操作,并且在代码里 x 先于 y 出现,那么有 hb(x, y),正如下图所示:

image-20220214200352405

(2)锁操作规则

synchronized 和 Lock 接口等

操作 A 是解锁,而操作 B 是对同一个锁的加锁,那么 hb(A, B) 。

(3)volatile 变量规则

对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作。

(4)线程启动规则

Thread 对象的 start 方法 happen-before 此线程 run 方法中的每一个操作。

(5)线程 join 规则

假设线程 A 通过调用 threadB.start() 启动了一个新线程 B,然后调用 threadB.join() ,那么线程 A 将一直等待到线程 B 的 run 方法结束(不考虑中断等特殊情况),然后 join 方法才返回。在 join 方法返回后,线程 A 中的所有后续操作都可以看到线程 B 的 run 方法中执行的所有操作的结果,也就是线程 B 的 run 方法里面的操作 happens-before 线程 A 的 join 之后的语句。

(6)中断规则

对线程 interrupt 方法的调用 happens-before 检测该线程的中断事件。

(7)并发工具类的规则

  • 线程安全的并发容器(如 HashTable)在 get 某个值时一定能看到在此之前发生的 put 等存入操作的结果。也就是说,线程安全的并发容器的存入操作 happens-before 读取操作。
  • 信号量(Semaphore)它会释放许可证,也会获取许可证。这里的释放许可证的操作 happens-before 获取许可证的操作,也就是说,如果在获取许可证之前有释放许可证的操作,那么在获取时一定可以看到。
  • FutureFuture 有一个 get 方法,可以用来获取任务的结果。那么,当 Futureget 方法得到结果的时候,一定可以看到之前任务中所有操作的结果,也就是说 Future 任务中的所有操作 happens-before Futureget 操作。
  • 线程池:要想利用线程池,就需要往里面提交任务(Runnable 或者 Callable),这里面也有一个 happens-before 关系的规则,那就是提交任务的操作 happens-before 任务的执行。