在上一篇聊聊高并发(三十三)从一致性(Consistency)的角度理解Java内存模型 我们说了Java内存模型是一个语言级别的内存模型抽象。它屏蔽了底层硬件实现内存一致性需求的差异,提供了对上层的统一的接口来提供保证内存一致性的编程能力。
在一致性这个问题域中,各个层面扮演的角色大致例如以下:
1. 一致性模型,定义了各种一致性模型的理论基础
2. 硬件层,提供了实现某些一致性模型的硬件能力。硬件在默认情况下依照最主要的方式执行,比方
- 对同一个线程没有数据依赖的指令能够重排序优化运行,有数据依赖的指令依照程序顺序运行,从而保证单线程程序运行的正确性
- 保证读操作读到的数据肯定是之前在同一位置写入的数据
3. 语言层,少数语言提供了语言层面的满足一致性模型的编程能力,另外一些语言则直接使用硬件层提供了一致性编程的能力。提供一致性能力语言的工作方式例如以下:
- 把满足一致性需求的编程能力作为一种资源,指定一些规则,比方volitile, synchronized,Happens-before规则等
- 当应用层须要使用这样的编程能力的时候,须要显式地提出申请,比方显式地使用volatile来标识变量
- 通过编译器适配底层各种硬件平台提供了一致性编程的能力,比方有些平台使用内存屏障,有些平台使用read-modified-write。须要语言层来屏蔽这样的差异性
4. 应用层,比方分布式系统,比方并发的server程序。它们在一致性问题中的工作有
- 依据实际需求来定义应用所须要满足的一致性需求
- 定义和选择对应的实现一致性需求的算法。比方分布式存储中通过消息协议实现的Paxos,Zab。多阶段提交等
- 利用编程语言提供了主要的一致性编程的能力作为实现一致性需求算法的基础
说了一堆一致性需求相关的。那么问题来了。为什么有内存一致性的这个需求呢?
内存一致性需求的出现主要是由于多核CPU的出现,而且存在多级的快速缓存,这样就出现了对内存读写的并发问题,从而出现了内存的一致性问题。
所以快速缓存是造成内存一致性问题的一个重要原因。非常多写Java内存模型的文章笼统的说CPU写操作的时候存在一个写缓冲区write buffer。导致写操作不能及时写回到主存,造成了其它线程不能看到新写入的值,也就是所谓的可见性问题; 而且因为写缓存区是一种lazy write,导致了CPU能够在写没有刷新到内存的时候就開始兴许的读,也形成了重排序的场景。所谓的有序性的问题。
这篇文章写写CPU快速缓存相关的工作原理,来看看写缓存区究竟是个什么东西。本人不是研究硬件的,一些观点也是基于自己的理解。假设说的不正确请进一步查阅资料。
先来看一张图,这张就是Java内存模型的概念模型图。工作内存 work memory是对CPU寄存器和快速缓存的抽象。
watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvSVRlcl9aQw==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">
再来看一张图,摘自《深入理解计算机系统》中描写叙述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. 对于取指令来说。循环有好的时间和空间局部性。循环体越小。循环次数越多,局部性越好
另外来看一下存储器的体系结构
watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvSVRlcl9aQw==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">
有几个要点
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(组数)
watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvSVRlcl9aQw==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">
先看快速缓存是怎样在当前缓存中定位一个目标内存地址的缓存并读命中的。分为三步
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内存模型的起因有了更深刻认识。这些缓存的原理不仅适合快速缓存。并且适合全部的缓存系统。
參考资料:
《深入理解计算机系统》