JVM:Java内存模型(1)
🥬一、计算机内存模型
1. 缓存
在计算机的硬件不断发展过程中,处理器和内存速度的差异越来越大,同时处理器的运算能力也在提高。为了解决这个问题,在处理器核内存中间增加了高速缓存缓解了处理器与内存速度之间的矛盾,但是也带来了缓存一致性的问题。为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时根据协议来进行操作,这类协议有MESI、MSI等。
2. 乱序执行
除了增加高速缓存之外,为了使处理器内部的运算单元尽量被充分利用,处理器可能会对输入代码乱序执行out-of- order Execution优化,处理器会在计算之后将乱序执行的结果重组,保证结果与顺序执行的结果一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。
3. 内存模型
内存模型是一种规范和抽象,定义了在共享内存的系统中对共享数据的读写操作的行为规范,通过这种规范保证了对共享数据读写的正确性,解决了高速缓存、处理器优化导致的内存访问问题。
🥒二、CPU缓存设计
1. 为什么需要缓存
计算机是通过CPU执行程序,在CPU执行程序的过程中,需要将程序指令和执行程序指令所需要的数据从内存读取到CPU。如CPU在执行程序a = b + c的时候,需要先从内存中读取数据b和c,然后再执行b+c。
CPU的执行速度比内存的速度快很多,而且根据摩尔定律 CPU性能每十八到二十四个月就会翻倍,内存的速度每年也会增长,只不过增长较慢,大概每年7%。
为了解决CPU和内存速度不匹配的问题,就在CPU和内存之间,增加一个高速缓存CPU Cache。
除了CPU和内存速度不匹配的原因,引入高速缓存还有程序局部性原理的原因,程序局部性原理即如果访问内存的一个数据a,那么接下来很可能再次放到a,同时a相邻的数据也有可能会被访问到,这就是时间局部性和空间局部性。
2. 缓存介绍
CPU缓存通常分为了三级缓存,即L1 Cache、L2 Cache、L3 Cache通常级别越小越靠近CPU,速度越快,容量越小。
程序执行的时候会将数据先读取到L3 Cache,然后L2 Cache,最后L1 Cache,而CPU读取的时候先在L1 Cache中查找,如果没找到再到L2 Cache中查找,如果还是没找到最后再到L3 Cache中查找,还是没找到,那只能到内存中查找。
在CPU缓存中读取数据都是以缓存行Cache Line为单位的,Cache Line的结构如下

在Linux系统中可以通过一下两个命令来查看CPU缓存相关参数
#查看cache line 大小
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
#查看各级缓存大小 inde0-3分别是 L1数据缓存 L1指令缓存 L2数据缓存 L3数据缓存
cat /sys/devices/system/cpu/cpu0/cache/index0/size
3. 缓存结构图
下面这幅图就是CPU缓存结构,其中L1 Cache和L2 Cache是CPU核独占的,L3 Cache是CPU核共享的。L1 Cache分为了两部分L1 D-cache数据缓存和L1 i-cache指令缓存。

4. 缓存数据写入
4.1 数据写入的策略
数据读取到内存之后经过运算之后需要将数据写入到内存里面,CPU采用的是两种策略:
🥇写直达:每次都把数据写回主内存,这样每次都写回内存很耗时间,性能低,所以被废弃了
🥈写回:每次写数据的时候,都把数据写回到Cache Line里面,当被修改过的Cache Line再次被写回的时候,才需要把Cache Line中的数据写回到内存里面。
4.2 写回
在CPU写回操作的时候,如果数据已经在CPU Cache里的话,把数据更新到CPU Cache里面,同时标记这个Cache Line的数据为脏Dirty的,表示这个Cache Line里面存的数据和内存里面是不一致的。
当CPU写回操作的时候,如果数据对应的CPU Line里面存放的是其它内存地址的数据,就要检查这个Cache Line的数据是否被标记为脏Dirty的,如果被标记为脏Dirty的,那就需要把Cache Line里面的数据同步到内存里面去,然后把当前要写回的数据写到Cache Line,并标记当前数据为脏Dirty的。
5. 缓存设计带来的问题
CPU缓存设计带来了缓存一致性问题,缓存一致性问题简单来说就是多个CPU核缓存了同一个数据,每个核对这个数据的操作对其它核是不可见的,导致这个数据出现不一致,这就是缓存一致性问题。
用图来描述一下这个过程:
🥇两个核同时缓存了内存的一个变量a到自己的缓存中

🥈核1对这个变量进行了加1操作,根据写回策略,核1会标记这个数据为Dirty,但是不会把这个值同步回内存到中,核2也无法感知这个值发生了变化,导致了a这个变量在两个核中不一致。

6. 缓存一致性问题
6.1 保证的前提
由于缓存的设计带来了缓存一致性问题,要保证缓存一致性的前提就要保证以下两点:
-
写传播
某个
CPU核里面的Cache数据更新时,必须要传播到其他核的Cache。 -
事务的串行化
某个
CPU核对数据的操作顺序,必须在其它核看起来顺序是一致的,也就是核1先操作了a,核2先操作了b,必须保证核3和核4看到这两个顺序一致的,不能核3收到的写传播是ab,核4收到的写传播是ba。

6.2 如何保证缓存一致性
要保证缓存一致性,有两个前提就是写传播和事务的串行化,事务串行化是包含了写传播的,所以只要保证了事务串行化也保证了缓存一致性。
要实现事务串行化必须要做到两点:
🥇CPU核对Cache数据的操作,需要同步给其它CPU核,也就是写传播
🥈要引入锁的概念,同一时刻只能有一个核操作同一个数据,只有拿到了锁的核才能操作数据
保证缓存一致性的具体技术实现有:总线嗅探、MESI、内存屏障。
7. 总线嗅探
写传播就是CPU核更新了自己的Cache里的数据,然后广播给其它CPU核,最常见的实现方法是总线嗅探。
假设现在有两个CPU核,同时缓存了一个数据a=1,现在CPU核1修改了自己Cache的数据a=2之后,通过总线把修改a=2这个事件广播到其它核,每个CPU核都会监听总线上的广播事件,并检查自己的Cache是不是缓存了数据a,如果自己缓存了这个数据a,那么就需要把自己Cache中的a的值修改为2。
总线嗅探的缺点
🥇无论其它核是否缓存了相同的数据,都需要发一个广播事件,并且需要监听总线,增加了总线的压力;
🥈无法保证事务串行化。
8. MESI
8.1 什么是MESI
MESI又叫做缓存一致性协议,这个协议用于保证CPU Cache的数据一致性,MESI规定数据有四种状态
- Modified: 数据是有效的,当前
Cache独享这个数据,数据被修改了和内存的数据不一致 - Exclusive:数据是有效的,当前
Cache独享这个数据,数据和内存的数据一致 - Shared: 数据是有效的,所有的
Cache共享这个数据,数据和内存的数据一致 - Invalid: 数据是无效的
8.2 如何实现MESI
那么CPU是如何实现这个协议的呢?
在Cache line中有两个位用来表示MESI的四种状态
Cache line的状态转换过程
假设现在有A、B、C三个CPU Core,在主内存当中有一个变量x=1
1.现在A从自己的Cache里面读取数据,没有命中,然后从主内存中把x=1这个变量读取到自己的Cache中,并标记为Exclusive状态。
2.现在B和C也从主内存把x=1这个变量读取到自己的Cache中,这个时候所有的Cache都要标记成Shared状态。
3.A把缓存里面的数据x修改为2,这个时候A需要先发一个广播要求B和C把Cache设置为Invalid状态,然后再修改x=2,然后把Cache状态修改为Modified。
4.如果A继续修改x的值,这个时候由于Cache的状态本身就是Modified的,所以不需要给其它CPU Core发消息,直接修改就可以了。
如果A里的x变量对应的Cache Line要被替换,发现Cache Line状态是Modified,就会在替换前先把数据同步到内存。
8.3 MESI的缺点
MESI是基于总线嗅探机制来实现的,在这一过程中有什么弊端呢?
假设现在有三个CPU Core, Core1、Core2、Core3它们都缓存了同一变量a,Cache Line的状态的状态都是Shared的状态,现在Core1要修改自己缓存的a了,需要把这个通知给Core2和Core3,Core2和Core3收到这个消息之后把自己的Cache line设置为Invalid状态,然后给给Core1一个响应,Core1收到两个响应之后修改数据,然后把自己的Cache line设置为Modified状态。
这一过程中的两个弊端:
🥇写Core1要等Core2和Core3响应之后才能修改自己的数据。
🥈Core2和Core3要修改自己Cache line为Invalid状态后才能给Core1一个响应
这两个过程如果都是同步的话,效率未免太低了,为了解决这两个过程的同步问题,内存模型引入了:Strore Buffer和Invalidate Queue。
8.2. Store Buffer
Store Buffer也叫做写缓存技术,也就是在CPU核和Cache之间引入一层Buffer,写的时候将数据写入到Store buffer里面去了,然后又去执行其它任务了,等其它CPU都响应了,再执行修改操作,把Store buffer里面的数据写入到Cache Line里面去。
8.3. Invalidate Queue
Core2和Core3也不会把Cache Line设置为Invalid状态之后再返回,而是先把这个操作先放到一个Invalidate Queue里面去,然后就给Core1一个响应,后续CPU再来执行这个队列里面的操作。
后续Core1如果要读取变量,先读取Store Buffer里面的数据,没有找到再到Cache Line里面去找,而Core2和Core3读取的时候不会读区Invalidate Queue就会造成脏读的可能。
9 内存屏障
上面两个队提升MESI性能有很大的帮助,但是同时又会引入其它的问题,使得在一些临界点上全局的高速缓存中的数据并不是完全一致的。对于一般的缓存数据基于异步的最终一致性的缓存间数据同步不是大问题,但对于并发程序会影响共享数据的可见性。使得并发程序不能的到正确的结果。
CPU的设计着提供了内存屏障机制将对共享变量读写的高速缓存的强一致性控制权交给程序的编写者或者编译器
内存屏障分为两种写屏障和读屏障。
写屏障:当CPU执行写屏障的指令,必须等待Store Buffer中的指令全部处理完,再执行后面的指令。
读屏障:当CPU执行读屏障指令的时候,必须将Invalid Queue中的指令全部处理完,再执行后面的指令
🥕三、处理器优化
为了使处理器内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行处理,这就是处理器优化。
Java虚拟机的及时编译器(JIT)也会对代码进行优化乱序处理,叫做指令重排。
与处理器的乱洗执行优化类似,Java虚拟机的即时编译器中也有指令冲排序优化。
🥦四、Java内存模型
1. Java内存模型的定义
Java5开始使用了新的内存模型规范JSR133,在JSR133里面对内存模型的定义是一组规范。这组规范的作用是抽象了内存划分、约束编译器行为、定义数据读写规范等等。
内存模型的设计理念是:内存模型是用来保证共享变量的正确性,如果完全禁止缓存、处理器优化、指令重排这样的措施,很明显是因噎废食,但是如果放任不管那程序不会得到正确的结果。如何平衡内存模型对编译器和处理器之间的度,是内存模型的设计理念
2. 内存抽象
2.1 主内存和工作内存
内存模型定义了主内存和工作内存两个区域,主内存和工作内存是一种抽象的描述,并不是真的就给线程分配一个内存区域。
主内存是共享的,所有变量都存储在主内存当中,工作内存是线程私有的,每个线程都有的工作内存,线程不能直接操作主内存的变量,而是要先把主内存的变量做一个拷贝,然后被拷贝的副本放到自己的工作内存。线程之间也不能访问对方的工作内存,只能通过主内存进行数据同步。

2.2 内存模型和内存结构
内存模型的主内存和工作内存是一种抽象的描述,无法做一个直接的映射关系,在深入理解Java虚拟机中描述,如果勉强对应起来的话,主内存主要对应JVM堆空间中的对象实例数据部分,工作内存则对应虚拟机栈的部分区域。

2.3 内存模型和硬件缓存
硬件缓存有一级缓存、二级缓存、三级缓存、寄存器,如果没有内存模型,那么我们编程的时候就要考虑这些复杂的缓存设计。内存模型帮我们做了抽象,把三级缓存、寄存器抽象成工作内存,把内存抽象成主内存。

3. Java内存模型存在的必要性
既然有操作系统级别的内存模型那么为什么还需要Java自己的内存模型?Java程序从编写到运行会面临三个问题:
🥇Java内存模型的主内存和工作内存的数据同步在多线程环境下存在缓存一致性问题
🥈CPU执行指令的时候乱序执
🥉JVM的即时编译JIT会进行指令重排序优化
虽然在硬件层面有内存模型保证数据的读写正确性,但是并不能完全保证,比如JIT的指令冲排序,其次Java是跨平台的,定义自己的内存模型可以屏蔽掉在不同的操作系统的硬件内存模型下的差异,保证Java程序在不同的硬件平台上都能得到正确的结果。
4. Java内存模型规定
Java程序的正确运行面临的三个问题,为了解决这三个问题在JSR133中都做了规定:
处理器优化做了规定: 给定一个程序和该程序的一串执行轨迹,内存模型描述了该执行轨迹是否是该程序的一次合法执行。对于Java内存模型检查执行轨迹中的每次读操作,然后根据特定规则,检查该读操作观察到的写是否合法。
对缓存一致性做了规定: 内存模型的一个高级、非正式的概述显示其是一组规则,规定了一个线程的写操作何时会对另一个线程可见。
对编译的时候指令重排做了规定: 内存模型描述了某个程序的可能行为,JVM实现可以自由地生成想要的代码,只要该程序所有最终执行产生的结果能通过内存模型进行预测。这为大量的代码转换提供了充分的自由,包括动作的重排序以及非必要的同步移除。
针对处理器优化和指令冲排序,内存模型的要求是:对于会改变执行结果的优化,编译器和处理器必须禁止这种优化;对于不会改变程序执行结果的优化,编译器和处理器不做要求,可以进行重排序优化措施。
5. 原子性、可见性、有序性
上面我们说了,Java程序正确执行面临的三个问题,为了解决这三个问题,Java内存模型也做了相应的规定。
把这三个规定再抽象一下就得到了:原子性、可见性、有序性。
🌷原子性:操作在处理器执行的时候不能被中断,要么不执行要么全部执行,处理器优化就会导致原子性问题。
🌹可见性:多个线程读写同一个变量的时候,其中一个线程修改了变量,其它的线程能立马获取到修改后的值。
🌺有序性:对程序重排序执行,不会导致程序结果的改变。
内存模型在保证程序的正确性,其实就是在保证原子性、可见性、有序性三个问题。
🌽五、相关规范
在JSR133中对内存模型的描述是一组规范,既然是规范,那我们先来看看有哪些规范。
1. as-if-serial
不管怎么重排序,单线程的执行结果不能被改变。编译器和处理器都不会对有数据依赖的代码做重排序操作
如下面代码,第三行代码依赖第二行和第一行的结果,所以重排序不能把第二行代码和第一行代码放到第三行后面,但是第一行和第二行的顺序是可以交换的,它们之间没有数据依赖。
int a = 1;
int b = 2;
int c = a + b;
2. happens-before
上面的as-if-serial规则保证了单线程内程序的执行结果不会被改变,在多线程我们也需要规则能保证我们的执行结果不会被改变,happens-before规则能保证正确同步的多线程程序的执行结果不会被改变,保证了可见性。
2.1 关系描述
happe-before规定了先后顺序的可见性,happe-before规则的描述:
- 如果一个操作
happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。 - 两个操作之前存在
happens-before关系,并不意味着必须按照顺序来执行,如果重排序之后的执行结果,与按照happens-before关系来执行的结果一直,那么这种重排序并不是不允许。
2.2 具体的规则
-
程序顺序规则
在单线程中,前面的代码操作
happens-before后面的代码操作。比如下面这两行代码
int a = 1;happens-beforeSystem.out.println(a);int a = 1; System.out.println(a); -
监视器锁规则
对于一个锁的解锁
happens-before于随后对这个锁的加锁。当线程
t1获取到锁,a的初始值是1,然后执行赋值a=2,线程t1执行完毕之后就会释放锁,然后线程t2才能获取到锁。synchronized (this) { if (a == 1) { a = 2; } } -
volatile变量规则
对一个
volatile变量的写happens-before于任意后续对这个volatile变量的读。也就是说只要是
volatile变量写了,后续的读都可以看到这个修改,相当于volatile保证了可见性问题。在Java1.5版本的java.util.concurrent并发工具就是靠volatile语义来实现可见性。 -
传递性
如果
A happens-before B且B happens-before C那么A happens-before C。在执行
read方法的时候,如果x==1能够成立,那么y一定是2,因为x happens-before y, 写y变量happens-before读取y。那么x happens-before读取y,所以这里输出的一定是x=1。int x = 0; volatile int y = 0; public void write() { int x = 1; int y = 2; } public void read () { if (y == 2) { System.out.println(x); } } -
start规则
如果在线程
t1里面调用线程t2的start方法,也就是在线程t1里面启动线程t2,则线程t2的start方法happens-before线程t2里面的任意操作。也就是说线程
t1在调用t2的start方法之前的所有操作都对线程t2可见。public class NDemo { private static int x = 0; public static void main(String[] args) throws InterruptedException { x = 100; Thread t2 = new Thread(() -> { System.out.println(x);//100 }); t2.start(); } } -
join规则
在线程
t1里面调用了线程t2的join方法,线程t2里面的任意操作happens-before线程t1调用线程t2.join方法返回。也就是线程
t1在join方法后面的操作能访问到线程t2里面的操作。public class NDemo { private static int x = 0; public static void main(String[] args) throws InterruptedException { Thread t2 = new Thread(() -> { x = 100; }); t2.start(); t2.join(); System.out.println(x);//100 } } -
线程中断规则
调用线程
interrupt方法happens-before监测到中断事件的发生。public class NDemo { private static int x = 0; public static void main(String[] args) { Thread t2 = new Thread(() -> { if (Thread.currentThread().isInterrupted()) { System.out.println(x);//100 } }); t2.start(); x = 100; t2.interrupt(); } } -
对象终结规则
一个对象的初始化
happens-before对象的finalize方法.public class NDemo { public NDemo () { System.out.println("对象初始化"); } protected void finalize () throws Throwable { System.out.println("对象销毁"); } public static void main(String[] args) { new NDemo(); System.gc(); } } //对象初始化 //对象销毁
🫑六、原语
除了规范之外Java内存模型还提供了一些原语来保证原子性、可见性、有序性。
🥝有序性:synchronized和volatile可以保证有序性。
🍈可见性:synchronized、volatile、final可以保证可见性。
🍇原子性:synchronized关键字可以保证方法或者代码块的操作是原子性的。
转载自:https://juejin.cn/post/7248157339811627064