目录
一.可见性是什么
二.可见性的本质
2.1 CPU高速缓存
2.1.1 缓存一致性
2.1.2缓存一致性协议
2.1.3MESI带来的可见性问题
2.2CPU的乱序执行
2.3解决乱序执行的方案—内存屏障
三.JMM—java内存模型
3.1JMM的重排序问题
3.2Happens-before
3.3Volatile内存语义实现
我们知道终止线程的方式有调用interrupt()和改变终止标识两种。下面的改变终止标识示例中,thread不会终止。
stop变量使用volatile关键字修饰,进程终止成功
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。那么是如何保证可见性的呢?
通过工具我们获取volatile修饰的共享变量进行写操作的时候会多出行汇编代码,会发现有一个lock前缀:
而Lock前缀的指令在多核处理器下会引发了两件事情:
将当前处理器缓存行的数据写回到系统内存。
这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
说清楚这两件事前我们需要先从硬件层面了解下可见性的本质。
一台计算机中核心的组件包括CPU处理器、内存以及I/O设备。而三者处理速度的差异却影响着计算机的性能。CPU跑的快要等跑的最慢的I/O设备。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化:
CPU增加了高速缓存
操作系统增加了进程、线程,通过CPU的时间片切换提高效率
编译器的指令优化
高速缓存(Cache)是一种用于存储和快速检索数据的临时存储设备或存储空间,用于提高计算机系统的性能和响应速度。高速缓存通常位于计算机的内存层次结构中,处于主存和中央处理单元(CPU)之间,用于存储常用的数据和指令,以减少对慢速主存或硬盘的访问。
通常cpu内有3级缓存,即L1、L2、L3缓存。其中L1缓存分为数据缓存和指令缓存,cpu先从L1缓存中获取指令和数据,如果L1缓存中不存在,那就从L2缓存中获取。每个cpu核心都拥有属于自己的L1缓存和L2缓存。如果数据不在L2缓存中,那就从L3缓存中获取。而L3缓存就是所有cpu核心共用的。
多CPU场景下,每个线程可能会运行在不同的CPU中,并且每个线程拥有自己的高速缓存。同一份数据就会被缓存到多个CPU中,如果在不同CPU中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。解决缓存不一致,CPU层面提供两种办法:
总线锁(Bus Lock):
在多处理器系统中,多个处理器(CPU核心)共享同一物理总线以访问系统内存。总线锁是一种用于同步多个处理器之间访问共享内存的机制。当一个处理器尝试获取总线锁时,它会锁住整个系统总线,阻止其他处理器对内存的访问,直到它完成了需要的操作然后释放锁。这可以确保在多处理器环境下,某些关键操作的执行是互斥的,不会同时被多个处理器访问。
缓存锁(Cache Lock):
多处理器系统中的每个处理器通常都有自己的缓存(Cache),用于存储最近访问的内存数据。缓存锁是一种机制,用于确保多个处理器的缓存中的数据是一致的。当一个处理器修改了内存中的某个数据时,它会发出缓存锁指令,通知其他处理器清除它们的缓存中的相关数据。这样可以确保各个处理器之间的内存数据一致性。
缓存锁的实现是基于缓存一致性协议的。常见的协议有 MSI,MESI,MOSI 等。最常见的就是 MESI 协议。
MESI协议(Modified, Exclusive, Shared, Invalid)是一种用于处理多处理器系统中缓存一致性的协议。它用于确保多个处理器的缓存中的数据是一致的,以避免数据的不一致性和冲突。
MESI协议定义了四种状态,每个缓存行(通常是以缓存行为单位的数据块)可以处于其中之一:
Modified(M):缓存行被标记为“修改”,表示该缓存拥有这个数据,并且数据已被修改。这个缓存行的数据与主内存中的数据不同步。如果其他处理器要读取这个数据,必须从拥有Modified状态的缓存中获取,并且主内存中的数据会被更新。
Exclusive(E):缓存行被标记为“独占”,表示该缓存拥有这个数据,但数据与主内存中的数据一致,没有被修改。其他处理器可以读取这个数据,但写入操作会使该缓存行的状态变为Modified。
Shared(S):缓存行被标记为“共享”,表示多个缓存都拥有相同的数据,且与主内存中的数据一致。其他处理器可以读取这个数据,但写入操作会使该缓存行的状态变为Modified。
Invalid(I):缓存行被标记为“无效”,表示该缓存行无效,不能被使用。通常,在处理器第一次访问某个数据时,它的缓存行状态会被设置为Invalid。如果其他处理器要读取或写入这个数据,必须从主内存中获取。
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的 读写操作,而且也监听(snoop)其它 Cache 的读写操作
对于MESI协议,从CPU读写角度来说都会遵循一下原则:
CPU读请求:缓存处于M、E、S状态都可以被读取,I状态只能从主存中读取。
CPU写请求:缓存处于M、E状态才可以被写。对于S状态的写,需要将其他CPU中缓存行设置为无效才可以写。
综上所述,CPU对内存的操作我们抽象一下,似乎达到了缓存一致性效果:
到这里,我们会觉得已经达到了缓存一致性的要求,为什么还要volatile关键字呢?
MESI关键的点在于能知道各个CPU缓存行的状态,但是这个状态是通过消息传递进行的,也就是同步阻塞的机制,而为了避免阻塞带来的资源浪费,CPU引入了Store Buffers。
现代处理器使用写缓冲区临时保存向内存写入的数据。好处是:
避免由于处理器停顿等待向内存写入数据而产生延迟
通过批处理的方式刷新写缓冲区
合并写缓冲区对同一内存地址的多次写,减少对内存总线的占用
有了Store Buffer,CPU0在写入共享数据时,就写到Store Buffers中,同时发送invalidate,然后继续处理其他指令。当收到其他所有CPU发送了ack消息时,再将Store Buffers中的数据存储在缓存行中,最后从缓存行同步到主内存。
这种优化也存在两个问题:
数据什么时候提交不确定,因为要等其他CPU回复,这是个异步操作。
有了Store Buffers,CPU会先尝试从StoreBuffers中读取值,如果有数据会直接读取,否则从缓存行读取
StoreBuffers的机制下,有一种经典的情况:
value = 0; void cpu0{ value = 1; // value的缓存行状态假设为S isFinish = true; // isFinish的缓存行状态假设为E } void cpu1{ if(isFinish){ // isFinish为true assert value = 1; // 执行结果返回 false } }
CPU0写共享变量value需要先把value = 1先写入StoreBuffers,通知其他缓存了该变量的CPU,然后继续执行isFinish=true,但是独有变量isFinish可以直接在缓存行更改为true然后刷新到主内存。
CPU1直接从主内存读取到isFinish为true,但是在判断value的时候是有可能还没接收到CPU0的Invalidate消息,value不一定为1。这种现象我们认为是CPU的乱序执行,也可以认为是一种重排序,而且这种重排序是会带来可见性的问题。
在CPU的乱序执行场景下,内存屏障成为了问题的解决方案。内存屏障(Memory Barrier),也称为内存栅栏或内存栅障,是计算机系统中的一种重要概念,用于管理和优化内存操作的顺序性和可见性。内存屏障确保多线程或多处理器系统中的内存访问在执行时满足一定的序列和同步要求,以避免数据竞争和不一致性。
内存屏障的主要作用包括以下几个方面:
确保顺序性:内存屏障用于确保内存操作的顺序性,即某个线程在执行内存操作时不会出现乱序执行的情况。它可以强制指令按照编写程序的顺序执行,而不会受到编译器或处理器的重排序优化影响。
保障可见性:内存屏障用于确保一个线程对共享变量的写入对其他线程是可见的。这意味着当一个线程写入共享变量后,其他线程能够看到该写入,而不会看到之前的旧值。
同步多线程操作:内存屏障用于同步多个线程的操作,以确保它们之间的协作和通信是正确的。例如,它可以确保线程 A 在线程 B 之前完成了某个操作。
内存屏障通常有以下几种类型:
读屏障(Read Barrier):确保读操作不会受到重排序的影响,并且能够看到最新的写入值。
写屏障(Write Barrier):确保写操作不会受到重排序的影响,并且能够保证写入的值对其他线程是可见的。
全屏障(Full Barrier):同时包含读屏障和写屏障的功能,确保读写操作的顺序性和可见性。
内存屏障指令:一些处理器提供专门的指令(如 x86 的 mfence
指令),用于在程序中插入内存屏障以实现序列性和同步要求。
上面的例子就可以进行修改,从而避免出现可见性问题:
value = 0; void cpu0{ value = 1; // value的缓存行状态假设为S writeBarrier;// 伪代码:插入一个写屏障,value = 1 强制写入主内存 isFinish = true; // isFinish的缓存行状态假设为E } void cpu1{ if(isFinish){ // isFinish为true readBarrier; // 伪代码:插入一个读屏障,cpu1从主内存中读取value最新值 assert value = 1; // 执行结果返回 false } }
而这个屏障怎么添加的呢?就是Volatile关键字,这个关键字会生成一个Lock的汇编指令,而这个指令就相当于一种内存屏障。
通过前面的分析发现,导致可见性问题的根本原因是缓存以及重排序。 而 JMM实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上 去可能是在乱序执行。 从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
1属于编译器重排序,其中2和3就是上文例子中的处理器重排序。这些重排序可能会导致多线程程序 出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要 求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
在JMM中,如果一 个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
happens-before规则:
程序顺序规则(Program Order Rule):在单个线程中,操作按照程序中的顺序执行,也就是说,如果操作 B 出现在操作 A 之后,那么操作 A happens-before 操作 B。
监视器锁规则(Monitor Lock Rule):如果一个线程释放监视器锁,而另一个线程在之后获得了同一个监视器锁,那么释放锁的操作 happens-before 获取锁的操作。
volatile变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作 happens-before 对同一个变量的读操作。这确保了对 volatile 变量的写入对其他线程是可见的。
线程启动规则(Thread Start Rule):一个线程的启动(start)操作 happens-before 于新线程的任何操作。
线程终止规则(Thread Termination Rule):一个线程的任何操作 happens-before 于其他线程检测到该线程已经终止(通过 Thread.join() 或 Thread.isAlive() 等方式)的操作。
线程中断规则(Thread Interruption Rule):对线程的中断(interrupt)操作 happens-before 于被中断线程检测到中断请求的操作。
对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束或其他初始化工作完成) happens-before 于它的 finalize() 方法的开始。
传递性规则(Transitivity Rule):如果操作 A happens-before 操作 B,且操作 B happens-before 操作 C,那么操作 A happens-before 操作 C。这是一个传递性规则,它将多个 happens-before 关系连接起来。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来 禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障
3.3.1volatile读内存语义实现
volatile读插入内存屏障后生成的指令序列示意图如下:
3.3.2volatile写内存语义实现
volatile写插入内存屏障后生成的指令序列示意图如下: