嘿,同学,你要的 Java 内存模型 (JMM) 来了
转自:https://xie.infoq.cn/article/739920a92d0d27e2053174ef2
1、 计算机的硬件内存结构
2、 Java 内存模型的背景和定义
3、 Java 内存模型 3.1 主内存、工作内存的定义 3.2 内存的交互操作 3.3 JMM 缓存不一致问题
4、 Java 内存模型的实现
在学习 Java 内存模型(JMM)前,我们先了解下计算机的硬件内存结构,因为 JMM 结构就是基于此演变而来的。
1、 计算机的硬件内存结构
在单核计算机中,计算机中的 CPU 计算速度是非常快的,但是与计算机中的其它硬件(如 IO、内存等)同 CPU 的速度比起来是相差甚远的,所以协调 CPU 和各个硬件之间的速度差异是非常重要的,要不然 CPU 就一直在等待,浪费资源。单核尚且如此,在多核中,这样的问题会更加的突出。硬件结构如下图所示:
我们先大概梳理下这个流程:当我们的计算机要执行某个任务或者计算某个数字时,主内存会首先从数据库中加载计算机计算所需要的数据,因为内存和 CPU 的速度相差较大,所以有必要在内存和 CPU 间引入缓存(根据实际的需要,可以引入多层缓存),主内存中的数据会先存放在 CPU 缓存中,当这些数据需要同 CPU 做交互时会加入到 CPU 寄存器中,最后被 CPU 使用。
事实上,在单核情况下,基于缓存的交互可以很好的解决 CPU 与其它硬件之间的速度匹配,但是在多核情况下,各个处理器都要遵循一定的协议来保障内存中的各个处理器的缓存和主内存中的数据一致性问题,这类协议通常被称为缓存一致性协议。
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 的控制模型作图
由此可见,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 操作从工作内存中得到的变量的值放入主内存的变量中
单看这八种类型的原子操作可能有点抽象,我们画一个操作流程图仔细梳理下。
操作流程图:
从图中可以看出,如果要把一个变量从内存中复制到工作内存中,就需要顺序的执行 read 和 load 操作,如果把变量从工作内存同步到主内存中,就需要执行 store 和 write 操作。
NOTE: Java 内存模型只要求上述操作必须按顺序执行,却没要求是连续执行。
我们以两个线程为例梳理下操作流程:
假设存在两个线程 A 和 B,如果线程 A 要与线程 B 要通信的话,首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去;然后,线程 B 到主内存中读取线程 A 之前已经更新过的共享变量。
敏锐的小伙伴可能会发现,如果多个线程同时读取修改同一个共享变量,这种情况可能会导致每个线程中的本地内存中缓存变量一致的问题,这个时候该怎么解决呢?
3.3 JMM 缓存不一致问题
解决 JMM 中的本地内存变量的缓存不一致问题有两种解决方案,分别是总线加锁
和MESI缓存一致性协议
。
总线加锁
总线加锁是 CPU 从主内存读取数据到本地内存时,会先在总线对这个数据加锁,这样其它 CPU 就没法去读或者去写这个数据,直到这个 CPU 使用完数据释放锁后,,其它的 CPU 才能读取该数据。
总线加锁虽然能保证数据一致,但是它却严重降低了系统性能,因为当一个线程多总线加锁后,其它线程都只能等待,将原有的并行操作转成了串行操作。
通常情况下,我们不采用这种方法,而是使用性能较高的缓存一致性协议。
MESI 缓存一致性协议
MESI 缓存一致性协议是多个 CPU 从主内存读取同一个数据到各自的高速缓存中,当其中的某个 CPU 修改了缓存里的数据,该数据会马上同步回主内存,其它 CPU 通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。
在并发编程中,如果多个线程对同一个共享变量进行操作是,我们通常会在变量名称前加上关键在volatile
,因为它可以保证线程对变量的修改的可见性,保证可见性的基础是多个线程都会监听总线。即当一个线程修改了共享变量后,该变量会立马同步到主内存,其余线程监听到数据变化后会使得自己缓存的原数据失效,并触发read
操作读取新修改的变量的值。进而保证了多个线程的数据一致性。事实上,volatile
的工作原理就是依赖于 MESI 缓存一致性协议实现的。
4、 Java 内存模型的实现
在 Java 多线程中,Java 提供了一系列与并发处理相关的关键字,比如volatile
、synchronized
、final
、concurren
包等。其实这些就是 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 来保证方法和代码块内的操作是原子性的。
复制代码
如一个线程观察另外一个线程执行上面的代码,只能看到 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
评论