JAVA内存模型JMM
概述
- 概念:
Java Memory Model (JMM)
JAVA内存模型是一种抽象的概念,描述的是一组规范,规范中定义了程序中各个变量(实例字段、静态字段、数组对象的组成元素)的访问方式,决定了一个线程对共享变量的写入何时对另一个线程可见; - 工作流程:
JVM
运行程序的实体是线程,在线程创建时,JVM
都会为其分配工作内存,用于存储线程私有的数据;JMM
规定所有变量都存储在主内存中,所以当线程想操作变量时,需要先将变量从主内存中拷贝进自己的工作内存中,然后再对变量进行操作,操作完成后再将变更后的值刷写回主内存中;- 结合
JVM
,也就是当线程操作一个对象时,会根据工作内存中引用地址去找到主内存中的真实对象,然后会讲对象拷贝到自己的工作内存中,当操作的对象较大时,会进行选择性拷贝,只拷贝自己需要操作的那部分数据;
主内存
- 所属区域:属于线程共享区域,对
JVM
来说,主内存包括了堆和方法区; - 存储内容:主要存储的是类的成员变量、方法中的局部变量、共享类的信息、常量、静态变量、线程创建的实例对象等共享数据都会放到主内存中,栈上分配的对象除外;
- 当多条线程对同一数据进行非原子性操作时,就可能出现线程安全问题;
工作内存
- 所属区域:属于线程私有区域,对
JVM
来说,工作内存包括了程序计数器、虚拟机栈和本地方法栈; - 存储内容:主要存储当前方法的所有本地变量信息;
- 工作内存是每个线程的私有数据,线程之间无法相互访问,所以不存在线程安全问题;
主内存和工作内存的关系
-
数据存储类型:
- 工作内存:对一个实例对象的成员方法来说,如果方法中含有的局部变量为
boolean、byte、short、char、int、long、float、double
八大基本数据类型,则这些数据将直接存储在工作内存的栈帧结构的局部变量表中,引用类型的局部变量则是存储对象的引用地址; - 主内存:存储具体的实例对象、实例对象的所有成员字段、类的相关信息、
static
静态变量;
- 工作内存:对一个实例对象的成员方法来说,如果方法中含有的局部变量为
-
数据操作方式:
-
主内存:
public class Test { Integer num = new Integer(100); private void add(){ num++; } }
-
工作内存:
public class Test { private void add(){ Integer num = new Integer(100); num++; } }
-
计算机内存架构
JAVA程序是运行在操作系统上的,它的所有操作最终都是在与操作系统交互,想要理解JAVA内存模型,需要对计算机内存架构有一定的了解;
架构图
-
多核
CPU
: 现在的CPU
一般都为多核CPU
拥有多个核心,可以支持多任务并发执行,每个线程也最终也是映射到各个CPU
核心上去执行的;-
超线程技术:增强核心并行运算性能,它允许一个CPU执行多个控制流,工作原理是将一颗物理CPU虚拟化为两颗逻辑CPU,我们常说的4核8线程就是通过这个技术实现的;
-
-
多级缓存:
- 问题:由于内存的处理速度远低于
CPU
,导致CPU
在处理指令时大量时间花费在等待内存准备数据上,从而影响CPU
性能; - 解决办法:是在寄存器和主内存之间添加
L1、L2、L3
多级高速缓冲区,来缓存CPU
频繁访问的数据,之后寄存器再需要获取数据就可以直接从高速缓冲区中获取,不需要去访问内存;
- 问题:由于内存的处理速度远低于
CPU缓存一致性
由于CPU
为了提升性能使用了多核与多级缓存等技术,那么各各核心、各级缓存之间就可能出现数据不一致的情况,CPU主要通过以下集中方式来保证缓存的一致性;
数据的写入
-
写直达:在数据写入前判断数据是否已经在
Cache
中,如果数据存在,则将Cache
中的数据更新,然后将数据写入内存中;这种方式无论数据在不在
Cache
里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,影响性能; -
写回:当发生写操作时,只有在
Cache
不命中且数据对应的Cache
中的Cache Block
为脏标记情况下,才会将数据写到内存中;这种方式可以减少对主内存的写操作次数,提高性能。但是,可能会导致缓存中的数据与主内存不一致,需要额外的机制(如缓存一致性协议)来维护数据一致性。
缓存一致性问题
-
问题:现在的多核
CPU
,由于L1/L2 Cache
是多个核心各自独有的,而且CPU
为了考虑性能,数据写入采用的是写回策略,这就可能导致多个核心缓存中数据不一致的情况,从而造成结果错误;- 例子: 如上图,假设,A读取了内存中的
i
变量,并执行了i++
语句,由于使用了写回策略;- A就会先把执行结果
i = 1
写入到L1/L2 Cache
中,然后把L1/L2 Cache
中对应的Block
标记为脏的,此时数据并没有被同步到内存中的,因为写回策略,只有在 A 中的这个Cache Block
要被替换的时候,数据才会写入到内存里,这就出现了内存中的数据和A中Cache
数据不一致的情况; - 如果这时旁边的 B 从内存读取 i 变量的值,则读到的将会是错误的值;
- 这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误;
- A就会先把执行结果
- 例子: 如上图,假设,A读取了内存中的
-
解决思路:解决这个问题就需要对两个不同核心中的缓存数据进行同步,一般需要满足两点:
-
写传播:某个
CPU
核心里的Cache
数据更新是,需要传播到其他核心的Cache
中; -
事物串行化:在某个
CPU
里对数据的操作顺序,必须在其他核心看起来顺序是一样的;- 没有事物串行化存在的问题:A把 i 的值改为100,此时在同一时间 B 把 i 值改为200,这两个修改都会传播到C、D,此时就可能出现C、D收到A、B两个数据更改的顺序不同,也就会导致各个
Cache
中的数据不一致;所以,我们要保证 C 、 D能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串行化;
-
实现技术:
- CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
- 需要引入锁的概念呢,相当于只有获取到锁才能进行对应的数据更新;
- 没有事物串行化存在的问题:A把 i 的值改为100,此时在同一时间 B 把 i 值改为200,这两个修改都会传播到C、D,此时就可能出现C、D收到A、B两个数据更改的顺序不同,也就会导致各个
-
总线嗅探
- 作用:可以实现写传播;
- 实现:当A修改了
Cache
中的i变量时,会通过总线将这个事件广播通知给其他核心,CPU
的所有核心,都会监听总线上的广播事件,检查Cache
是否有相同变量,如果有则会更新自己的Cache
; - 缺点:不能保证事物串行化,需要频繁发送广播事件,加重了总线带宽压力;
MESI协议
-
概念:将数据用四种状态来标记
M(Modified|已修改) E(Exclusive|独占) S(Shared|共享) I(Invalidated|已失效)
;M(Modified)
已修改:表示Cache
中的数据已经被更新过,但还没写入到内存中;E(Exclusive)
独占:表示数据值存储在一个CPU
核心中,不需要考虑缓存一致性问题;S(Shared)
共享:表示数据存储在多个CPU
核心中,当我们需要更新数据时,需要向其他核心发送广播请求,要求其他核心将Cache
中对应的数据标记为已失效状态,然后再更新;I(Invalidated)
已失效:表示Cache
中的数据已经失效,不可读取该状态数据;
-
相互之间的转化:
-
当A从内存中读取变量 i 的值,会通过总线发消息通知其他CPU核心,如果其他CPU核心中没有缓存该数据,则A的
Cache
中 i 变量的标记为独占状态; -
之后,若其他核心中任意一个也要读取 i 变量,假设是B,则会通过总线发送广播消息,给其他核心,由于A中已经读取了i 变量,所以会把数据返回给B,并将核心中的 i 变量标记为共享状态;
-
如果A这时要修改变量 i 的值,并且
Cache
中变量 i 的标记为共享状态,则会通过总线发送广播消息,将各个核心中的 i 变量标记为失效状态,然后将A的Cache
中 i 变量标记为修改状态; -
若此时A继续修改 i 变量的值,则不需要通知其他核心,直接修改即可;
-
当B需要读取i数据时,发现
Cache
中变量 i 的标记为失效,会发出读取数据的请求,A在收到B读取数据的请求后会将变量 i 同步到内存中,并将状态设为共享,这是B就可以读取到最新数据;
-
-
带来的优点:当数据标记为修改或者独占时,修改更新数据不需要发送广播消息,在一定程度上减小了总线带宽压力;
操作系统与JMM之间的关系
JMM
只是一组抽象的概念,是一组规则,它的内存划分:工作内存(线程私有)、主内存(线程共享)对于计算机硬件来说并不存在;JMM
的数据操作对应到底层也是操作主内存、操作高速缓存来操作数据的,而多核CPU
有是通过MESI
协议来达到数据一致性的,所以,JAVA内存模型底层其实也还是通过MESI
一致性来保证的数据一次性;
JMM原理
指令重排序
为了提高性能,编译器和处理器通常会对指令进行重排序,有如下三种:
编译器指令优化的重排
-
编译器在不改变程序语义的前提下,重新安排语句的执行顺序;
- 不改变语义即代码之间不存在依赖,依赖可分为两种:数据依赖
int a = 1; int b = a;
和条件依赖boolean f = ture; if(f){}
;
- 不改变语义即代码之间不存在依赖,依赖可分为两种:数据依赖
-
例子:如下代码我们的预期应该是得到
x=0、y=0
这个结果,但实际上可能出现x=2、y=1
这种结果;// 主存的共享变量 int a = 0; int b = 0; //代码的顺序 //线程A 线程B 代码1:int x = a; 代码3:int y = b; 代码2:b = 1; 代码4:a = 2; //经过重排序后最终执行可能出现的顺序 //线程A 线程B 代码2:b = 1; 代码4:a = 2; 代码1:int x = a; 代码3:int y = b;
指令并行的重排
- 现代处理器一般都的是采用指令级并行技术,会将多条不相互依赖(即后一个执行的语句,无需依赖前面语句的执行结果)的指令重叠执行,如CPU的流水线技术;
CPU流水线
-
指令执行一般分为如下步骤:
-
IF
取指:CPU 会根据程序计数器里的存储地址,从内存或缓存中取出待执行的指令,将其加载到指令寄存器中; -
ID
译码和取寄存器操作数:指令译码单元将指令解析成操作码和操作数,并确定执行指令所需的资源; -
EX
执行或者有效地址计算:处理器根据指令的操作码执行相应的操作,可能涉及算术逻辑运算、内存访问或控制流操作等; -
MEM
存储器访问:如果指令涉及内存操作,处理器会在这个阶段进行内存读取或写入操作; -
WB
写回:将执行阶段得到的结果写回到寄存器文件或内存中;
-
-
为了提高硬件的利用率,
CPU
执行指令会采用流水线技术来工作;图中可以看出,当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样能提升CPU性能; -
存在的问题:当流水线出现中断,所有的硬件设备都会进入一轮停顿期;
- 比如说需要执行
i = a + b; j = c + d;
不存在指令重排序时他们的执行顺序:1.读取a、2.读取b、3.执行a+b、4.保存结果到i、5.读取c、6.读取d、7.执行c+d、8.保存结果到j; - 我们可以知道3、4两个步骤依赖于1、2两步,当1、2两步卡顿时,会导致3进行等待,则4也需要进行等待,最终拖慢整个流水线的执行,5、6、7、8同理;
- 解决的办法:我们可以知道第3步与5、6不存在依赖关系,所以CPU可以通过指令重排序的方式将5、6步的执行提前,执行顺序变为1、2、5、3、6、4、7、8,这时下相当于给具有依赖关系的语句中间插入无关语句,从而尽量保证前面的指令执行完成,减少停顿的可能;
- 比如说需要执行
-
作用:根据上述,我们可以知道,指令重排序的作用时减少CPU在流水线执行时的停顿;
-
带来的问题:对于单线程而言,由于指令重排是在保证串行语义执行的一致性的情况下进行的,但对于多线程环境就可能导致程序乱序执行的问题;
内存系统的重排
- 处理器缓存的存在,可能导致内存与缓存数据的同步存在时间差,导致加载
load
和存储store
操作看上去可能是在乱序操作;
三大特性
JMM
主要是围绕程序执行的原子性、有序性、可见性来开展的,通过这三大特性来保证数据的并发安全;
原子性
- 概念:指操作的原子性,指一组操作要么全部成功要么全部失败;
- 对于
32
位系统而言,byte、short、int、float、boolean、char
等基本数据类型的读写操作是原子操作,而lone、double
存储大小为64bit
,一次读写操作需要分两次读取数据,就可能出现数据被两个线程分两次读取的情况;
可见性
- 概念:当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值;
- 由上述指令重排序可知,编译器还是处理器的重排现象,在多线程环境下会导致乱序执行的情况出现,也可能导致可见性问题;
有序性
- 概念:指代码按照我们编写的顺序从上往下依次执行;
- 在多线程环境下,对于某个线程来说,他的所有操作都视为有序的,但如果从一个线程中观察另外一个线程,所有操作都是无序的,原因是指令重排序和主内存与工作内存之间的同步存在延迟;
JMM如何保证三大特性
- 原子性:除了基本数据类型的操作本身就保证原子性外,对于方法或代码块级别的原子性操作可以使用
synchronize
关键字或Lock
锁接口实现类来保证程序执行的原子性; - 可见性:工作内存和主内存同步延迟现象导致的可见性问题,可以通过加锁或者
volatile
关键字解决; - 有序性:通过加锁或者
volatile
关键字解决,volatile
可以通过禁止指令重排序来保证有序性; JMM
内部还定义一套happens-before
原则来保证多线程环境下两个操作间的原子性、可见性以及有序性
JMM中的happens-before原则
线程与内存的交互
- 交互类型:在
JAVA
程序在执行过程中,实际就是OS
在调度JVM
的线程执行,执行过程就是与内存的交互操作,而内存的交互操作有8
种;lock
锁定:作用于主内存的变量,将一个变量标识为线程独占状态;unlock
解锁:作用于主内存的变量,将一个锁定状态的变量释放出来;read
读取:作用于主内存的变量,将一个变量的值从主内存传输到线程的工作内存中;load
载入:作用于工作内存的变量,将read操作从主内存中传输的值放入工作内存中;use
使用:作用于工作内存的变量,将工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到的变量值,就会使用到这个命令;assign
赋值:作用于工作内存的变量,把一个从执行引擎中接受到的值放入工作内存的变量副本中;store
存储:作用于主内存的变量,把一个工作内存的一个变量值传送到主内存中;write
写入:作用于主内存的变量,把store操作传送的值放入主内存的变量中;
JMM指定的
交互规则:- 不允许read和load、store和write操作之一单独出现;即:使用了read必须load,使用了store必须write;
- 不允许线程丢弃他最近的
assign
操作,即工作变量的数据改变后,必须告知主内存; - 不允许线程将没有
assign
操作的数据同步回主内存; - 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施
use、store
操作之前,必须经过assign
和load
操作; - 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁;
- 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
- 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量;
- 对一个变量进行unlock操作之前,必须把此变量同步回主内存;
JMM中的happens-before原则
- 程序顺序原则:即在一个线程内必须保证语义串行性,也就是按照代码顺序执行;
- 锁规则:解锁(
unlock
)操作必然发生在后续的同一个锁的加锁(lock
)之前; volatile
规则:volatile
变量的写,先发生于读,这保证了volatile变量的可见性;简单的理解就是:volatile
变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值;- 线程启动规则:线程的
start()
方法先于它的每一个动作,即如果线程A,在执行线程B的start
方法前修改了共享变量的值,那么当线程B执行start
方法时,线程A变更过的共享变量,对线程B可见; - 传递性优先级规则:A先于B,B先于C,那么A必然先于C;
- 线程终止规则:线程的所有操作先于线程的终结,
Thread.join()
方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join
方法成功返回后,线程B对共享变量的修改将对线程A可见; - 线程中断规则:对线程
interrupt()
方法的调用,先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()
方法检测线程是否中断; - 对象终结规则:对象的构造函数执行,结束先于
finalize()
方法;
Volatile关键字
volatile
是JAVA
提供的轻量级的同步工具,它可以保证可见性和做到禁止指令重排序做到有序性,但不能保证原子性;
内存屏障(Memory Barrier)
- 内存屏障是一个
CPU
指令,作用是保证特定操作的执行顺序和保证某些变量的内存可见性;- 保证执行顺序:具体的就是在指令直接插入
MemoryBarrier
内存屏障,相当于告诉编译器和CPU
不管什么指令都不能与这条MemoryBarrier
进行指令重排序,也就是通过插入内存屏障,禁止在内存屏障前后的指令执行重排序; - 保证可见性:强制刷出各种
CPU
缓存数据,使CPU
上的任何线程都能读到这些数据的最新版本;
- 保证执行顺序:具体的就是在指令直接插入
- 内存屏障的类型:
LoadLoad Barriers
:确保Load1
指令数据的装载,发生于Load2
及后续所有装载指令的数据装载之前;- 指令示例:
Load1; LoadLoad; Load2;
- 指令示例:
StoreStore Barriers
:确保Store1
数据的存储对其他处理器可见(刷新到内存中)并发生于Store2
及后续所有存储指令的数据写入之前。- 指令示例:
Store1; StoreStore; Store2;
- 指令示例:
LoadStore Barriers
:确保Load1
指令数据的装载,发生于Store2
及后续所有存储指令的数据写入之前。- 指令示例:
Load1; LoadStore; Store2;
- 指令示例:
StoreLoad Barriers
:确保Store1
数据的存储对其他处理器可见(刷新到内存中),并发生于Load2
及后续所有装载指令的数据装载之前;StoreLoad Barriers
会使该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行该屏障之后的内存访问指令;- 指令示例:
Store1; StoreLoad; Load2;
- 指令示例:
内存可见
volatile
可以保证,一个线程对volatile
所修饰的变量进行更改操作后,总能对其他线程可见;- 当一个变量被
volatile
修饰,发生写操作时JMM
会把该线程工作内存中的共享变量值刷新到主内存中;当读取操作时,JMM
会把该线程对应的工作内存置为无效,要求该线程从主内存中重新读取该变量的值,也是通过内存屏障实现的;
禁止指令重排序
-
volatile
通过在修饰变量访问前后添加内存屏障,来静止指令重排序,从而保证有序性; -
例子:说明
volatile
禁止指令重排序,synchronized
不禁止;-
以下是一个双重锁检测的代码:
public class Singleton{ private static Singleton singleton; private Singleton(){} public static Singleton getInstance(){ if(singleton == null){ synchronized(Singleton.class){ if(singleton == null){ singleton = new Singleton(); } } } return singleton; } }
-
当
singleton
没有被volatile
修饰的时候是可能获取到null
值,出现线程不安全的情况,,原因如下: -
但当如果
singleton
变量加上volatile
后,会禁止new
这个操作被其他线程打断,从而保证线程安全;
-
具体实现
- 具体表现:假设有五个相互之间不存在依赖性的指令操作
A、B、C、D、E
,现在对B、C、D
加上内存屏障就变成了A、内存屏障( B、C、D)内存屏障、E
;此时依旧可以发生重排序,但会将(B、C、D)
看作一个整体,再去与A、E
排序,其内部也可以重排序; - 实现方式:
volatile
并没有直接使用操作系统的内存屏障指令,而是使用的JVM
内存屏障字节码指令,JVM
的内存屏障字节码指令会间接使用操作系统的内存屏障指令,也就是JVM
对操作系统的内存屏障指令做了层封装,具体的定义位于HotSport
源码的bytecodeInterpreter.cpp
文件中; - 实现原理:通过内存屏障的
StoreLoad
读+写屏障实现;- 当多个线程读取共享变量时,触发读屏障,读屏障中会记录哪些线程读取了这个变量
- 当有一条线程写会数据时,就会触发写屏障,此时写屏障会根据前面读屏障记录下来的线程,去通知所有还未刷回的线程,重新再来获取一次最新的值;(保证了可见性)
- 硬件层面:
- 将
volatile
修改的高速缓存数据,写回到机器内存时,会通过缓存一致性协议和总线嗅探技术,通知其他处理器来检查这个变量有没有在自己的缓存中,如果缓存了该变量的数据则将该数据置为无效,后续再需要使用次变量时,重新从内存中读取;
- 将
不满足原子性
- 假设现在有两个线程
T1、T2
此时内存中i = 1
,现在两个线程都执行i++
操作,大致操作如下:- T1-读值、T1-计算、T1-写值;
- T2-读值、T2-计算、T2-写值;
- 他们的每一步操作都是原子的,但整体不是,这就会导致当两条线程并行时,语句的执行可能变为(T1-读值、T2-读值、T2-计算、T2-写值、T1-计算、T1-写值),最终的结果为
i = 2
不符合预期的i = 3
; - 现在假设使用了
volatile
来修饰变量,假设还是上面的执行流程,当T2
写值时,会触发内存屏障,此时会要求还未刷回数据的T1
线程重新获取一次主存数据,也就是i = 2
,再经过计算后写入主存,最终结果为i = 3
符合预期;- 从步骤三来看,似乎
volatile
解决线程安全问题,但其实步骤三是需要建立在T1、T2
处与同一核心的情况;对于多核CPU
,T1、T2
线程可以绑定不同核心,从而达到并行执行的效果,此时就可能出现T1、T2
的i++
操作,在同一时刻并发执行,接着出现T1、T2
同时将i==2
这个结果,刷写回主内存情况,从而导致线程安全问题;
- 从步骤三来看,似乎