什么是JMM?

对于不同的硬件和操作系统,有着自己的底层内存模型,可能导致Java程序在一些的平台可以正确并发,而在另一些平台出现并发错误,JMM是Java内存模型,是语言级别的内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM主要考虑的就是各种变量的访问规则,正确实现线程间的通信以及线程间的同步,对于JMM,把各种硬件抽象为本地内存和主存来存放变量。

总的来说JMM把硬件和操作系统的差异屏蔽了,让Java在不同机器上都可以正确的访问数据从而实现并发。

为了提升速度,给线程加了本地内存,JMM用来处理线程本地内存和主存的一致性问题

image-20240318203150822

内存间交互操作

关于变量如何从主存拷贝到本地线程以及变量如何从本地线程同步到主存的细节,JMM定义了8中操作来完成,对于每个线程来说,只能操作本地内存的变量,规定read、load要顺序执行,store、write要顺序执行。

image-20240318205713894

操作系统的三大问题

在谈论JMM之前,首先我们需要了解操作系统的几个问题,JMM正是解决了操作系统这些问题。

随着时代的发展,计算机逐步更新换代,人们对于计算机的要求越来越高,对于家庭或者公司的电脑需要人机交互,这就要求计算机需要有较短的响应时间,为此工程师引入了CPU的多级缓存、时间片轮转调度算法、指令优化重排,但是也相应的带来了问题:

  • CPU缓存一致性问题(可见性),解决方案:总线锁、MESI协议
  • 代码被多个线程访问的并发问题(原子性),解决方案:锁
  • 指令没有按照程序员的意愿执行(有序性)

JMM抽象的问题

  • 可见性线程本地变量和主存的可见性问题,规定线程只能操作本地内存变量,线程对内存的修改无法及时被其它线程看到
  • 原子性:如 i++ 语句,底层由多条指令组成,CPU实际执行的是指令,在指令执行期间可能会发生时钟中断切换线程,最终导致程序执行结果出问题
  • 有序性:如 Java 创建对象过程,类加载 -> 实例化 -> 初始化 -> 引用赋值,由于指令重排可能导致引用赋值在初始化之前,导致其它线程使用到未初始化的对象
    • 编译器优化:无依赖语句重排
    • 指令流水线优化:
    • 内存系统优化:可能导致程序执行错误

Java关键字如何解决问题

  • 可见性
    • 可通过volatile关键字解决,强制线程每次修改本地变量需要同步到主存,同时每次读取本地变量都需要从主存读取
    • 可通过synchrinized关键字解决,锁获取和volatile读有相同的语义,将本地变量置为无效状态,锁释放和volatile写有相同的语义,变量需要同步到主存
    • 可通过final关键字解决,构造函数返回后,任何线程都可以看到final字段正确初始化之后的值
  • 原子性:可通过synchrinized关键字解决,底层是通过monitorenter和monitorexit字节码指令
  • 有序性
    • 可通过volatile关键字解决,volatile规则
    • 可通过synchrinized关键字解决,程序次序规则(只有一个线程访问同步代码块,单线程重排优化语义是一样的)

Happens-Before原则

如果JMM中所有的有序性仅靠volatile和synchronized来完成,未免很多操作有点啰嗦,我们在日常编码的过程中并不会过多关注有序性,因为有个Happens-Before原则来辅助保证代码操作的顺序性

常见的Happens-Before原则:

  • 程序次序规则:同一个线程内,对于顺序执行,前面的操作先行于后面的操作
  • 管程锁定规则:对于一个锁,unlock先行于lock
  • volatile规则:对于一个用volatile修饰的变量,写操作先行于读操作
  • 线程启动规则:start()先行于这个线程的其它动作
  • 线程终止规则:线程所有动作先行于终止检查
  • 线程中断规则:interrupt()先行于检测到中断事件的发生
  • 对象终止规则:对象的初始化完成先行于销毁
  • 传递性:A操作先行于B操作,B操作先行于C操作,那么A操作先行于C操作

如下面这个程序,你不能保证 i = 1 一定会在 j = 2 前执行,但是这也不影响Happens-Before原则的正确性,因为这并不影响整个程序执行结果的正确性。

1
2
int i = 1;
int j = 2;