故事还得从一个矛盾说起。
摩尔定律告诉我们:大约每18个月会将芯片的性能提高一倍。芯片的这种飞速发展直接导致了芯片的指令执行速度与内存读取速度之间的巨大鸿沟。
举个例子,CPU在1纳秒之内可以执行几十条指令,但是从内存中读取一条数据就需要花费几十纳秒。这种数量级的差异便是计算机中的一个主要矛盾:
CPU日益增长的对数据快速读取的需要和I/O设备读取速度不平衡不充分的发展之间的矛盾
而CPU运行所需要的指令和数据都存储在低速的内存中,人们无法容忍让CPU这样宝贵的高速设备进行漫长的等待。
计算机科学领域的任何问题都可以通过增加一个中间层来解决。所以需要一个比内存更快的存取设备做缓冲, 尽量 做到和CPU一样快,这样就不需要每次都从低速的内存中获取数据了。
于是引入了高速缓存。
1. 高速缓存
我们已经知道为什么需要高速缓存了。那么什么是高速缓存?它为什么就比内存快?既然这么快,为什么不直接当成内存用?
别急,我一点点解释。
1.1. 什么是高速缓存Cache
我们最熟悉的内存是一种 动态随机访问存储器(Dynamic RAM,DRAM) ,存储器中每个存储单元由配对出现的晶体管和电容器构成,每隔一段时间,固定要对 DRAM 刷新充电一次,否则内部的数据就会消失。
而高速缓存是一种 静态随机访问存储器(Static RAM,SRAM) ,不需要刷新电路就能保存它内部存储的数据,这就是静态的含义,因此 SRAM 的存储性能非常高!工作速度在纳秒级别,勉强能跟得上CPU的运算速度。
但是 SRAM 的缺点就是集成度低,相同容量的内存可以设计成较小的体积,但是 SRAM 却需要更大的体积;而且, SRAM 这玩意儿巨贵!这就是不能直接把它当内存用的原因。
越靠近CPU核心地带的设备越需要强悍的性能,可是容量如果太小又帮不上太大的忙。如果一个中间层(一层高速缓存)不能高效解决问题,那就多来几个中间层。目前CPU的解决思路一般是以量取胜,比如同时设置 L1
、 L2
、 L3
三级缓存。
在缓存容量上,通常是 内存 > L3 > L2 > L1
,容量越小速度越快。其中 L1
和 L2
是由每个CPU核心独享的, L3
缓存是由所有CPU核心共享的。CPU的架构见下图:
需要特别说明的是, L1
缓存又分为了 L1d
数据缓存(L1 Data)和 L1i
指令缓存(L1 Instruct),上图为了完整性一并画出了,本文中的高速缓存一律指数据缓存。
为了接下来方便讲解,我们把三级缓存模型简化为一级缓存模型,毕竟道理都是相通的嘛。看一下简化之后的图。
1.2. 缓存行
说完了什么是Cache,接下来我们来看看Cache里装的到底是什么?
这不是废话嘛,肯定装的是数据啊。没错,是从内存中获取到的数据,但是数据的单位呢?CPU每次只把需要的数据从内存中读取到Cache就行了吗?肯定不是,我们想一下,只把需要的一个数据从内存中读到Cache,CPU再从Cache中继续读这个数据进行处理,Cache的存在完全就是多此一举,还不如直接从内存读数据呢。
所以要想让Cache充分发挥作用,必须让它做点“多余”的事情。因此从内存中获取数据的时候,我们把包含目标数据的一整块内存数据都放入Cache中。别小看这个动作,它有个科学的解释,叫做 空间局部性 。
位置相邻的数据常常会在相近的时间内被访问
根据 空间局部性 原理,如果目标数据相邻的数据被访问,CPU就不需要再从内存中获取了,这种直接从Cache中获取到目标数据的行为叫做“ 缓存命中 ”,极大地提高了CPU的工作效率。如果Cache里边没有,就称为 Cache Miss ,CPU需要再等待几十个指令周期从内存中把这一整块内存数据读入Cache。
给存储“一整块内存数据”的地方起个名字,叫「 缓存行 」( Cache Line )。
Cache是由缓存行组成的,缓存行是CPU高速缓存和内存交互的最小单元。在X86架构中,缓存行的大小是64个字节,大小和CPU具体型号有关。本文只关注缓存行的抽象概念,不涉及具体的缓存行大小。
接下来,终于要进入本文的正式部分了。
我一直认为,计算机的演进就是一部在挖坑和填坑之间反复横跳的发展史。对这一点的理解会随着本文的后续讲述逐渐加深。比如高速缓存Cache很好地解决了CPU与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,我来举个例子。
2. 伪共享问题
我们到目前为止说的都是CPU从Cache中read数据,但是总得有write的时候吧。既然有了Cache,肯定就得先把值write到Cache中,再更新到内存里啊。那么,问题来了。
2.1. 什么是伪共享
数据 X
、 Y
、 Z
同处于一个缓存行内, Core0
和 Core1
同时加载了该缓存行到Cache中,此时 Core0
修改了该缓存行中的 X
为 X1
,如果此时 Core1
也想修改 Y
为 Y1
该怎么办呢?
由于缓存行是Cache和内存之间交互的最小单元,所以 Core0
根本不知道 Core1
修改的是缓存中的 Y
还是 X
,所以为了防止造成并发问题,最好的办法就是让 Core1
中的该缓存行失效,重新加载。这就是 伪共享 问题。
伪共享问题的定义: 当多核心修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享 。
2.2. 解决伪共享
既然问题是由多个变量共享一个缓存行导致的,那就让 Y
变量独享一个缓存行就好了。
最简单的方法就是通过代码手动进行字节填充,拿早期的 LinkedTransferQueue
中的部分源码举个例子,注意看注释内容:
static final class PaddedAtomicReference<T> extends AtomicReference<T> { // 追加15个对象引用,一个对象引用占据4个字节 // 加上继承自父类的value,共64字节,正好占一个缓存行 Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe; PaddedAtomicReference(T r) { super(r); } } //父类 public class AtomicReference<V> implements java.io.Serializable { private volatile V value; public AtomicReference(V initialValue) { value = initialValue; } }
此外,JDK 8开始,提供了一个 sun.misc.Contended
注解来解决伪共享问题,加上这个注解的类会自动补齐缓存行。
稍微扯远了一些,我们回到上方的动图。 Core0
修改了缓存行中的 X
,我们说当前最合适的处理办法就是让 Core1
中的缓存行失效,否则就会出现缓存一致性问题。伪共享问题其实就是解决缓存一致性问题的副作用。只不过本文中我单独把这个问题列了出来。
为了解决缓存一致性问题,CPU天然支持了总线锁的功能。
3. 总线锁
顾名思义就是,锁住Bus总线。通过处理器发出 lock
指令,总线接受到指令后,其他处理器的请求就会被阻塞,直到此处理器执行完成。这样,处理器就可以独占共享内存的使用。
但是,总线锁有一个非常大的缺点,一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,多处理器的优势就无法发挥。
于是,经过发展、优化,又产生了缓存锁。
4. 缓存锁
缓存锁:不需锁定总线,维护本处理器内部缓存和其他处理器缓存的一致性。相比总线锁,会提高cpu利用率。
但是缓存锁也不是万能,有些场景和情况依然必须通过总线锁才能完成。
缓存锁其实是一种实现的效果,它是通过 缓存一致性协议 来实现的,可能有的读者也听说过 Snoopy嗅探协议
,我举个例子帮助大家理解这三个概念。
假如村里有一个单人公厕,一条蜿蜒大道与公厕相连,大道旁边住着A、B、C、D四个人,每个人要上厕所必须经过主干道。
我们再设置一点前提,假设每个人都不想到了厕所门口的时候才知道厕所已经被人占用了。
为了合理使用厕所,保证每次只有一个人进入厕所,并且不会出现其他人在厕所门口等待的情况,ABCD四个人聚在一起开会讨论,协商出了一条约定。
当有人去上厕所的时候,其他人在家老实呆着,不要去上厕所!
四个人纷纷拍着自己大腿叫绝。他们商议出来了一个听起来确实能解决问题,