CPU缓存
CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:
一次主内存的访问通常在几十到几百个时钟周期
一次L1高速缓存的读写只需要1~2个时钟周期
一次L2高速缓存的读写也只需要数十个时钟周期
这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。
基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。
按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:
一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存
二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半
三级缓存:简称L3 Cache
使用CPU缓存带来的问题
用一张图表示一下CPU–>CPU缓存–>主内存数据读取之间的关系:
当系统运行时,CPU执行计算的过程如下:
1.程序以及数据被加载到主内存
2.指令和数据被加载到CPU缓存
3.CPU执行指令,把结果写到高速缓存
4.高速缓存中的数据写回主内存
如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例
试想下面一种情况:
1.核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
2.核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
3.核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
4.核3访问该字节,由于核0并未将数据写回主存,数据不同步
为了解决这个问题,CPU制造商制定了一个规则:当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,它们的缓存将视为无效。于是,在上面的情况下,核3发现自己的缓存中数据已无效,核0将立即把自己的数据写回主存,然后核3重新读取该数据。
反汇编Java字节码,查看汇编层面对volatile关键字做了什么
有了上面的理论基础,我们可以研究volatile关键字到底是如何实现的。首先写一段简单的代码:
publicclass Singleton {
privatestaticvolatile Singleton singleton;
private Singleton() {}
publicstatic Singleton getInstance() {
if(singleton ==null) {
synchronized(Singleton.class) {
if(singleton ==null) {
singleton =new Singleton();
}
}
}
return singleton;
}
}
首先反编译一下这段代码的.class文件,看一下生成的字节码:
0 getstatic #2 <com/h3bpm/web/qk/handle/Singleton.singleton>
3 ifnonnull 37 (+34)
6 ldc #3 <com/h3bpm/web/qk/handle/Singleton>
8 dup
9 astore_0
10 monitorenter
11 getstatic #2 <com/h3bpm/web/qk/handle/Singleton.singleton>
14 ifnonnull 27 (+13)
17 new #3 <com/h3bpm/web/qk/handle/Singleton>
20 dup
21 invokespecial #4 <com/h3bpm/web/qk/handle/Singleton.<init>>
24 putstatic #2 <com/h3bpm/web/qk/handle/Singleton.singleton>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #2 <com/h3bpm/web/qk/handle/Singleton.singleton>
40 areturn
没有任何特别的。要知道,字节码指令,比如上图的getstatic、ifnonnull、new等,最终对应到操作系统的层面,都是转换为一条一条指令去执行,我们使用的PC机、应用服务器的CPU架构通常都是IA-32架构的,这种架构采用的指令集是CISC(复杂指令集),而汇编语言则是这种指令集的助记符。
因此,既然在字节码层面我们看不出什么端倪,那下面就看看将代码转换为汇编指令能看出什么端倪。
然后跑main函数,跑main函数之前,加入如下虚拟机参数:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*LazySingleton.getInstance
运行main函数即可,代码生成的汇编指令
定位到主要的两行:
0x0000000002931351: lock add dword ptr [rsp],0h ;*putstatic instance
; - org.xrq.test.design.singleton.LazySingleton::getInstance@13(line 14)
定位到这两行是因为这里结尾写明了line 14,line 14即volatile变量instance赋值的地方。后面的add dword ptr [rsp],0h都是正常的汇编语句,意思是将双字节的栈指针寄存器+0,这里的关键就是add前面的lock指令,后面详细分析一下lock指令的作用和为什么加上lock指令后就能保证volatile关键字的内存可见性。
lock指令的几个作用:
1.锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存
2.lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
3.不是内存屏障却能完成类似内存屏障的功能,阻止屏障两边的指令重排序
(1)中写了由于效率问题,实际后来的处理器都采用锁缓存来替代锁总线,这种场景下多缓存的数据一致是通过缓存一致性协议来保证的,我们来看一下什么是缓存一致性协议。
缓存一致性协议
讲缓存一致性之前,先说一下缓存行的概念:
缓存是分段(line)的,一个段对应一块存储空间,我们称之为缓存行,它是CPU缓存中可分配的最小存储单元,大小32字节、64字节、128字节不等,这与CPU架构有关,通常来说是64字节。当CPU看到一条读取内存的指令时,它会把内存地址传递给一级数据缓存,一级数据缓存会检查它是否有这个内存地址对应的缓存段,如果没有就把整个缓存段从内存(或更高一级的缓存)中加载进来。注意,这里说的是一次加载整个缓存段,这就是上面提过的局部性原理。
上面说了,LOCK#会锁总线,实际上这不现实,因为锁总线效率太低了。因此最好能做到:使用多组缓存,但是它们的行为看起来只有一组缓存那样。缓存一致性协议就是为了做到这一点而设计的,就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。
缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于”嗅探(snooping)”协议,它的基本思想是:
所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。
CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。
MESI协议是当前最主流的缓存一致性协议,在MESI协议中,每个缓存行有4个状态,可用2个bit表示,它们分别是:
这里的I、S和M状态已经有了对应的概念:失效/未载入、干净以及脏的缓存段。所以这里新的知识点只有E状态,代表独占式访问,这个状态解决了”在我们开始修改某块内存之前,我们需要告诉其它处理器”这一问题:只有当缓存行处于E或者M状态时,处理器才能去写它,也就是说只有在这两种状态下,处理器是独占这个缓存行的。当处理器想写某个缓存行时,如果它没有独占权,它必须先发送一条”我要独占权”的请求给总线,这会通知其它处理器把它们拥有的同一缓存段的拷贝失效(如果有)。只有在获得独占权后,处理器才能开始修改数据—-并且此时这个处理器知道,这个缓存行只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。
反之,如果有其它处理器想读取这个缓存行(马上能知道,因为一直在嗅探总线),独占或已修改的缓存行必须先回到”共享”状态。如果是已修改的缓存行,那么还要先把内容回写到内存中。
由lock指令回看volatile变量读写
相信有了上面对于lock的解释,volatile关键字的实现原理应该是一目了然了。首先看一张图:
工作内存Work Memory其实就是对CPU寄存器和高速缓存的抽象,或者说每个线程的工作内存也可以简单理解为CPU寄存器和高速缓存。
那么当写两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时,Thread-A写了变量i,那么:
Thread-A发出LOCK#指令
发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效
Thread-A向主存回写最新修改的i
Thread-B读取变量i,那么:
Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值
由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上
后记
为什么static volatile int i = 0; i++;不保证线程安全?
因为i++并不是一个原子操作,它包含了三步(实际上对应的机器码步骤更多,但是这里分解为三步已经足够说明问题):
1、获取i
2、i自增
3、回写i
A、B两个线程同时自增i
由于volatile可见性,因此步骤1 两条线程一定拿到的是最新的i ,也就是相同的i
但是从第2步开始就有问题了,有可能出现的场景是线程A自增了i并回写,但是线程B此时已经拿到了i,不会再去拿线程A回写的i,因此对原值进行了一次自增并回写
这就导致了线程非安全,也就是你说的多线程技术器结果不对
总而言之,volatile只能保证拿到的变量一定最新的,至于拿到的变量做了其他操作,volatile无法也没有办法保证它们的线程安全性
也许你会问,如果线程A对i进行自增了以后cpu缓存不是应该通知其他缓存,并且重新load i么?
拿的前提是读,问题是,线程A对i进行了自增,线程B已经拿到了i并不存在需要再次读取i的场景,当然是不会重新load i这个值的。
ps:也就是线程B的缓存行内容的确会失效。但是此时线程B中i的值已经运行在加法指令中,不存在需要再次从缓存行读取i的场景。