除了编译器优化等导致重排序,还有一种情况也会导致可见性问题,那就是高速缓存的存在,本节我们就来分析一下。
本节涉及的内容,例如缓存一致性协议等,在网上能找到很多介绍,但是其转移过程都非常复杂,我们重点深入浅出解释该问题。
我们知道在计算机中CPU是速度最快的,而磁盘IO等是相对比较慢的,因此为了提高CPU的处理效率会在多个层次增加缓存。首先是CPU内核就有寄存器作为缓存,另外还有数据缓存和指令缓存。这两级都是在CPU内的,而CPU和IO设备之间还有一层缓存,这就是CPU的三级缓存。如下图所示:
这时候很明显的问题就出现了,如果一个CPU修改了数据,而其他CPU并不知道,就可能使用错误的数据继续进行其他处理,那么自然结果就错了。这就是CPU的缓存导致一致性出现问题的原因。
为了解决该问题就有两种基本的方法:总线锁和缓存锁,而缓存锁主要是指缓存一致性协议。
我们先明确一下什么是总线,所谓总线就是CPU与内存、IO设备之间的公共通道,也就是上图中蓝色标记的区域。当CPU访问内存时,必须经过总线锁。
而总线锁就是在总线上声明一个Lock#信号,这个信号能保证共享内存只有当前CPU可以访问,其他的处理器请求时都会被阻塞,这样就可以保证同一个时刻只有一个处理器能访问共享内存,从而解决了不一致的问题。这么做的代码也很明显,就是CPU的利用率严重下降。
为此,处理器又推出了缓存锁机制。意思是说如果当前CPU访问的数据已经缓存在其他CPU的高速缓存中,那么CPU不会总再总线上声明Lock#信号,而是采用缓存一致性协议来保证多个CPU的缓存一致性问题。
缓存锁是通过缓存一致性协议来保证缓存一致性的,不同CPU支持的类型有所差异,目前使用最多的是MESI协议,该协议被应用在Intel奔腾系列的CPU中。
MESI是Modified、Exclusive、Shared、Invalid这四个单词的首字母。这4个字母分别代表4种状态:该协议的原理很简单,就是在MESI协议中,定义了几个不同情况下的状态,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。每个Cache line所处的状态根据本核和其它核的读写操作在4个状态间进行迁移。
状态 | 描述 |
---|---|
Modified(修改) | 这行数据有效,数据被修改了,和内存中的数据不一样,数据只存在于本cache中。 |
Exclusive(互斥) | 这行数据有效,数据和内存中的数据一致,数据只存下于本Cache中 |
Shared(共享) | 这行数据有效,数据和内存中的数据一致,数据存在于很多cache中 |
Invalid(无效) | 这行数据无效 |
我们结合图示再看一下上述几种状态是啥意思。
E状态
只有Core 0访问变量x,它的Cache line状态为E(Exclusive)。
S状态
3个Core都访问变量x,它们对应的Cache line为S(Shared)状态。
M状态和状态之间的转化
Core 0修改了x的值之后,这个Cache line变成了M(Modified)状态,其他Core对应的Cache line变成了I(Invalid)状态。
状态明确之后,那相互之间是如何迁移的呢?这个过程描述起来非常复杂,我们只看简化的情况:
假如只有两个CPU核,如图1所示,当单个CPU从主内存中读取一个数据保存到高速缓存中时,具体的流程是CPU0发出从内存中读取x变量的指令,主内存通过总线返回数据后缓存到CPU0的高速缓存中 ,并且设置该缓存状态为E。
此时如果CPU1同样发出一条针对x的读取指令,如图2所示,那么当CPU0检测到时会针对该消息做出响应,将缓存在CPU0里的x通过Read Response消息返回给CPU1,此时x分别存储在CPU0和CPU1的高速缓存中,所以x的状态被设置为S。
然后CPU0把x变量的值修改成x=30,把自己的缓存状态设置为E,接着把修改后的值写入内存,此时x的缓存行是共享状态,同时需要发送一个Invalidate消息给其他缓存,CPU1收到该消息之后,把高速缓存中的x设置为Invalid,最终得到如下的结构:
也许这里你会有个疑问,图3中Cache的状态应该是E还是M呢?我的理解是如果将x=30同步到内存之前就是M,同步到内存之后就是E。
根据上面的描述,我们可以看到,CPU的高速缓存导致了缓存一致性的问题,为此,CPU层面提供了总线锁和缓存锁的机制。基本思想是通过LOCK#信号触发总线锁和缓存锁,如果不支持总线锁。则会使用缓存一致性协议来保证缓存一致性协议。其目标都是为了保证同一时刻只允许一个CPU堆共享内存进行读写操作。
总结
说了这么多,那volatile到底做了什么呢?其实就是JVM看到某个变量有volatile之后,调了一下底层的Lock#指令而已。其他的都交给CPU和总线等来处理了。
说了这么多,与volatile有啥关系呢?Volatile一个关键字将上面的内容全给实现了,或者调用底层的服务来实现了。我们可以看一下,当我们对一个变量加了volatile关键字之后在不同的层次会有什么变化,具体包括:
代码层面: volatile关键字
字节码层面:ACC_VOLATILE字段访问标识符
JVM层面:JMM要求实现为内存屏障。
(Hospot)系统底层: 读volatile基于c++的volatile关键字,每次从主存中读取。 写volatile基于c++的volatile关键字和 lock 指令的内存屏障,每次将新值刷新到主存,同时其他cpu缓存的值失效。 C++的volatile禁止对这个变量相关的代码进行乱序优化(重排序),也就具有内存屏障的作用了。
Linux内核也可以手动插入内存屏障:_ asm _ _ volatile _ ( " " : : : "memory" )。这样就控制CPU的执行按照的执行了。
拓展
这里补充一个概念,上面图中我们用的是缓存行而不是缓存,而且这里会引申出另外一个问题——伪共享问题,感兴趣的同学可以研究一下。