JMM内存模型
# JMM内存模型
# 了解冯诺依曼计算机模型
现代计算机模型是基于冯诺依曼计算机模型的,如果不了解的推荐阅读一下这篇文章:冯诺依曼计算机模型详解 (opens new window),下面的一些概念和图片均来源这篇文章。
# CPU读取存储器数据过程
现代CPU为了提升执行效率,减少CPU与内存的交互(交互影响CPU效率),一般在CPU上集成了多级缓存架构。常见的为三级缓存结构: L1、L2是多核独享、L3是多核共享。
CPU读取存储器数据过程:
- CPU要取寄存器X的值,只需要一步:直接读取
- CPU要取L1 cache的某个值,需要1-3步(或者更多):把cache行锁住,把某个数据拿来,解锁,如果没锁住就慢了。
- CPU要取L2 cache的某个值,先要到L1 cache里取,L1当中不存在在L2里,L2开始加锁,加锁以后,把L2里的数据复制到L1,再执行读L1的过程,即上面的3步,再解锁。
- CPU取L3 cache的也是一样,只不过先由L3复制到L2,从L2复制到L1,从L1到CPU
- CPU取内存则最复杂:通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定
总的来说,CPU读取存储器数据过程简单可以理解为是一级一级往下去读取的,读取到之后再一级一级往上复制同步,最后CPU读取到寄存器进行操作,中间的过程会涉及到锁。
# JVM创建线程CPU的工作过程
CPU有4个运行级别:ring0、ring1、ring2、ring3。Linux与Windows只用到了2个级别:ring0(系统态,操作系统内部内部程序指令)、ring3(用户态,第三方程序),第三方程序如果要调用操作系统内部函数功能,CPU要切换运行状态。例如,JVM创建线程、线程阻塞唤醒需要CPU切换运行状态,是重型操作了。
JVM创建线程CPU的工作过程如下:
- CPU从ring3切换ring0创建线程
- 创建完毕,CPU从ring0切换回ring3
- 线程执行JVM程序
- 线程执行完毕,销毁还得切会ring0
Java是内核线程模型(KLT),我们的线程都有两个堆和栈,一个在用户空间(用户态),一个在系统空间(系统态)。如果我们不去调用系统库的话(比如开启一个线程),都是运行在用户空间,一旦你的线程需要阻塞或者杀死,那么你的CPU状态就要从用户态切换到内核态,等把操作系统的堆和栈给丢了,即阻塞或者杀掉好了以后,又会从系统态切回到用户态 。
# JMM内存模型
JMM(Java Memory Model)—Java内存模型,简称JMM。JMM本身是一种抽象的概念,并不真实存在。它描述的是一组规则或规范,通过规范定制了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。
为什么推导出JMM模型?
前面提到CPU采用了多级缓存架构,而CPU的运行并不是直接操作内存而是先把内存里边的数据读到缓存,而CPU缓存与内存与主存速度差距又比较大,最后导致内存的读和写操作的时候造成不一致的问题。所以Java虚拟机规范中试图定义一种Java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
简单来说,JMM是为了规避“内存可见性的问题”。我们看看CPU是怎么处理缓存一致性的问题的,例如下图为x86架构下CPU缓存的布局,即在一个CPU 4核下,L1、L2、L3三级缓存与主内存的布局。每个核上面有L1、L2缓存,L3缓存为所有核共用。
因为存在CPU缓存一致性协议,例如MESI,多个CPU核心之间缓存不会出现不同步的问题,不会有“内存可见性”问题。缓存一致性协议对性能有很大损耗,为了解决这个问题,又进行了各种优化。例如,在计算单元和L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer),如下图:
L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和L1之间却是异步的。向内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入L1中,同时同步写入主内存中。操作系统内核视角下的CPU缓存模型:
多CPU,每个CPU多核,每个核上面可能还有多个硬件线程,对于操作系统来讲,就相当于一个个的逻辑CPU。每个逻辑CPU都有自己的缓存,这些缓存和主内存之间不是完全同步的。
对应到Java里,就是JVM抽象内存模型,如下图所示:
所以,Java引入了JMM,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。JMM关键技术点都是围绕多线程的可见性、原子性、和有序性展开的。
# 可见性-修改是否可见
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更。简单来说,就是能不能及时看得到修改。
JMM规定了所有的变量都存储在主内存中,但是Java中普通的共享变量不保证可见性。因为数据修改被写入内存的时机是不确定的,多线程并发很可能出现"脏读",所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必需在线程自己的工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
假设有A、B两个线程同时去操作主物理内存的共享数据number=0
,A抢到CPU执行权,将number
刷新到自己的工作内存,这个时候进行number++
的操作,即number=1,这时将A中的工作内存中的数据刷新到主物理内存,随后,马上通知B,B重新拿到最新值number=1
刷新B的工作内存中。
volatile关键字可以解决变量的可见性。
# 原子性-操作原子性
原子性指一个操作是不可中断的,即多线程坏境下,操作不能被其他线程干扰。
# 八大原子操作
一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,JMM定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
- unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工 作内存的变量副本中
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
- write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值 传送到主内存的变量中
有兴趣可以读读字节码指令集
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
@Slf4j
public class CodeVisibility {
private static boolean initFlag = false;
private volatile static int counter = 0;
public static void refresh(){
log.info("refresh data.......");
initFlag = true;
log.info("refresh data success.......");
}
public static void main(String[] args){
Thread threadA = new Thread(()->{
while (!initFlag){
//System.out.println("runing");
counter++;
}
log.info("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变");
},"threadA");
threadA.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread threadB = new Thread(()->{
refresh();
},"threadB");
threadB.start();
}
}
# 有序性-读写有序
计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,一般分为编译器优化重排、指令并行重排、内存系统重排:
- 编译器重排序:对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
- 指令重排序:在指令级别,让没有依赖关系的多条指令并行。
- 内存系统重排:CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。前面提到的CPU Store Buffer的延迟写入其实是内存系统重排。
指令重排要注意的问题:
单线程坏境里面确保程序最终执行结果和代码顺序执行的结果一致
必须要考虑指令之间的数据依赖性,例如下面的例子:
public void mySort() { int x = 11; // 语句1 int x = 11; // 语句2 x = x + 5; // 语句3 y = x * x; // 语句4,无法重排,存在数据依赖性 }
# 指令重排导致的问题
在三种重排序中,第三类就是造成“内存可见性”问题的主因,如下案例:
// 线程1:
X = 1;
a = Y;
// 线程2:
Y = 1;
b = X;
假设X、Y是两个全局变量,初始的时候:X=0,Y=0
。请问,这两个线程执行完毕之后,a、b的正确结果应该是什么?
很显然,线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的结果可能是:
1. a = 0, b = 1
2. a = 1, b = 0
3. a = 1, b = 1
也就是不管谁先谁后,执行结果应该是这三种场景中的一种。但实际可能是a=0,b=0
。
如果两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0,b=0
。原因是线程1先执行X=1,后执行a=Y,但此时X=1还在自己的Store Buffer里面,没有及时写入主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。
虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来,a=Y和X=1顺序却是颠倒的。指令没有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题。
由此也可以得知,多线程坏境中线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致是无法确认的。
# 多线程对变量的读写过程
这里其实在前面引入JMM内存模型的时候提到过。也就是规避“内存可见性”的问题
在JMM规范下,多线程对变量的读写过程:由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(Java栈),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有的变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到线程自己的工作内存空间,然后对变量进行操作,操作完成后将变量写回主内存,不能直接操作主内存中的变量,各个线程的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图)
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)。每个线程都有一个私有的本地工作内存,JMM本地工作内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,JMM比如寄存器、L1、L2、L3缓存等)。
# 内存屏障
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。
- 编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
- 而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了。但从JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数,如下所示。
public final class Unsafe {
// ...
public native void loadFence();
public native void storeFence();
public native void fullFence();
// ...
}
在理论层面,可以把基本的CPU内存屏障分成四种:
- LoadLoad:禁止读和读的重排序。
- StoreStore:禁止写和写的重排序。
- LoadStore:禁止读和写的重排序。
- StoreLoad:禁止写和读的重排序。
Unsafe中的方法:
- loadFence=LoadLoad+LoadStore
- storeFence=StoreStore+LoadStore
- fullFence=loadFence+storeFence+StoreLoad
# as-if-serial语义
重排序的原则是什么?什么场景下可以重排序,什么场景下不能重排序呢?
# 单线程程序的重排序规则
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。
即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题。
# 多线程程序的重排序规则
编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。
对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。编译器和CPU只能保证每个线程的as-if-serial语义。线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。
上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。这个也是JMM要处理的事情。
# happen-before原则
JMM是一套规范,在多线程中,一方面,要让编译器和CPU可以灵活地重排序;另一方面,还要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序。
如果Java内存模型中有序性仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点。这是因为Java语言中,JMM有一个happens-before原则,即"先行发生"原则,它主要描述两个操作之间的内存可见性。
# happen-before描述
A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束(有序性)。
举个例子,有以下的代码执行:
x = 1; // 线程A执行 y = x; // 线程B执行
如果线程A先执行了,那么happen-before保证线程B的操作读取到的x=1,也就是y=1。
A与B存在happens-before关系,并不意外着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
# happen-before传递性
除了这些基本的happen-before规则,happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。
如果一个变量不是volatile变量,当一个线程读取、一个线程写入时可能有问题。那岂不是说,在多线程程序中,我们要么加锁,要么必须把所有变量都声明为volatile变量?这显然不可能,而这就得归功于happen-before的传递性。
class A {
private int a = 0;
private volatile int c = 0;
public void set() {
a = 5; // 操作1
c = 1; // 操作2
}
public int get() {
int d = c; // 操作3
return a; // 操作4
}
}
假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值一定是a=5。为什么呢?
- 操作1和操作2是在同一个线程内存中执行的,操作1 happen-before 操作2,同理,操作3 happen-before 操作4。
- 又因为c是volatile变量,对c的写入happen-before对c的读取,所以操作2 happen-before 操作3。
- 利用happen-before的传递性,就得到:操作1 happen-before 操作2 happen-before 操作3 happen-before 操作4。
- 所以,操作1的结果,一定对操作4可见。
class A {
private int a = 0;
private int c = 0;
public synchronized void set() {
a = 5; // 操作1
c = 1; // 操作2
}
public synchronized int get() {
return a;
}
}
假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值也一定是a=5。为什么呢?
因为与volatile一样,synchronized同样具有happen-before语义。展开上面的代码可得到类似于下面的伪代码:
线程A: 加锁; // 操作1 a = 5; // 操作2 c = 1; // 操作3 解锁; // 操作4 线程B: 加锁; // 操作5 读取a; // 操作6 解锁; // 操作7
根据synchronized的happen-before语义,操作4 happen-before 操作5,再结合传递性,最终就会得到:
操作1 happen-before 操作2……happen-before 操作7。所以,a、c都不是volatile变量,但仍然有内存可见性。
# happen-before八条
- 次序规则:一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作(强调的是一个线程),前一个操作的结果可以被后续的操作获取。简单说,就是前面一个操作把变量X赋值为1,那后面一个操作肯定能知道X已经变成了1。
- 锁定规则:一个unlock操作先行发生于后面(指时间上的先后)对同一个锁的lock操作。简单说,就是上一个线程unlock了,下一个线程才能获取到锁,进行lock。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。简单说,就是前面的写对后面的读是可见的,这里的"后面"同样是指时间是的先后。
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出A先行发生于操作C。也就是上面说的传递性。
- 线程启动规则(Thread Start Rule):Thread对象的start( )方法先行发生于线程的每一个动作
- 线程中断规则(Thread Interruption Rule):对线程interrupt( )方法的调用先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted( )检测到是否发生中断。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测
- 对象终结规则(Finalizer Rule):对象没有完成初始化之前,是不能调用finalized( )方法的