首页 > 编程笔记 > Java笔记 阅读:13

剖析Java volatile的内存可见性(新手必看)

JVM 使用 JMM 来屏蔽硬件和操作系统内存读取差异,以在各个平台下都能达到一致的内存访问效果。

JMM 和 CPU 存储结构的对应关系如下图所示:


理想中的 CPU 存储结构是所有 CPU 共享同一个缓存,当其中一个 CPU 进行写操作时,另一个 CPU 进行读取操作,总是能读取到正确的值,但是这样会极大降低系统的运算速度。在实际中,每个 CPU 会有一个自己的工作内存,而这种多缓存情况会导致数据的不一致性。

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。如果线程 1 修改的变量值,线程 2 没有立刻看到,则称为内存不可见,如下图所示:


volatile 关键字在 Java 中提供了一种轻量级的同步机制,它保证了对变量的读写都是直接对主内存进行的,而不是先缓存到 CPU 的本地缓存中,然后随机写回主内存。这样可以确保在一个线程中对该变量的修改可以立即对其他线程可见。

在 JVM 底层,volatile 的内存可见性是通过 lock 指令和缓存一致性协议来实现的:
对 volatile 修饰的变量执行写入操作时,JVM 会发送一个 lock 指令给 CPU,CPU 在执行完写操作后,会立即将新值刷新到内存。同时,因为使用了 MESI 协议,其他 CPU 都会对总线进行嗅探,查看自己本地缓存中的数据是否被别人修改,如果发现被修改了,这个 CPU 会把自己本地缓存的数据进行过期处理。然后这个 CPU 里的线程在读取修改的变量时,就会从主内存里加载最新的值了,这样就保证了可见性。

下面是一个 volatile 应用的简单示例,具体代码如下:
public class Test  {
    private static volatile int a = 1;
    public static void test() {
        a = 2;
    }
    public static void main(String [] args) {
        test();
    }
}

我们通过 hsdis 工具获取JIT编译器生成的汇编指令,得到以下结果:

0x000000011a5ddf25: callq  0x000000010cb439f0  ;   {runtime_call}
0x000000011a5ddf2a: vzeroupper
0x000000011a5ddf2d: movl   ﹩0x5,0x270(%r15)
0x000000011a5ddf38: lock addl ﹩0x0,(%rsp)
0x000000011a5ddf3d: cmpl   ﹩0x0,-0xd4ec2e7(%rip)        # 0x000000010d0f1c60

通过上述结果,可以发现有一个 lock addl 指令,而 lock 指令就是 CPU 实现 volatile 关键字可见性的秘密所在。

通过查询《英特尔®64和IA-32架构软件开发人员手册》,可以发现lock前缀指令介绍如下:
1) Intel486 和 Pentium CPU,在锁操作时,总是在总线上声明 LOCK# 信号。但 P6 family CPU,如果访问的内存区域已经缓存在 CPU 内部,则不会声明 LOCK# 信号。相反,它们会锁定这块内存区域的缓存并写回到内存,并使用缓存一致性协议来保证修改的原子性,此操作被称为“缓存锁定”,缓存一致性协议会阻止同时修改由两个以上 CPU 缓存的内存区域数据。

2) 在 Pentium 和 P6 family CPU 中,如果通过嗅探一个 CPU 检测到其他 CPU 打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的 CPU 将使它的缓存内容无效,并且在下次访问相同的内存地址时,强制执行缓存内容写回到内存,导致其他 CPU 的缓存无效。

上述引用内容总结为 volatile 关键字可见性原理的两条实现原则:

相关文章