[Java多线程编程之四] CPU缓存和内存屏障

一、CPU三级缓存

1、缓存的作用

  CPU的结构很复杂,简单地说由运算器和寄存器组成。程序运行时,需要CPU去执行运算,运算是由运算器来执行,运算器可以做加减乘除运算以及与或非逻辑运算,运算过程中可能需要临时存放数据到某个地方,寄存器就起到这个作用。


[Java多线程编程之四] CPU缓存和内存屏障_第1张图片

  虽然寄存器可以存储一些运行时数据,但是容量是很小的,程序运行时产生的大部分数据(比如Java对象)是存储在内存中的,并且程序指令也是存储在内存中,所以程序运行时CPU需要频繁操作内存,包括读取和写入,但是CPU的速度太快了,如果直接操作内存,CPU的大部分时间会处于等待内存操作的空转状态,内存完全跟不上节奏,怎么办?


[Java多线程编程之四] CPU缓存和内存屏障_第2张图片

  这时候就需要有缓存的存在了,内存将CPU要读取的数据源源不断地加载到缓存中,CPU读取缓存,缓存的速度比内存快多了,勉强能跟得上CPU大哥的节奏了!
[Java多线程编程之四] CPU缓存和内存屏障_第3张图片

  但是CPU表示缓存你还是太慢了,我带不动,所以产生了一级缓存、二级缓存、三级缓存,一级缓存最快、二级次之、三级最慢;缓存容量则反过来,一级最小,二级大一些,三级最大。
  为什么缓存能加快系统运行?举个例子,现在需要很多水,如果直接打开水龙头,要放很久,如果有水桶已经放满了水,取水是不是会快点?如果需要更多的水,我们弄个水塔,平时储满水,假如水桶的水不够用,则打开水塔,这样就达到快速取水的目的。
  缓存可以看成是一个数据的池子,由于速度越快的缓存单位存储空间的价格也越高,所以要有多级缓存,速度快的存储小,速度慢的存储大,多级缓存结合达到总体上经济又实惠的效果,在三级缓存中,每一级缓存都有80%左右的命中率,如果本级缓存中找不到CPU要的数据,则进入下一级缓存中查找,三级缓存中找不到则进入内存查找,这种可能性只有0.8%,大多数情况下可以保证了CPU快速运行,避免内存延迟。


[Java多线程编程之四] CPU缓存和内存屏障_第4张图片
CPU读取数据顺序
  • L1 Cahce(一级缓存)是CPU第一层高速缓存,分为数据缓存和指令缓存,一般服务器CPU的L1缓存的容量通常在32-4096KB。
  • L2 是由于L1高速缓存容量的限制,为了再次提高CPU的运算速度,在CPU外部放置一高速存储器,即二级缓存。
  • L3 缓存的应用可以进一步降低内存延迟,同时提升大数据量计算时处理器的性能;具有较大L3缓存的处理器可以提供更有效的文件系统缓存行为及较短信息和处理器队列长度;现在的计算机都内置了L3,并且多核计算机中多个CPU可以共享一个L3缓存,但是每个CPU都会有它自己的L1、L2。


    [Java多线程编程之四] CPU缓存和内存屏障_第5张图片
    CPU缓存设计示意图

  CPU在读取数据时,先在L1中寻找,再从L2寻找,再从L3寻找,然后是内存,最后是外存储器。

2、缓存同步协议

  对于多核计算机,多个CPU可能会读取同样的数据进行缓存,在经过不同运算之后,最终写入主内存,那么问题来了,写入的时候谁先谁后,最终写入主内存中的数据以哪个CPU为准?
  为了应对这种高速缓存回写的场景,众多CPU厂商联合制定了缓存一致性协议MESI协议,并分别实现,MESI协议规定每条缓存有个状态位,同时定义了下面四个状态:

  • 修改态(Modified)- 此cache行已被修改过(脏行),内容已不同于主存,为此cache专有;
  • 专有态(Exclusive)- 此cache行内容同于主存,但不出现于其他cache中;
  • 共享态(Shared)- 此cache行内容同于主存,但也出现于其他cache中;
  • 无效态(Invalid)- 此cache行内容无效(空行)。

  当计算机中有多个处理器时,单个CPU对缓存中数据进行了改动,需要通知给其他CPU;这意味着CPU不仅要控制自己的读写操作,还要监听其他CPU发出的通知,从而保证最终一致,所以在MESI协议下不存在 “可见性” 问题。


二、CPU缓存模型

  缓存一致性协议可以保证CPU缓存一致,但是对性能有很大的消耗,因此CPU的架构师在计算单元和L1之前又增加了 Store Buffer、Load Buffer,如下所示:


[Java多线程编程之四] CPU缓存和内存屏障_第6张图片
加了 Store Buffer 和 Load Buffer 的 CPU 缓存体系

  L1、L2、L3和主内存之间是同步的,这是通过缓存一致性协议来保证的,但是 Store Buffer、Load Buffer 和L1之间却是异步的。也就是说,往内存中写入一个变量,这个变量会保存在 Store Buffer 里面,稍后才异步写入L1中,同时同步写入主内存中。

  从操作系统内核的角度,缓存模型可以被简化为如下图所示:


[Java多线程编程之四] CPU缓存和内存屏障_第7张图片
操作系统内核视角下的 CPU 缓存模型

  多 CPU,每个 CPU 多核,每个核上面可能还有多个硬件线程,对于操作系统来讲,就相当于一个个的逻辑 CPU。每个逻辑 CPU 都有自己的缓存,这些缓存和主内存之间不是完全同步的。

  对应到 Java 里,就是 JVM 抽象内存模型,如下:

[Java多线程编程之四] CPU缓存和内存屏障_第8张图片
JVM抽象内存模型



三、重排序

3.1 分类

3.1.1 编译器重排序

  对没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。

3.1.2 CPU指令重排序

  在指令级别,让没有依赖关系的多条指令并行。

  运行时指令重排是CPU为了避免阻塞等待某些操作需要的资源,先去执行可执行的指令,当阻塞等待的资源获取到时,再去执行对应的指令的操作。

   代码示例:


[Java多线程编程之四] CPU缓存和内存屏障_第9张图片
指令重排序

  指令重排的场景:当CPU写缓存时发现缓存区块正在被其他CPU占用,为了提高CPU处理性能,可能将后面的读缓存命令优先执行。

3.1.3 CPU内存重排序

  CPU 有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致,这是造成内存可见性问题的主要原因,因为按照执行顺序,读写都是在 Load Buffer 和 Store Buffer 中完成的,缓存中的数据不会马上同步到主内存中去,因此数据同步到主内存中的顺序可能跟指令执行的顺序不一致,最终造成 “内存可见性” 问题。

3.2 as-if-serial

  不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变,编译器、runtime和处理器都必须遵循as-if-serial语义,也就是说,编译器和处理器不会对存在数据依赖关系的操作做重排序。


3.3 指令重排存在问题

  但是对多线程程序来说,指令逻辑无法分辨因果关联,因此指令重排可能会出现乱序执行,导致程序运行结果错误,因此在多线程程序中有些时候需要通类似 volatile 修饰变量之类的方式来禁止可能导致可见性问题的指令重排。


四、内存屏障

  为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier),这也正是 JMM 和 happen-before 规则的底层实现原理。

  处理器提供了两个内存屏障指令:

1、写内存屏障(Store Memory Barrier)

  在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见;当发生这种强制写入主内存的显式调用,CPU就不会处于性能优化考虑进行指令重排。

2、读内存屏障(Load Memory Barrier)

  在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据,让CPU缓存与主内存保持一致,避免缓存导致的一致性问题。

你可能感兴趣的:([Java多线程编程之四] CPU缓存和内存屏障)