一文读懂 Java Memory Model(JMM)

305 阅读7分钟

Java Memory Model

版本说明

  • JDK 1.8

背景知识

多级缓存

  • 我们知道处理器的处理速度很快,内存处理速度远远赶不上处理器的处理速度,为了解决CPU处理速度和内存处理速度不对等的问题,我们引入了CPU Cache,现代的CPU Cache一般分为三层L1、L2、L3 Cache,当我们处理器需要处理某份数据时,这份数据首先会把数据从内存读入到缓存中,然后交由处理器处理,处理器处理完成之后再讲数据写回缓存,最后写回内存。
  • CPU Cache确实解决了上述的问题,但同时也带了另外一个问题,同一份数据在不同的内存缓存中不一致,为了解决数据不一致的问题制定了缓存一致性协议,比如常见的MESI协议。

DM_20230822200654_001.png

速度匹配

  • 在工程学中,解决速度不匹配的方式主要由两种:速度适配、空间缓存。CPU多级缓存就是常见的空间缓存,速度适配最常见的案例就是多级机械齿轮。

局部性原理

  • 时间局部性:如果一个信息正在被访问,那么很大概率这个信息会被再次访问,如LRU、LFU算法。
  • 空间局部性:如果一个信息正在被访问,那么这个信息附近的数据很大概率会被访问,如缓存行。

指令重排序

  • 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。什么是指令重排序?简单来说就是系统在执行代码的时候并不一定是按照程序的代码的顺序依次执行。
  • 指令重排序可以保证单线程串行语义一致(as-if-serial),但是没有义务保证多线程间的语义也一致,所以在多线程下,指令重排序可能会导致一些问题。

常见的指令重排

  • 编译器优化重排:编译器(包括JVM、JIT编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 多级缓存导致的指令重排,多线程中当线程A修改元素a但未写回主存,线程B读取元素a时,对于线程B来说上一句指令似乎没有执行,类似发生指令重排。

处理指令重排序的方式

  • 编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。
  • 内存屏障(Memory Barrier,或叫做内存栅栏,Memory Fence)是一种CPU指令,用来禁止处理器指令发生重排序,从而保障指令执行的有序性。另外,为了达到屏障的效果,它也让处理器执行写指令时,将数据写回主内存,执行读指令时,清空无效队列,从主内存读取最新的数据,从而保障变量的可见性。
int a = 1;  (1)
int b = 2;   (2)
int c = a + b;

// 上述代码块中(1)(2)的重排序并不会影响程序的运行结果,因此可以进行重排序

什么是 Java内存模型(Java Memory Model)?

  • 一般来说,编程语言可以复用操作系统的内存模型,但是Java是跨平台系统,不同的操作系统有不同的内存模型,为了满足跨平台的适用性,Java提出了自己的内存模型来屏蔽不同系统间的差异。早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java5 开始,Java 开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》
  • 但跨平台兼容性只是其中一个因素,另外一个比较重要的因素是JMM提供了线程工作内存和主内存的关系,以及Java 源代码到 CPU 可执行指令的这个转化过程需要遵守的原则和规范,让开发者不必要过多的了解底层细节就可以安全的开发自己的程序,简化了多线程编程。

工作内存和主内存

  • 从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存 中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不 真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下:

DM_20230822200659_001.png

happens-before 原则

  • JMM 向开发者提供 happens-before 原则来满足开发者的需求,一方面 happens-before 原则不但简单易懂,而且向开发者提供了足够强的内存可见性保证,帮助开发者更加方便的编写安全的程序。

程序次序规则(Program Order Rule)

  • 在一个线程内,按照代码编写顺序执行。

锁定规则(Monitor Lock Rule)

  • 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

volatile 变量规则(Volatile Variable Rule)

  • 对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  • 对于同一个 volatile 变量,如果对于这个变量的写操作先行发生于这个变量的读操作,那么对于这个变量的写操作所产的影响对于这个变量的读操作是可见的。

线程启动规则(Thread Start Rule)

  • Thread 对象 start()方法先行发生于此线程的每一个动作。

线程终止规则(Thread Termination Rule)

  • 线程中的所有操作都先行发生于对此线程的终止检测,也就是说线程中的所有操作所产生的影响对于调用线程Thread.join()方法或者Thread.isAlive()方法都是可见的。

线程中断规则(Thread Interruption Rule)

  • 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,也就是说线程interrupt()方法调用所产生的影响对于该线程检测到中断事件是可见的。

对象终结规则(Finalizer Rule)

  • 一个对象的初始化完成(构造函数结束)先行发生于它的finalize()方法的开始。

传递性(Transitivity)

  • 如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

happens-before 原则 和 JMM 的关系

  • happens-before 原则是 JMM 向开发者提供的编写安全程序的指导手册,JMM 会根据 happens-before 原则提供的语义来插入内存屏障来禁止编译器、处理器、以及内存可见性导致的重排序。

DM_20230822200703_001.png

JMM 内存屏障

DM_20230822200928_001.png

总结

as-if-serial & happens-before

  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before原则给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  • 实际上,无论是单线程的as-if-serial语义还是多线程的happens-before原则,两个满足上述关系的操作不一定是按照上述语义的指定顺序来执行,如果重排序的结果仍然满足语义,那么这种重排序是合法的,只是对开发者来说,好像实际运行的结果是按照顺序执行的,这是JMM向开发者做出的保证。