【并发】深入理解JMM&并发三大特性(二)
【疑问】如果业务逻辑超过1ms,是不是就不需要volatile???
(2)事务串行化(Transaction Serialization)
Modified(M)已修改 与 Invalidated(I)已失效
【并发】深入理解JMM&并发三大特性(二)
我们在上一篇文章中提到了JMM内存模型,并发的三大特性,其中对可见性做了详细的讲解!
这一篇文章,将会站在硬件层面继续深入讲解并发的相关问题!
一、JMM可见性问题回顾
我们上一篇文章,主要讲了 volatile 的作用:
volatile 是为了解决,当前线程对共享变量的操作会存在“读不到”,或者不能立即读到另一个线程对此共享变量的“写操作”(可能需要隔一段时间才可以读到)的问题!
我们解决可见性问题,上一篇文章提到了很多种解决方案,最常用的、最优解一般都是采用volatile修饰共享变量!锁机制、用final修饰也可以达到一样的效果,但是会有一定的性能问题和局限性。(当然,如果业务中“必须”要用到锁,那么改共享变量自然也不用多此一举去加一个volatile了!)
【疑问】如果业务逻辑超过1ms,是不是就不需要volatile???
之前在程序中会去调一个我们自己写的shortWait()函数,用于模拟业务逻辑的执行时间。
public class VisibilityTest {
private boolean flag = true;
private int count = 0;
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
while (flag) {
// TODO 业务逻辑
shortWait(1000000);
count++;
}
System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
Thread.sleep(1000);
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
// 模拟业务执行时间
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
所以,是不是可以说,如果我们知道这一块的业务比较复杂,要查数据库等等,操作较多,一定会超过1ms,那么是不是可以不用volatile?
直接说结论:不行!!!
注意!这里是一个天坑!!!
先看下面的程序,我们将之前的调shortWait(),改为直接在业务逻辑部分编写,但是运行结果居然诡异的发生了变化!!!
public class VisibilityTest {
private boolean flag = true;
private int count = 0;
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
while (flag) {
// TODO 业务逻辑
// do-while的逻辑 shortWait()
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + 1000000 >= end);
count++;
}
System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
}
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
Thread.sleep(1000);
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
}
但是,假如我们把do-while改为while,那么运行结果居然又奇迹般的发生了变化!
long start = System.nanoTime();
long end = 0;
while (start + 1000000 >= end) {
end = System.nanoTime();
}
为什么while可以,do-while不行???小编也不清楚,听完图灵的老师讲课他也说不清楚。
所以,像这种令人匪夷所思的场景肯定还有,还不如直接在变量上加一个volatile来的保险!
二、多CPU多核缓存架构解析
1. CPU高速缓存(Cache Memory)
CPU缓存即高速缓冲存储器,是位于CPU与主内存间的一种容量较小但速度很高的存储器。它是在寄存器与内存之间的“东西”。
如下图所示:
由于CPU的速度远高于主内存,CPU直接从内存中存取数据要等待一定时间周期,Cache中保存着CPU刚用过或循环使用的一部分数据,当CPU再次使用该部分数据时可从Cache中直接调用,减少CPU的等待时间,提高了系统的效率。
在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就是局部性原理。
时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
比如循环、递归、方法的反复调用等。
空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。
比如顺序执行的代码、连续创建的两个对象、数组等。
2. 多CPU多核缓存架构
物理CPU:物理CPU就是插在主机上的真实的CPU硬件
核心数:我们常常会听说多核处理器,其中的核指的就是核心数
逻辑CPU:逻辑CPU跟超线程技术有联系,假如物理CPU不支持超线程的,那么逻辑CPU的数量等于核心数的数量;如果物理CPU支持超线程,那么逻辑CPU的数目是核心数数目的两倍
现代CPU为了提升执行效率,减少CPU与内存的交互,一般在CPU上集成了多级缓存架构,常见的为三级缓存结构。
每个核都有一个独享的L1Cache(L1Cache分为数据缓存和指令缓存)和L2Cache;而L3Cache是所有的核共享缓存。
- 一级缓存
都内置在CPU内部并与CPU同速运行,可以有效的提高CPU的运行效率。一级缓存越大,CPU的运行效率越高,但受到CPU内部结构的限制,一级缓存的容量都很小。
- 二级缓存
它是为了协调一级缓存和内存之间的速度。cpu调用缓存首先是一级缓存,当处理器的速度逐渐提升,会导致一级缓存就供不应求,这样就得提升到二级缓存了。二级缓存它比一级缓存的速度相对来说会慢,但是它比一级缓存的空间容量要大。主要就是做一级缓存和内存之间数据临时交换的地方用。
- 三级缓存
是为读取二级缓存后未命中的数据设计的—种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,这进一步提高了CPU的效率。其运作原理在于使用较快速的储存装置保留一份从慢速储存装置中所读取数据并进行拷贝,当有需要再从较慢的储存体中读写数据时,缓存(cache)能够使得读写的动作先在快速的装置上完成,如此会使系统的响应较为快速。
可以参考这篇文章-三级缓存:
初步理解三级缓存Cache__古_凡_的博客-CSDN博客_三级cachehttps://blog.csdn.net/Ang_ie/article/details/115335431三级缓存与CPU寄存器、内存的关系示意图:
由于现在的CPU都是多核缓存,那么对于同一个变量的运算很可能发生在不同的核心中!那么势必会出现缓存不一致的问题!!!
我们现在要研究的就是CPU是通过什么方式、什么机制来保证缓存一致的???
三、缓存一致性问题
计算机体系结构中,缓存一致性是共享资源数据的一致性,这些数据最终存储在多个本地缓存中。当系统中的客户机维护公共内存资源的缓存时,可能会出现数据不一致的问题,这在多处理系统中的CPU中尤其如此。
在共享内存多处理器系统中,每个处理器都有一个单独的缓存内存,共享数据可能有多个副本:一个副本在主内存中,一个副本在请求它的每个处理器的本地缓存中。当数据的一个副本发生更改时,其他副本必须反映该更改。
缓存一致性是确保共享操作数(数据)值的变化能够及时地在整个系统中传播的机制。
缓存一致性的要求(前提)
(1)写传播(Write Propagation)
某个 CPU 核心里的 Cache 数据更新时,必须要传播(同步)到其他核心的 Cache。
假设我们有一个含有 4 个核心的 CPU,这 4 个核心都操作一个共享变量 i(初始值为 0 )。A 号核心先把 i 值变为 100,而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。这就是写传播!
但是,问题来了,C 和 D都收到了 “传播” 的信息,如果进行更新?i 的值是100还是200?它们的执行顺序又是怎么样的?
要想解决这个问题,我们要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。
(2)事务串行化(Transaction Serialization)
某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的。
怎么实现?
我们这里就需要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。
上述,我们只是讲解了大致的实现逻辑,那么具体是怎么做的呢?
对于写传播(Write Propagation)问题,我们可以通过总线窥探(Bus Snooping) 机制来解决!
对于事务串行化(Transaction Serialization)问题,我们可以通过MESI协议来解决!
总线窥探(Bus Snooping)
当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。
数据变更的通知可以通过总线窥探来完成!
所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本。
如果缓存中有共享块的副本,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效。
窥探协议
根据管理写操作的本地副本的方式,有两种窥探协议
(1)Write-invalidate 写失效协议
当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型。
(2)Write-update 写更新协议(有性能问题)
当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。
MESI协议
MESI协议是一个基于写失效的缓存一致性协议,是支持写回(write-back)缓存的最常用协议。
与写直达(write through)缓存相比,写回(write back)缓冲能节约大量带宽。总是有“脏”(dirty)状态表示缓存中的数据与主存中不同。
它有4种状态:
- Modified(M),已修改
- Exclusive(E),独占
- Shared(S),共享
- Invalidated(I),已失效
Modified(M)已修改 与 Invalidated(I)已失效
「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。
Exclusive(E)独占 与 Shared(S)共享
「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的!!!
「独占」和「共享」它们是有一些差别的!
独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。
那么,「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。
MESI案例分析
我们举个具体的例子来看看这四个状态的转换:
(1)当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的
(2)然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息(广播)给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的
(3)当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了
(4)如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可
(5)如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存
四、总线仲裁机制
在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)
总线事务包括读事务(Read Transaction)和写事务(WriteTransaction)
读事务从内存传送数据到处理器,写事务从处理器传送数据到内存。
每个事务会读/写内存中一个或多个物理上连续的字。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。
假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其他两个处理器则要等待处理器A的总线事务完成后才能再次执行内存访问。
总线的这种工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。
这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
总线锁定
总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
缓存锁定
由于总线锁定阻止了被阻塞处理器和所有内存之间的通信,而输出 LOCK#信号 的CPU可能只需要锁住特定的一块内存区域,因此总线锁定开销较大。
缓存锁定是某个CPU对缓存数据进行更改时,会通知缓存了该数据的该数据的CPU抛弃缓存的数据或者从内存重新读取。
缓存锁定不能使用的特殊情况:
- 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定。
- 有些处理器不支持缓存锁定。
五、有序性 与 volatile禁止重排序场景分析
1. 案例场景分析
有序性是为了可见性服务的!(为了去保证同步)
程序是否可以正常退出?
我们可以来看一段简单的程序,请问下面的程序可以 “正常退出” 吗?换句话说,就是有没有机会执行到break?
public class ReOrderTest {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
while (true) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
/**
* x,y: 00, 10, 01, 11
*/
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
shortWait(20000);
a = 1;
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第" + i + "次(" + x + "," + y + ")");
if (x == 0 && y == 0) {
break;
}
}
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
运行结果
原因分析
我们可以先来分析一下,为什么程序会退出?
程序退出的条件是—— if (x == 0 && y == 0) ,也就是说,当x、y同时为0的时候才会退出!
如果单单看上面的代码,x和y的取值只有01、10、11这三种情况
想要出现00,那么一定是该程序出现了重排序的情况!!!
即这两个赋值语句的执行顺序发生了变化!——重排序!
顺序如下所示:
线程1
x = b;a = 1;
线程2
y = a;
b = 1;
如何避免重排序问题?
大致两个方法 —— volatile 和 内存屏障
其实很简单!使用 volatile 就行
private volatile static int a = 0, b = 0;
或者也可以在这两个 run() 方法中加上内存屏障!
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
shortWait(20000);
a = 1;
UnsafeFactory.getUnsafe().storeFence();
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
UnsafeFactory.getUnsafe().storeFence();
y = a;
}
});
内存屏障可以禁止重排序,通过禁止重排序的方式来保证有序性!
2. 指令重排序
什么是重排序?
JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
所以,在多线程环境下,顺序化语义得不到保证,就会出现问题!
为什么要有重排序?
JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
3. 内存屏障
【了解】JVM层面的内存屏障
有四种内存屏障!
但是,需要注意!x86处理器不会对读-读、读-写和写-写操作做重排序, 会省略掉这3种操作类型对应的内存屏障。仅会对写-读操作做重排序,所以volatile写-读操作只需要在volatile写后插入StoreLoad屏障
- LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
所以,我们重点要关注的就是这个写读屏障(StoreLoad)! 其它的基本没有什么用。
JMM内存屏障插入策略(volatile写与读问题)
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障(在x86下,只有这个有用!)
图解分析内存屏障
内存屏障,我认为,最核心的主要是有两个点——解决了有序性、可见性的问题!
在实际开发中,基本不可能去使用,UnsafeFactory.getUnsafe().storeFence();
而是,使用 volatile关键字去修饰执行写操作的变量!
【了解】volatile重排序规则
- 如果是volatile读、volatile写,那么一定不会发生重排序!
- 如果普通读/写在volatile读 前面 和 普通读/写在volatile写 后面,则会发生重排序!
硬件层内存屏障
硬件层提供了一系列的内存屏障 memory barrier / memory fence 来提供一致性的能力。拿X86平台来说,有几种主要的内存屏障:
- lfence,是一种Load Barrier 读屏障
- sfence, 是一种Store Barrier 写屏障
- mfence, 是一种全能型的屏障,具备lfence和sfence的能力
- Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
内存屏障-小结
内存屏障可以解决并发的有序性、可见性的问题! 关于内存屏障,它有两个能力:
- 阻止屏障两边的指令重排序
- 刷新处理器缓存/冲刷处理器缓存
Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。