volatile关键字在c、java中都有,用于修饰变量,例如
private volatile int i;
它在多处理器开发环境中保证了共享变量的“可见性”,即当一个线程修改一个共享变量时,另外一个变量能读到这个最新修改的值,那为什么没有被volatile修饰的共享变量存在“可见性”不成立的情况呢?这和计算机的高速缓存有关。
共享变量“可见性”不成立出现的原因
我们都知道计算机中CPU存取的速度很快,而大量数据主要保存在外存中,也就是硬盘或U盘等,外存存储空间大,现在一般都是1T左右,但硬盘I/O速度非常慢,没办法跟上CPU的运行速度,为了适配CPU的速度,让CPU充分发挥性能,现代的计算机在外存与CPU之间加了两层,从CPU出发,依次是高速缓存(cache)、主存,cache位于CPU与主存之间(有些Cache集在CPU芯片之中),用来存放当前运行的程序和数据,它的内容是主存某些局部区域(页)的复制品。它用快速的半导体RAM构成,采取随机存取方式,存储容量较小而速度最快,现在的计算机大多采用三级缓存,容量L1、L2、L3依次递增。主存储器用来存放需CPU运行的程序和数据。用半导体RAM构成,常包含少部分ROM。可由CPU直接编程访问,采取随机存取方式,主存储器就是我们常说的内存,对应的实体就是内存条。整体结构如下图所示
(图片来自:https://www.cnblogs.com/xxp17457741/p/6618293.html):
我们都知道,程序只有调入内存才能去执行,在一个程序中,线程之间共享的地址空间也是在内存中,而不是高速缓存中,高速缓存,顾名思义,用来做缓存,当CPU想要读写数据时,先将系统内存的数据读到高速缓存中,再进行操作,但操作完不知道何时写回内存,问题就出在了这里,线程之间共享的地址空间在内存中,而某个线程将共享变量放到高速缓存中进行修改,修改完之后可能没有及时写回内存,那么其他线程就无法读取到最新的值,也就是没有保证线程间共享变量的“可见性”,而volatile关键字就是为了解决这个问题。
volatile保证“可见性”的原理
volatile的底层实现涉及到CPU层面的一些操作,先罗列一些CPU术语的定义
术语 | 英文单词 | 术语描述 |
---|---|---|
内存屏障 | memory barriers | 是一组处理器指令,用于实现对内存操作的顺序限制 |
缓冲行 | cache line | CPU高速缓冲中可以分配的最下存储单位,CPU在填写缓存行时,会加载整个缓存行 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取的数据是可缓存的,处理器读取整个高速缓存行到适当 的缓存(L1、L2或其他) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置任然是下次处理器访问的地址时,处理器从 缓存中读取操作数,而不是从内存中读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,他会首先检查这个缓存的内存地址 是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写到缓存中, 而不是写回到内存中,称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
(关于缓存行可以参看https://blog.youkuaiyun.com/aigoogle/article/details/41517213)
先编写两个类,除了变量一个有volatile修饰,一个没有外,其他都一样,这里的代码要写的复杂一点,不然JIT日志里面没东西
public class VolatileTest {
public volatile Integer sum = new Integer(0);
public void add() {
sum += 1;
}
public static void main(String[] args) {
VolatileTest volatileTest = new VolatileTest();
for (int i = 0; i < 1000000; i++) {
volatileTest.add();
}
}
}
public class WithoutVolatileTest {
public Integer sum = new Integer(0);
public void add() {
sum += 1;
}
public static void main(String[] args) {
WithoutVolatileTest withoutVolatileTest = new WithoutVolatileTest();
for (int i = 0; i < 1000000; i++) {
withoutVolatileTest.add();
}
}
}
查看下他们对应的汇编代码,查看方式可参考博客如何在windows平台下使用hsdis与jitwatch查看JIT后的汇编码,一般网上给的方式都是在%JAVA_HOME/jre/bin/server%下添加hsdis-amd64.dll,再用命令行参数来查看
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly VolatileTest
其中VolatileTest对应的是用javac编译VolatileTest.java形成的class文件,用这种方式,上面的三四行代码变成了十几页,很难找到对应,所以还是用jitwatch工具查看JIT汇编代码,找到与变量sum相关的部分结果如下:
在VolatileTest中:
0x0000000005158971: lock addl $0x0,(%rsp) ;*putfield sum
; - simple_demo.VolatileTest::add@13 (line 7)
; - simple_demo.VolatileTest::main@14 (line 15)
0x0000000005158986: mov 0xc(%r11),%r11d ;*getfield sum
; - simple_demo.VolatileTest::add@2 (line 7)
; - simple_demo.VolatileTest::main@14 (line 15)
在WithoutVolatileTest中:
0x0000000005486db9: movb $0x0,(%rdx,%rcx,1) ;*putfield sum
; - simple_demo.WithoutVolatileTest::<init>@13 (line 4)
; - simple_demo.WithoutVolatileTest::main@4 (line 11)
0x0000000005486e31: je L001a
L0009: mov 0xc(%rbx),%edx ;*getfield sum
; - simple_demo.WithoutVolatileTest::add@2 (line 7)
; - simple_demo.WithoutVolatileTest::main@14 (line 15)
分号后面对应的是java字节码,putfield和getfield分别是写入和读取(猜的),对比在两个类在JIT中生成的汇编代码,可以看到有volatile修饰的变量sum,在写操作中,汇编代码用Lock add1声明,在读取操作操作中是没有的,接下来就看一下Lock指令前缀的作用。
官方解释:LOCK指令前缀会设置处理器的LOCK#信号(译注:这个信号会使总线锁定,阻止其他处理器接管总线访问内存),直到使用LOCK前缀的指令执行结束,这会使这条指令的执行变为原子操作。在多处理器环境下,设置LOCK#信号能保证某个处理器对共享内存的独占使用。(译自intel 64-ia-32-architectures-software-developer-instruction-set-reference-manual)
所以有Lock指令前缀修饰的指令,在操作系统层面,能保证其原子性,即使是多核处理器,也可以通过锁住总线来达到防止其他处理器的修改,展开来讲,在多核处理器中引发以下两件事:
(1)在其他处理器访问该数据之前,将当前处理器修改后的结果同步到内存中
Lock前缀指令导致在执行指令期间,声言处理器的LOCK#指令。在多处理器环境中,LOCK#信号确保在声言该信号期间,当前处理器可以锁住总线,达到禁止其他处理器访问总线的目的,也就是禁止其他处理器访问系统内存,然后将缓存行写回到内存。
在最近的处理器中,LOCK#信号一般不锁总线,因为开销比较大,在一些处理器中,如果当前执行指令声明了lock前缀,且访问的内存区域已经缓存在了高速缓冲的缓冲行上,则不会再总线上声言LOCK#信号,而是锁定这块缓存并将其结果回写到内存,但是万一两个处理器都已经缓存了怎么办?这就需要“缓存锁定”和Lock声明引发的另一件事共同解决。其中“缓存锁定”是缓存一致性机制,作用是保证修改操作的原子性,原理是阻止同时修改由两个以上处理器缓存的内存区域数据。
(2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
在多核处理器系统进行操作时,IA-32和Inter 64处理器能嗅探其他处理器访问系统内存和他们的内部缓存。处理器使用嗅探技术保证他的内部缓存、系统内存和其他处理器的缓存数据在总线上保持一致,例如,在Pentium和P6 family处理中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个内存地址当前处于共享状态,那么正在嗅探的处理器将使他自己的缓冲行无效,在下次需要访问相同的内存数据时,强制再次进行缓存,而此时,被嗅探的处理器已经将更改后的操作数写回到内存中,从而达到了“可见性”。
volatile使用的优化
尽管volatile从锁总线更改为锁缓存行,降低了开销,但一个缓存行的空间也不小,32字节或64字节或其他,在java中,对象本身都是通过指针进行操作的,指针四字节(和位数有关,目前是4字节),也就是一个缓冲行可能放了不止一个对象的引用,而主要加锁,锁的就是整个缓存行,这就有可能造成想锁一个对象,结果把其他的几个对象锁起来了,优化的方法就是给对象追加字节码,让该对象占用的空间等于缓存行的大小,这样,其他的对象就不会与这个对象放在同一个缓存行里。
这里需要注意的是,不要在所有情况下使用追加字节码的方式,有些情况下并不会提升性能,比如共享变量不会被频繁的写,就无需优化。
参考资料:《Java并发编程的艺术》