嘿,同学,你要的Java内存模型(JMM)来了!

 2023-02-08
原文作者:Simon郎 原文地址:https://juejin.cn/post/6926715555760046087

在学习Java内存模型(JMM)前,我们先了解下计算机的硬件内存结构,因为JMM结构就是基于此演变而来的。

1、 计算机的硬件内存结构

在单核计算机中,计算机中的CPU计算速度是非常快的,但是与计算机中的其它硬件(如IO、内存等)同CPU的速度比起来是相差甚远的,所以协调CPU和各个硬件之间的速度差异是非常重要的,要不然CPU就一直在等待,浪费资源。单核尚且如此,在多核中,这样的问题会更加的突出。硬件结构如下图所示:

202301011639383511.png

硬件内存

我们先大概梳理下这个流程:当我们的计算机要执行某个任务或者计算某个数字时,主内存会首先从数据库中加载计算机计算所需要的数据,因为内存和CPU的速度相差较大,所以有必要在内存和CPU间引入缓存(根据实际的需要,可以引入多层缓存),主内存中的数据会先存放在CPU缓存中,当这些数据需要同CPU做交互时会加入到CPU寄存器中,最后被CPU使用。

事实上,在单核情况下,基于缓存的交互可以很好的解决CPU与其它硬件之间的速度匹配,但是在多核情况下,各个处理器都要遵循一定的协议来保障内存中的各个处理器的缓存和主内存中的数据一致性问题,这类协议通常被称为缓存一致性协议。

202301011639389522.png

一致性协议

2、 Java内存模型的背景和定义

我们在开发时会经常遇到这样的场景,我们开发完成的代码在我们自己的运行环境上表现良好,但是当我们把它放在其它硬件平台上时,就会出现各种各样的错误,这是因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。

为了解决这个问题,Java内存模型(JMM)的概念就被提出来了,它的出现可以屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果,实现平台的一致性,使得Java程序能够 一次编写,到处运行

这样的描述的好像有点熟悉啊,这不是JVM的概念描述么,它们两者有什么区别啊?

JVM与JMM间的区别?

实际上,JMM是Java虚拟机(JVM)在计算机内存(RAM)中的工作方式,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本,本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。而JVM则是描述的是Java虚拟机内部及各个结构间的关系。

小伙伴这时可能会有疑问,既然JMM是定义线程和主内存之间的关系,那么它的出现是不是解决并发领域的问题啊?没错,我们先回顾一下并发领域中的关键问题。

并发领域中的关键问题?

  • 线程之间的通信

在编程中,线程之间的通信机制有两种,共享内存消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。

消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。

  • 线程间的同步

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

事实上,Java内存模型(JMM)的并发采用的是共享内存模型。

下面,我们一起来学习Java内存模型

3、 Java内存模型

我们先看一张JMM的控制模型作图

202301011639394613.png

Java内存模型

由此可见,Java内存模型(JMM)同CPU缓存模型结构类似,是基于CPU缓存模型来建立的。

我们先梳理一下JMM的工作流程,以上图为例,我们假设有一台四核的计算机,cpu1操作线程A,cpu2操作线程B,cpu3操作线程C,当这三个线程都需要对主内存中的共享变量进行操作时,这三条线程分别会将主内存中的共享内存读入自己的工作内存,自己保存一份共享变量的副本供自己线程本身使用。

这时有的小伙伴可能会有以下疑问:

  • 主内存、工作内存的定义是什么?
  • 如何将主内存中的共享变量读入自己线程本身的工作内存?
  • 当其中的某一条线程修改了共享变量后,其余线程中的共享变量值是否变化,如果变化,线程间是怎么保持可见性的?

下面,我们针对这两个疑问一一解答。

3.1 主内存、工作内存的定义

  • 主内存

主内存主要存储的是Java实例对象,即所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

  • 工作内存

工作内存主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),即每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

**NOTE:**这里的主内存、工作内存与Java内存区域中的Java堆、栈、方法区不是同一层次的内存划分,这两者基本上没有关系。

搞清楚主内存和工作内存后,下一步就需要学习主内存与工作内存的数据交互操作的方式。

3.2 内存的交互操作

主内存与工作内存的交互操作有8种,虚拟机必须保证每一个操作都是原子的,这八种操作分别是:

  • Lock(锁定)

作用于主内存的变量,把一个变量标识为一条线程独占状态。

  • unlock(解锁)

作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

  • read(读取)

作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用

  • load(载入)

作用于工作内存的变量,它把read操作从主存中变量放入工作内存中

  • use(使用)

作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令

  • assign(赋值)

作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中

  • store(存储)

作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用

  • write(写入)

作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

单看这八种类型的原子操作可能有点抽象,我们画一个操作流程图仔细梳理下。

操作流程图:

202301011639401114.png

操作流程图

从图中可以看出,如果要把一个变量从内存中复制到工作内存中,就需要顺序的执行read和load操作,如果把变量从工作内存同步到主内存中,就需要执行store和write操作。

NOTE: Java内存模型只要求上述操作必须按顺序执行,却没要求是连续执行。

我们以两个线程为例梳理下操作流程:

假设存在两个线程A和B,如果线程A要与线程B要通信的话,首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去;然后,线程B到主内存中读取线程A之前已经更新过的共享变量。

敏锐的小伙伴可能会发现,如果多个线程同时读取修改同一个共享变量,这种情况可能会导致每个线程中的本地内存中缓存变量一致的问题,这个时候该怎么解决呢?

3.3 JMM缓存不一致问题

解决JMM中的本地内存变量的缓存不一致问题有两种解决方案,分别是总线加锁MESI缓存一致性协议

总线加锁

总线加锁是CPU从主内存读取数据到本地内存时,会先在总线对这个数据加锁,这样其它CPU就没法去读或者去写这个数据,直到这个CPU使用完数据释放锁后,,其它的CPU才能读取该数据。

202301011639412985.png

总线加锁

总线加锁虽然能保证数据一致,但是它却严重降低了系统性能,因为当一个线程多总线加锁后,其它线程都只能等待,将原有的并行操作转成了串行操作。

通常情况下,我们不采用这种方法,而是使用性能较高的缓存一致性协议。

MESI缓存一致性协议

MESI缓存一致性协议是多个CPU从主内存读取同一个数据到各自的高速缓存中,当其中的某个CPU修改了缓存里的数据,该数据会马上同步回主内存,其它CPU通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。

在并发编程中,如果多个线程对同一个共享变量进行操作是,我们通常会在变量名称前加上关键在volatile,因为它可以保证线程对变量的修改的可见性,保证可见性的基础是多个线程都会监听总线。即当一个线程修改了共享变量后,该变量会立马同步到主内存,其余线程监听到数据变化后会使得自己缓存的原数据失效,并触发read操作读取新修改的变量的值。进而保证了多个线程的数据一致性。事实上,volatile的工作原理就是依赖于MESI缓存一致性协议实现的。

4、 Java内存模型的实现

在Java多线程中,Java提供了一系列与并发处理相关的关键字,比如volatilesynchronizedfinalconcurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字

事实上,Java内存模型的本质是围绕着Java并发过程中的如何处理原子性可见性顺序性这三个特征来设计的,这三大特性可以直接使用Java中提供的关键字实现,它们也是面试中经常被问到的题目。

  • 原子性

原子性的定义是一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。

JMM保证的原子性变量操作包括read、load、assign、use、store、write

NOTE :基本类型数据的访问大都是原子操作,long 和double类型的变量是64位,但是在32位JVM中,32位的JVM会将64位数据的读写操作分为2次32位的读写操作来进行,这就导致了long、double类型的变量在32位虚拟机中是非原子操作,数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。

对于非原子操作的基本类型,可以使用synchronized来保证方法和代码块内的操作是原子性的。

    1         synchronized (this) {
    2             a=1;
    3             b=2;
    4         }

如一个线程观察另外一个线程执行上面的代码,只能看到a、b都被赋值成功结果,或者a、b都尚未被赋值的结果。

  • 可见性

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。

  • 有序性

在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized关键字是万 能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。

但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

参考文献

[1]https://www.jianshu.com/p/8a58d8335270 [2]https://blog.csdn.net/javazejian/article/details/72772461 [3]https://blog.csdn.net/zjcjava/article/details/78406330 [4]https://segmentfault.com/a/1190000016085105