JUC——Java内存模型
任何语言都是抽象自操作系统底层,而操作系统建构在硬件之上,所以语言的一些操作特性,一定抽象自系统底层硬件的运行特点
Java的并发编程有三要素,原子性、可见性、有序性,JMM解决了这个问题
CPU缓存
CPU缓存分为三层,I/O的速度从高到低根据距离CPU核心的远近而逐渐降低,CPU缓存出现的目的是为了解决CPU读取速度与内存的I/O速度不匹配的关系,CPU会将数据按照访问的频率从内存中读取出来,放在缓存的不同位置,这样当CPU读取数据的时候,就会优先读取缓存的,提高了读取速度,提高了CPU的利用率。
L1——一级缓存
属于近核缓存,是内存最小的一层,也是三级缓存中最快的一层,L1缓存分为两种,一种是指令缓存,一种是数据缓存,两种缓存可以同时被CPU访问,而不会相互产生数据干扰,这样减少了CPU多核心、多线程争用缓存造成的冲突,提高了处理器的效率。
L2——二级缓存
CPU如果没有在L1中查询到想要的数据,就会来查询L2,这是CPU的第二层高速缓存,内存大于L1,但是访问速度比L1也慢,L1读取速度为4个时钟周期,L2为11个时钟周期。
L3——三级缓存
属于远核缓存,是三级缓存中最慢的一层,也是多CPU共享的缓存层,当L1、L2都没有命中后,就会到L3中进行查询,L3是所有CPU共享的,访问周期在107个时钟周期左右,L1、L2均为内置缓存,L3早期外置,现在大多数也不外置了,大多改成了内置缓存,L3的应用可以进一步降低内存延迟。
工作原理
当所需要的数据在CPU缓存中的时候,CPU直接从缓存中读取,如果不在缓存中,则耗费一定的时间从内存中读取,同时把这段数据从内存中加载到缓存中,保存一份副本;
缓存的读取有两大原则:
- 时间局部性原则:某个数据被访问,将来的某个时刻一定会被再次访问
- 空间局部性原则:某个数据被访问,紧邻着它的数据很快也会被访问
在缓存中数据被访问的同时,为了保证缓存内的数据的一定量,缓存也会按照访问频率的减少进行数据的淘汰,从L1淘汰至L2,从L2淘汰至L3,在进一步淘汰到内存中。
缓存一致性协议MESI
MESI是一种基于失效的缓存一致性协议
缓存一致性协议就是为了保证多个CPU内核之间保存的数据副本是一致的,这就涉及到了内存可见性问题的解决,比如说目前CPU内存储的数据为int a= 0;
,Core1提出修改a为1,那么所有Core都要得知这个结果,全部修改a为1
为什么说MESI是基于失效的呢?这就不得不讲到MESI的协写回机制了,数据从缓存修改,同步到内存,有两种方法,一种是直写*「Write-Though」
,就是每次修改都会将数据同步到内存中,还有一种是写回「Write-Back」
,在缓存中修改完之后,不立即同步到内存中,而是等到缓存行将要被替代时才会更新到内存上,这就是MESI的更新时机;所谓基于缓存失效,则是一个共有缓存在Core0被更新,所有其他CPU上有关的缓存全部失效,当需要读取这段数据的时候,让Core0将数据写入内存,再从内存中读取就好。
缓存行
高速缓存就是一组缓存行组成的数据块,缓存行是CPU缓存中可分配的最小资源,通常是64个字节,当CPU从内存中读取数据到缓存的时候,每一次就是读取一个缓存行大小,缓存相当于是内存数据的一个映射,所以缓存行的地址是和内存中的地址是相同的
- 工作方式:CPU从缓存中读取数据的时候,会比较待访问的内存地址和缓存行的地址块是否相同,如果地址相同,则检查缓存行状态,状态有效则读取该条缓存行,无效则从主存中读取,或者根据一致性协议发生一次cache-to-cache之间的数据推送
MESI
在CPU内核中,无论哪片CPU的缓存内容出现改变,也就是发生了数据不一致的问题,这个协议就是为了保证缓存数据一致性而产生的。
CPU的缓存一致性协议听起来很像MySQL的查询缓存,当Core0修改缓存中的数据的时候,其他CPU都会收到消息,告知它们的缓存都已经视为无效,然后Core0将自己的数据写入内存,其他CPU发现自己的缓存失效之后就会去内存中读取。
MESI描述的是缓存行的状态情况(在比较完地址段映射后,查看缓存状态是否正常),状态共有四种,见如下列表。
正是有了MESI协议的存在,才能使多级缓存的使用体验和一级缓存一样,MESI协议是针对缓存行的,简而言之,和总线锁不同,MESI是给缓存行加锁 >_<
状态 | 描述 | 监听任务 |
---|---|---|
M 修改 (Modified) | 该缓存行状态有效,只是数据被修改了,和内存中数据不一致 | 该状态下处理器必须监听其他缓存是否需要操作这行数据,这种操作必须等到该行数据写入到内存中,状态变成S时才可以进行 |
E 独享、互斥 (Exclusive) | 该行缓存有效,数据和内存中一致,但是数据只存在于本缓存中 | 当前处理器对该缓存行独占处理,,其他处理器不会有相同数据,相当于加独占锁,当其他处理器需要读取这一行数据时,会先收到这行数据已经失效的信号,和M状态操作相同,成为S状态之后才可以被访问 |
S 共享 (Shared) | 该行缓存有效,数据和内存中一致,但是数据存在于多缓存中 | S状态下数据是只读的,所以S状态下,处理器需要监听其他处理器的写操作,如果其他缓存行状态变E或者M,则将该行缓存状态变为I |
I 无效 (Invalid) | 缓存行无效 | 无 |
状态转换
CPU cache
引起状态转换的操作有四种,与CPU读取或写入的请求类型相关:
操作 | 描述 |
---|---|
本地读取「Local Read」,LR | 当前核读取本地高速缓存的数据 |
本地写入「Local Write」,LW | 当前核将数据写入到本地高速缓存 |
远程读取「Remote Read」,RR | 将其他核将主内存中的数据读取到高速缓存中来 |
远程写入「Remote Write」,RW | 其他核将高速缓存中的数据写回到主存里面去 |
1. Modify:
场景:当前CPU中数据的状态是Modifiy,表示当前CPU中拥有最新数据,虽然主存中的数据和当前CPU中的数据不一致,但是以当前CPU中的数据为准;
- LR:此时如果发生local read,即当前CPU读数据,直接从cache中获取数据,拥有最新数据,因此状态不变;
- LW:直接修改本地cache数据,修改后也是当前CPU拥有最新数据,因此状态不变;
- RR:因为本地内存中有最新数据,当本地cache控制器监听到总线上有RR发生的时,必然是其他CPU发生了读主存的操作,此时为了保证一致性,当前CPU应该将数据写回主存,而随后的RR将会使得其他CPU和当前CPU拥有共同的数据,因此状态修改为S;
- RW:当cache控制器监听到总线发生RW,当前CPU会将数据写回主存,因为随后的RW将会导致主存的数据修改,因此状态修改成I;
2. Exclusive:
场景:当前CPU中的数据状态是exclusive,表示当前CPU独占数据(其他CPU没有数据),并且和主存的数据一致;
- LR:直接从本地cache中直接获取数据,状态不变;
- LW:修改本地cache中的数据,状态修改成M(因为其他CPU中并没有该数据,不存在共享问题,因此不需要通知其他CPU修改cache line的状态为I);
- RR:本地cache中有最新数据,当cache控制器监听到总线上发生RR的时候,必然是其他CPU发生了读取主存的操作,而RR操作不会导致数据修改,因此两个CPU中的数据仍和主存中的数据一致,此时cache line状态修改为S;
- RW:同RR,当cache控制器监听到总线发生RW,必然是其他CPU将最新数据写回到主存,此时为了保证缓存一致性,当前CPU的数据状态修改为I;
3. Shared:
场景:当前CPU中的数据状态是shared,表示当前CPU和其他CPU共享数据,且数据在多个CPU之间一致、多个CPU之间的数据和主存一致;
- LR:直接从cache中读取数据,状态不变;
- LW:发生本地写,并不会将数据立即写回主存,而是在稍后的一个时间再写回主存,因此为了保证缓存一致性,当前CPU的cache line状态修改为M,并通知其他拥有该数据的CPU该数据失效,其他CPU将cache line状态修改为I;
- RR:状态不变,因为多个CPU中的数据和主存一致;
- RW:当监听到总线发生了RW,意味着其他CPU发生了写主存操作,此时本地cache中的数据既不是最新数据,和主存也不再一致,因此当前CPU的cache line状态修改为I;
4. Invalid:
场景:当前CPU中的数据状态是invalid,表示当前CPU中是脏数据,不可用,其他CPU可能有数据、也可能没有数据;
LR:因为当前CPU的cache line数据不可用,因此会发生读内存,此时的情形如下。
- 如果其他CPU中无数据则状态修改为E;
- 如果其他CPU中有数据且状态为S或E则状态修改为S;
- 如果其他CPU中有数据且状态为M,那么其他CPU首先发生RW将M状态的数据写回主存并修改状态为S,随后当前CPU读取主存数据,也将状态修改为S;
LW:因为当前CPU的cache line数据无效,因此发生LW会直接操作本地cache,此时的情形如下。
- 如果其他CPU中无数据,则将本地cache line的状态修改为M;
- 如果其他CPU中有数据且状态为S或E,则修改本地cache,通知其他CPU将数据修改为I,当前CPU中的cache line状态修改为M;
- 如果其他CPU中有数据且状态为M,则其他CPU首先将数据写回主存,并将状态修改为I,当前CPU中的cache line转台修改为M;
RR:监听到总线发生RR操作,表示有其他CPU读取内存,和本地cache无关,状态不变;
RW:监听到总线发生RW操作,表示有其他CPU写主存,和本地cache无关,状态不变;
重排序
代码执行的时候,编译器有可能会为了执行的性能而重新排列指令执行的顺序,代码可能会经历三个排序的过程:
编译重排序 -> 指令并行重排 -> 内存系统重排
编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level
Parallelism
,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
编译优化重排一般采用内存屏障指令来禁止特定的编译器进行重排序
内存屏障
内存屏障分为读屏障和写屏障两种,是位于硬件之上,操作系统或JVM之下的对并发做出的最后一层支持。
所谓内存屏障,其实就是一种防止高级语言编译为汇编语言时指令颠倒的工具,
JMM内存模型
JMM是一种抽象的概念,它是为了规范线程间如何对主存内的共享变量进行读取和写入操作而产生的,JVM是针对不同操作系统编译语言的抽象,实现一次编译到处执行;JMM是针对不同的操作系统的内存模型,定义内存的访问规则,JMM遵守最终一致性
在并发条件下,我们需要解决两个问题,线程同步问题和线程通信问题,为此抽象出来两种并发模型:
- 共享内存并发模型:线程通信是隐式的,通过对共享内存的读写完成线程间的通信;线程同步是显式的,必须指定那段代码是互斥操作的
- 消息传递并发模型:线程通信是显式的,不同线程必须相互发送消息实现通信;线程同步是隐式的,在消息传递的过程中,就已经包含了线程内数据的变化信息,而且是顺序且原子的
JMM的内存同步规则抽象自CPU的缓存一致性原则,不同的是,硬件架构并不区分堆栈,而JMM为了应对Java程序中不同类型内存区域的划分抽象了线程堆栈和堆内存的概念
线程堆栈:每一个线程都有一个独立的线程堆栈,存储的是由这个线程创建或操作的局部变量,这个局部变量只对读写和创建它的线程可见,通过八种同步操作
读-写
共享内存来达到线程间的数据一致性堆内存:共享内存,存储的是共享的局部变量,内存可见性问题发生在对共享变量的操作中
以上是JMM内存模型的抽象示意图,线程对于局部变量的操作是通过本地内存进行的
JMM如何解决并发问题
JMM从两个维度来理解,一方面是JMM的核心体系,三大关键字和Happens-Before原则,一方面是从JMM的三大特性来理解
关键字
单开了一章
Happens-Before原则
Happens-Before原则实际上就是线程顺序先后的定义,直译为“先行发生”,描述了JMM中两项操作的先后关系和可见性的联系。Happens-Before原则定义如下:
- 如果一个操作Happens-Before另一个操作,那么第一个操作的执行结果对第二个操作可见,第一个操作的执行顺序排在第二个操作之前
- 两个操作存在Happens-Before关系,但不意味着操作的实现必须按照Happens-Before原则执行,如果重排序后和Happens-Before关系执行结果一致,则这种重排序并不非法
Happens-Before提供跨线程的内存可见性保证,上文提到的操作可以发生在一个线程内,也可以发生在不同线程中。
针对这个第 1 条定义,我来举个例子:
1 |
|
假设线程 A 中的操作 a Happens-before 线程 B 的操作 b,那我们就可以确定操作 b 执行后,变量 j 的值一定是等于 1。
得出这个结论的依据有两个:一是根据 Happens-before 原则,a 操作的结果对 b 可见,即 “i=1” 的结果可以被观察到;二是线程 C 还没运行,线程 A 操作结束之后没有其他线程会修改变量 i 的值。
现在再来考虑线程 C,我们依然保持 a Happens-before b ,而 c 出现在 a 和 b 的操作之间,但是 c 与 b 没有 Happens-before 关系,也就是说 b 并不一定能看到 c 的操作结果。那么 b 操作的结果也就是 j 的值就不确定了,可能是 1 也可能是 2,那这段代码就是线程不安全的。
我们接下来来看第二条定义,这条定义讲述了JMM的基本原则:只要不改变程序执行结果,编译器怎么优化都可以,在前文我们曾经提到过——JMM是最终一致性的,就是自Happens-before的定义的出来的结论。
happens-before 与 JMM 的关系如下图所示:
由此可见,happens-before就是JMM抽象的灵魂
8 条 Happens-before 规则
单一线程原则:在同一个线程内,程序次第执行
管程锁定原则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后。
这个规则针对的是synchronized
,具体过程就是A线程对变量i = 1
加锁,改成i = 2
后释放锁,B线程进入代码块时就可以发现A线程读变量i的操作
volatile 变量规则:使用volatile内存屏障完成读写操作的排序,对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。(就是写屏障)
这个规则就是 JDK 1.5 版本对 volatile 语义的增强,意义极其重大,举个例子
1 | int a = 0; |
假设线程 A 执行 writer() 方法之后,线程 B 执行 reader() 方法。
根据根据程序次序规则:1 Happens-before 2;3 Happens-before 4。
根据 volatile 变量规则:2 Happens-before 3。
根据传递性规则:1 Happens-before 3;1 Happens-before 4。
也就是说,如果线程 B 读到了 flag==true
或者int i = a
那么线程 A 设置的a=22
对线程 B 是可见的。
线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。
比如主线程先启动A线程再启动B线程,启动B线程之前的所有操作都对B可见
线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread 对象的 join() 方法是否结束、Thread 对象的 isAlive() 的返回值等手段检测线程是否已经终止执行。
线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread 对象的 interrupted() 方法检测到是否有中断发生。
对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
时间顺序与先行发生
一个操作 “时间上的先发生” 是否就代表这个操作会是“先行发生” 呢?一个操作 “先行发生” 是否就能推导出这个操作必定是“时间上的先发生”呢?
不能这样理解,一个操作 “时间上的先发生” 不代表这个操作会是 “先行发生(Happens-before),很简单,所谓时间上先发生是指主线程刚好运行到这里,但是并不代表在执行上是这样操作的,一个是开始调用,一个是编译执行,两者层次不同,自然也互不相通
三大特性
原子性
在线程和主存的拷贝操作中,必须保证一个变量从主内存拷贝到工作内存、以及从工作内存同步回主内存这一类的实现是原子的、不可再分的,也就是要么全部执行成功,要么全部失败,不存在中间状态
JMM原子操作
lock
(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。unlock
(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。read
(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。load
(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。use
(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。assign
(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。store
(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。write
(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量
实现原子性除了对变量的原子操作以外,还有使用锁和CAS的方式来解决这个问题
可见性
联系前文MESI协议,当一个CPU修改了变量,其他CPU必须立即得知该变量发生了修改,并且采用失效的方式解决这个问题。
在JMM中,可见性问题源于工作内存与主内存的同步延迟,解决可见性的方法除了volatile
之外,还有synchronized
和final
有序性
CPU有指令优化,JVM也有指令重排序,重排序并不是不受限制的,在单线程的时候,必须要遵守as-if-serial 语义:不管怎么重排序,单线程环境下程序的执行结果不能被改变。
为了遵守 as-if-serial 语义,CPU 和编译器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
这就引出了数据依赖性的概念
数据依赖性 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1; b = a; | 写一个变量,在读取 |
写后写 | a = 1; a = 2; | 写一个变量,然后修改 |
读后写 | a = b; b = 1; | 读出来一个变量,然后写入一个变量 |
数据依赖性是针对单线程的,不同 CPU 之间和不同线程之间的数据依赖性是不被 CPU 和编译器考虑的。
Java 语言提供了 volatile
和 synchronized
两个关键字来保证线程之间操作的有序性。