t【并发】深入理解JMM&并发三大特性(二)
我们在上一篇文章中提到了JMM内存模型,并发的三大特性,其中对可见性做了详细的讲解!
这一篇文章,将会站在硬件层面继续深入讲解并发的相关问题!
【并发】深入理解JMM&并发三大特性(一)_面向架构编程的博客-CSDN博客https://blog.csdn.net/weixin_43715214/article/details/127895883
一、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是通过什么方式、什么机制来保证缓存一致的???
三、缓存一致性问题与Bus snooping机制(总线窥探)详解
10 张图打开 CPU 缓存一致性的大门 - 小林coding - 博客园 (cnblogs.com)https://www.cnblogs.com/xiaolincoding/p/13886559.html
1. 缓存一致性(Cache coherence)
计算机体系结构中,缓存一致性是共享资源数据的一致性,这些数据最终存储在多个本地缓存中。当系统中的客户机维护公共内存资源的缓存时,可能会出现数据不一致的问题,这在多处理系统中的CPU中尤其如此。
在共享内存多处理器系统中,每个处理器都有一个单独的缓存内存,共享数据可能有多个副本:一个副本在主内存中,一个副本在请求它的每个处理器的本地缓存中。当数据的一个副本发生更改时,其他副本必须反映该更改。
缓存一致性是确保共享操作数(数据)值的变化能够及时地在整个系统中传播的机制。
缓存一致性的要求(前提)
(1)写传播(Write Propagation)
某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache。
(2)事务串行化(Transaction Serialization)
某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的。
如何保障一致性?
确保一致性的两种最常见的机制是窥探机制(snooping )和基于目录的机制(directory-based)。如果处理器的核心数大于64,那么一般采用目录机制,否则都是窥探机制。
2. 总线窥探(Bus Snooping)
原理
当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。数据变更的通知可以通过总线窥探来完成。
所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本。
如果缓存中有共享块的副本,则相应的窥探者会执行“一个动作”以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效。它还涉及到缓存块状态的改变,这取决于缓存一致性协议(cache coherence protocol)。
窥探协议类型
根据管理写操作的本地副本的方式,有两种窥探协议:
(1)Write-invalidate 写失效协议
当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型。
(2)Write-update 写更新协议(有性能问题)
当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。
MESI协议
MESI协议是一个基于写失效的缓存一致性协议,是支持回写(write-back)缓存的最常用协议。也称作伊利诺伊协议 (Illinois protocol,因为是在伊利诺伊大学厄巴纳-香槟分校被发明的)。与写通过(write through)缓存相比,回写缓冲能节约大量带宽。总是有“脏”(dirty)状态表示缓存中的数据与主存中不同。MESI协议要求在缓存不命中(miss)且数据块在另一个缓存时,允许缓存到缓存的数据复制。与MSI协议相比,MESI协议减少了主存的事务数量。这极大改善了性能。
它有4中状态:
已修改Modified (M)
缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).
独占Exclusive (E)
缓存行只在当前缓存中,但是干净的--缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。
共享Shared (S)
缓存行也存在于其它缓存中且是未修改的。缓存行可以在任意时刻抛弃。
无效Invalid (I)
缓存行是无效的