我们说了Java内存模型是一个语言级别的内存模型抽象,它屏蔽了底层硬件实现内存一致性需求的差异,提供了对上层的统一的接口来提供保证内存一致性的编程能力。
在一致性这个问题域中,各个层面扮演的角色大致如下:
说了一堆一致性需求相关的,那么问题来了,为什么有内存一致性的这个需求呢?
内存一致性需求的出现主要是因为多核CPU的出现,并且存在多级的高速缓存,这样就出现了对内存读写的并发问题,从而出现了内存的一致性问题。
所以高速缓存是造成内存一致性问题的一个重要原因。很多写Java内存模型的文章笼统的说CPU写操作的时候存在一个写缓冲区write buffer,导致写操作不能及时写回到主存,造成了其他线程不能看到新写入的值,也就是所谓的可见性问题; 并且由于写缓存区是一种lazy write,导致了CPU可以在写没有刷新到内存的时候就开始后续的读,也形成了重排序的场景,所谓的有序性的问题。
这篇文章写写CPU高速缓存相关的工作原理,来看看写缓存区到底是个什么东西。本人不是研究硬件的,一些观点也是基于自己的理解,如果说的不对请进一步查阅资料。
先来看一张图,这张就是Java内存模型的概念模型图,工作内存 work memory是对CPU寄存器和高速缓存的抽象。
再来看一张图,摘自《深入理解计算机系统》中描述Intel Core i7处理器的高速缓存的概念模型。
对比这两张图,我们可以看到Java内存模型中每个线程的工作内存实际上就是寄存器以及高速缓存的抽象。在目前主流的多核处理器设计中,一般每个核心都会包含1个L1缓存和L2缓存,多个核心共享一个L3高速缓存。各个核心直接通过系统总线连接。系统总线包括数据总线,地址总线,控制总线,统称系统总线。我们要记住的是总线是一种共享的资源,如果不合理的使用,比如存一致性协议导致的总线流量风暴,会影响程序执行的效率。
这张图说了各级高速缓存的一些参数,有几个要点:
说到高速缓存就不得不说到计算机领域的局部性原理(Principle of Locality)。局部性原理是缓存技术的底层理论基础。局部性包括两种形式:
我们知道64位机器一次内存数据读取64位,也就是8个字节,8个连续的内存位置,所以高速缓存中存放的也是连续位置的数据,这是局部性的体现
局部性对编程的一些指导:
下面正式进入高速缓存工作原理的主题,先看一下高速缓存的基本结构
可以看到L1的大小32K = 64个字节(块大小) * 8(行数) * 64(组数)
先看高速缓存是如何在当前缓存中定位一个目标内存地址的缓存并读命中的,分为三步
这个定位的过程有点类似哈希操作,把一个m位的内存地址映射到一个高速缓存的组索引(s位),行(t位),块偏移(b位)中去。
还拿Core i7的L1缓存(64,8,64,64)来说,拿到一个64位的内存地址
比如对于一个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]
高速缓存有直接映射高速缓存,E路相联高速缓存,全相联高速缓存之分,区别是直接相联高速缓存每一组只有1行,所以只要定位到组就能知道是否命中。全相联高速缓存则相反,只有1组,只要匹配到t位的标记位就知道是否命中。
E路相联高速缓存则是折中,比如Core i7的L1 d-cache就是8路相联高速缓存,每组有8行,这样定位到组之后,还需要在组的8个行里面去匹配标记位来判断是否命中。
缓存的常用术语命中hit表示在当前缓存中定位到了目标地址的缓存,不命中表示在当前缓存中没有找到目标地址的缓存。
结合读写动作,所以有4个状态
知道了如何把一个内存地址映射到高速缓存块中之后,我们来分析这4种情况各自的表现
读命中
最简单的情况,按照组选择,行匹配,数据抽取的步骤返回命中的数据
读不命中
读不命中的话就需要从下一层存储去加载对应的数据项来对应的缓存行中,注意加载的时候是整个缓存块都会被新的缓存块所代替。替换的时候比较复杂,要判断替换掉哪个缓存行。最常用的作法是使用LRU(least recently used)算法,最近最少使用算法,替换最后一次访问时间最久远的那一行。然后返回加载后找到的数据
关于写,情况就更复杂,这也是常说的CPU lazy write的原因。CPU写高速缓存有两种方式
在写命中的情况下,由于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的现象,写不是马上就写回到主内存,从而造成了数据可见性和有序性的问题,所以需要定义内存模型来提供一些手段来保证一些一致性需求,比如通过使用内存屏障强制把高速缓存/写缓冲区中的数据写回到内存,或者强制把高速缓存中的数据刷新,来保证数据的可见性和有序性。
原文链接:https://blog.csdn.net/ITer_ZC/article/details/41979189