聊聊高并发(三十四)Java内存模型那些事(二)理解CPU高速缓存的工作原理

在上一篇聊聊高并发(三十三)从一致性(Consistency)的角度理解Java内存模型 我们说了Java内存模型是一个语言级别的内存模型抽象,它屏蔽了底层硬件实现内存一致性需求的差异,提供了对上层的统一的接口来提供保证内存一致性的编程能力。

在一致性这个问题域中,各个层面扮演的角色大致如下:

1. 一致性模型,定义了各种一致性模型的理论基础

2. 硬件层,提供了实现某些一致性模型的硬件能力。硬件在默认情况下按照最基本的方式运行,比如

  • 对同一个线程没有数据依赖的指令可以重排序优化执行,有数据依赖的指令按照程序顺序执行,从而保证单线程程序运行的正确性
  • 保证读操作读到的数据肯定是之前在同一位置写入的数据

3. 语言层,少数语言提供了语言层面的满足一致性模型的编程能力,另外一些语言则直接使用硬件层提供了一致性编程的能力。提供一致性能力语言的工作方式如下:

  • 把满足一致性需求的编程能力作为一种资源,指定一些规则,比如volitile, synchronized,Happens-before规则等
  • 当应用层需要使用这种编程能力的时候,需要显式地提出申请,比如显式地使用volatile来标识变量
  • 通过编译器适配底层各种硬件平台提供了一致性编程的能力,比如有些平台使用内存屏障,有些平台使用read-modified-write,需要语言层来屏蔽这种差异性


4. 应用层,比如分布式系统,比如并发的服务器程序,它们在一致性问题中的工作有

  • 根据实际需求来定义应用所需要满足的一致性需求
  • 定义和选择相应的实现一致性需求的算法,比如分布式存储中通过消息协议实现的Paxos,Zab,多阶段提交等
  • 利用编程语言提供了基本的一致性编程的能力作为实现一致性需求算法的基础


说了一堆一致性需求相关的,那么问题来了,为什么有内存一致性的这个需求呢?


内存一致性需求的出现主要是因为多核CPU的出现,并且存在多级的高速缓存,这样就出现了对内存读写的并发问题,从而出现了内存的一致性问题。

所以高速缓存是造成内存一致性问题的一个重要原因。很多写Java内存模型的文章笼统的说CPU写操作的时候存在一个写缓冲区write buffer,导致写操作不能及时写回到主存,造成了其他线程不能看到新写入的值,也就是所谓的可见性问题; 并且由于写缓存区是一种lazy write,导致了CPU可以在写没有刷新到内存的时候就开始后续的读,也形成了重排序的场景,所谓的有序性的问题。


这篇文章写写CPU高速缓存相关的工作原理,来看看写缓存区到底是个什么东西。本人不是研究硬件的,一些观点也是基于自己的理解,如果说的不对请进一步查阅资料。

先来看一张图,这张就是Java内存模型的概念模型图,工作内存 work memory是对CPU寄存器和高速缓存的抽象。

再来看一张图,摘自《深入理解计算机系统》中描述Intel Core i7处理器的高速缓存的概念模型。



对比这两张图,我们可以看到Java内存模型中每个线程的工作内存实际上就是寄存器以及高速缓存的抽象。在目前主流的多核处理器设计中,一般每个核心都会包含1个L1缓存和L2缓存,多个核心共享一个L3高速缓存。各个核心直接通过系统总线连接。系统总线包括数据总线,地址总线,控制总线,统称系统总线。我们要记住的是总线是一种共享的资源,如果不合理的使用,比如聊聊高并发(五)理解缓存一致性协议以及对并发编程的影响 这篇中说的缓存一致性协议导致的总线流量风暴,会影响程序执行的效率。

这张图说了各级高速缓存的一些参数,有几个要点:

1. CPU只直接和寄存器已经L1缓存交互

2. 现代的L1缓存分为两个单独的物理块:

  • i-cache存储指令,是只读的,
  • d-cache存储数据,是读写的

3. L2和L3缓存存储指令和数据

4. 注意高速缓存的大小,Core i7的L1缓存大小为64KB, L2缓存是256KB,L3是8MB

5. 缓存是分块,分组的

6. L1的访问周期是4, L2是L1的3倍,L3是L2的3倍

7. 一次内存访问的时钟周期是L3的3倍左右,和L1差2个数量级

8. 一次硬盘(普通磁盘)访问的时间在1-10ms级别,和一次内存访问差4个数量级,和1次高速缓存访问差6个数量级以上

9. 一次固态硬盘访问的时间在10-100微秒级别,比普通硬盘快1到2个数量级,和一次内存访问差2-3个数量级左右


说到高速缓存就不得不说到计算机领域的局部性原理(Principle of Locality)。局部性原理是缓存技术的底层理论基础。局部性包括两种形式:

1. 时间局部性,一个具有良好时间局部性的程序中,被引用过一次的存储器位置很可能在不远的将来再被多次引用

2. 空间局部性,一个具有良好空间局部性的程序中,如果一个存储器位置被引用了一次,那么程序很可能在不远的将来引用附近的一个存储器位置

我们知道64位机器一次内存数据读取64位,也就是8个字节,8个连续的内存位置,所以高速缓存中存放的也是连续位置的数据,这是局部性的体现


局部性对编程的一些指导:

1. 重复引用同一个变量具有良好的时间局部性

2. 对于具有步长为k的引用模式的程序,步长越短空间局部性越好。尤其是操作数组,多维数组,局部性的影响很大

3. 对于取指令来说,循环有好的时间和空间局部性,循环体越小,循环次数越多,局部性越好


另外来看一下存储器的体系结构

有几个要点

1. 越往上存储容量越小,存取速度越快,成本越高,反之亦然

2. 一层存储器只和下层存储器打交道,不会跨级访问

3. 下层作为上层的一个缓存。CPU要访问的数据的最终一般都经过主存,主存作为下层其他设备的一个缓存,其他设备的数据最终都要进入主存才能被CPU访问到。比如磁盘文件读取操作,CPU只发起操作请求,具体的数据操作不需要经过CPU,由DMA(Direct Memory Access)来操作IO和主存的交互,当操作完成后,IO设备发出中断,通知CPU操作完成

4. 每层缓存都需要一个管理器来管理缓存,比如将缓存划分为块,在不同层中传送块,判定命中不命中。管理器可以是硬件,软件或两者的集合。比如高速缓存完全由内置在缓存中的硬件来管理


下面正式进入高速缓存工作原理的主题,先看一下高速缓存的基本结构

1. 划分为S个缓存组 cache set

2. 每组里面有E个缓存行 cache line,也叫Cache线,行数E也叫缓存的相联度

3. 每行里面1个有效位来标记该缓存行是否dirty,有t个长度的标记位来辅助缓存地址定位,标识该缓存块的唯一位置,有一个B个字节的缓存块block。一行只有一个块

4. 高速缓存的大小C = B * E * S,只计算有效的字节数,不包括有效位及标记位的大小

4. 一个高速缓存可以用一个四元组来表示(S, E, B, m),m表示计算机的位数。拿Core i7的L1缓存来说,S = 64, E = 8, B = 64, m = 64,可以表示为(64,8,64,64).

可以看到L1的大小32K = 64个字节(块大小) * 8(行数) * 64(组数)




先看高速缓存是如何在当前缓存中定位一个目标内存地址的缓存并读命中的,分为三步

1. 组选择

2. 行匹配

3. 字抽取


这个定位的过程有点类似哈希操作,把一个m位的内存地址映射到一个高速缓存的组索引(s位),行(t位),块偏移(b位)中去。



还拿Core i7的L1缓存(64,8,64,64)来说,拿到一个64位的内存地址

1. 组选择:有64个组,那么64位的内存地址中就要拿出s=6位(000000-111111)来表示64个组号,根据这个内存地址的s位定位到一个组

2. 行匹配:每个组有8行,大小为64B的块得到的b=6, 计算得到t = m - (b+s) = 64 - 12 = 52,也就是说64位地址的高52位作为t,用这个t标记去这个组的8个行去匹配对应t标记位,如果有匹配的行,就命中,否则不命中

3. 如果命中,再由这个内存地址的低b位计算出这个地址在块中的偏移位置。块可以理解为一个字节数组,64个字节的块就有块[0]....块[63]个偏移量,有内存地址的低b位可以计算得到这个地址对应的偏移量,从而找到这个数


比如对于一个32个元素的int数组int[32]来说,int[0] - int[15]存放到高速缓存组[0]的第0行,一个块是64个字节,正好可以存储16个int数据。int[16] - int[31]存放到高速缓存组[0]的第1行。当访问int[0]的时候,没有命中,会从下一层存储器加载0行的缓存块,这样int[0]-int[15]都加载到缓存块中了,下一次访问int[1] - int[15]的时候都命中。访问到Int[16]的时候没有命中,同样从下一层存储中加载int[16] - int[31]到第1行,这样下次访问int[16] - int[31]时就都命中


高速缓存有直接映射高速缓存,E路相联高速缓存,全相联高速缓存之分,区别是直接相联高速缓存每一组只有1行,所以只要定位到组就能知道是否命中。全相联高速缓存则相反,只有1组,只要匹配到t位的标记位就知道是否命中。

E路相联高速缓存则是折中,比如Core i7的L1 d-cache就是8路相联高速缓存,每组有8行,这样定位到组之后,还需要在组的8个行里面去匹配标记位来判断是否命中。


缓存的常用术语命中hit表示在当前缓存中定位到了目标地址的缓存,不命中表示在当前缓存中没有找到目标地址的缓存。

结合读写动作,所以有4个状态

1. 读命中

2. 读不命中

3. 写命中

4. 写不命中


知道了如何把一个内存地址映射到高速缓存块中之后,我们来分析这4种情况各自的表现

读命中

最简单的情况,按照组选择,行匹配,数据抽取的步骤返回命中的数据


读不命中

读不命中的话就需要从下一层存储去加载对应的数据项来对应的缓存行中,注意加载的时候是整个缓存块都会被新的缓存块所代替。替换的时候比较复杂,要判断替换掉哪个缓存行。最常用的作法是使用LRU(least recently used)算法,最近最少使用算法,替换最后一次访问时间最久远的那一行。然后返回加载后找到的数据


关于写,情况就更复杂,这也是常说的CPU lazy write的原因。CPU写高速缓存有两种方式

1. 直写 write-through, 这种方式会写高速缓存和内存

2. 写回,也有叫回写的,write-back,这种方式只写高速缓存,将相应的缓存行标记为脏dirty,我们前面说了每个缓存行有一个有效位,0表示dirty/空, 1表示有效。只有当这个脏的缓存行要被替换掉时,才会写到内存中去


写命中的情况下,由于write-through要写高速缓存和内存,每次写都会造成总线流量。write-back只写高速缓存,不产生总线流量

写不命中的情况下,有两种方法:写分配 write-allocate 和非写分配 not-write-allocate。写分配会从下一层存储加载相应的块到高速缓存,然后更新这个缓存块。非写分配会直接避开高速缓存,直接写到主存。一般都是write-back使用write-allocate的方式,write-through使用not-write-allocate的方式。


我们比较一下write-through和write-back的特点

write-through: 每次写都会写内存,造成总线流量,性能较差,优点是实时性强,不会因为断电丢失数据

write-back: 充分利用局部性原理,脏的缓存线也能被后面的读立刻读取,性能较高。缺点是实时性不高,出现故障可能会丢失数据


目前基本上CPU的写缓存都采用write-back的方式,不过可以通过BIOS或者操作系统内核参数来配置CPU采取哪种写的方式。


下面这两张来自wiki的图说清了write-through和write-back的流程




那么别人经常提到的写缓冲区write-buffer到底是个什么东西呢,write-buffer被write-through时使用,用来缓存写回到主内存的数据,我们知道写一次内存要100ns左右,CPU不会等待写直到写入内存才继续执行后续指令,它是把要写到主存的数据放到write-buffer,然后就执行后面的指令了,可以理解为一种异步的方式,来优化write-through的性能。如果write buffer满了,那么后续的写要等待write buffer中有空位置才能继续写。

理解下缓冲区的概念,缓冲区是用来适配两个流速不同的组件常用的方式,比如IO中的BufferedWriter,生产者-消费者模式的缓冲队列等等,它可以很好地提高系统的性能。


可以看到,不管是write-through,还是write-back,由于高速缓存和写缓冲区的存在,它们都造成了lazy write的现象,写不是马上就写回到主内存,从而造成了数据可见性和有序性的问题,所以需要定义内存模型来提供一些手段来保证一些一致性需求,比如通过使用内存屏障强制把高速缓存/写缓冲区中的数据写回到内存,或者强制把高速缓存中的数据刷新,来保证数据的可见性和有序性。


这篇分析了高速缓存的原理,应该能对Java内存模型的起因有了更深刻认识。这些缓存的原理不仅适合高速缓存,而且适合所有的缓存系统,


参考资料:

《深入理解计算机系统》

Cache

Write Buffer

Writing into Cache


阅读更多
所属专栏: 聊聊高并发
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭