Memory Semantics and Implementation of Volatile


 

Memory Semantics and Implementation of Volatile

深入理解volatile的内存语义及其实现

0x01. volatile 特性

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。假设有多个线程分别调用下图左边程序的3个方法,这个程序在语义上和下图右边程序等价。

img

可见一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到任意线程对这个 volatile 变量最后的写入。

锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的 long 型和 double 型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。简而言之,volatile变量自身具有下列特性:

  • 可见性。对一个 volatile 变量的读,总是能看到任意线程对这个 volatile 变量最后的写入。
  • 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

0x02. volatile 写-读内存语义

  • volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

以下面示例程序VolatileExample为例,假设线程A首先执行writer()方法,随后线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。下图左边是线程A执行volatile写后,共享变量的状态示意图。

class VolatileExample {
	int a = 0;
	volatile boolean flag = false;
	public void writer() {
		a = 1;		 // 1
		flag = true; // 2
	}	
	public void reader() {
		if (flag) { 	// 3
			int i = a;  // 4
			……
		}
	}
}

下面右边为线程B读同一个volatile变量后,共享变量的状态示意图。如图所示,在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。

img

0x03. volatile 重排序规则

重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。JMM针对编译器制定的volatile重排序规则如下表所示:

第一个操作/第二个操作 普通读/写 volatile 读 volatile 写
普通读/写 NO
volatile 读 NO NO NO
volatile 写 NO NO

上表中第一列表示先进行的第一个操作类型,第一行表示后进行第二个操作类型,对该表的解读是:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

0x04. 内存语义的实现

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

在该保守策略下,以 volatile 写插入内存屏障后生成的指令序列为例,从下图左边可见StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主存。

img

因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(如一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM 在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,这是因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升,由此可见 JMM 在实现力保正确性再求效率的特点。

对于在保守策略下,volatile读插入内存屏障后生成的指令序列,由上图右边可见的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。 上述volatile写volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例代码进行说明。

package github.jordon.asm.concurrency;
public class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;
    void readAndWrite() {
        int i = v1;
        int j = v2;
        a = i + j;
        v1 = i + 1;
        v2 = j * 2;
    }
    public static void main(String[] args) {
        new VolatileBarrierExample().readAndWrite();
    }
}

针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化,即根据程序的实际情况省略一些冗余的内存屏障以加快执行效率,需要注意的是,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障

img

上面的优化针对任意处理器平台,由于不同的处理器有不同松紧度的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,上图中除最后的StoreLoad屏障外,其他的屏障都会被省略。

0x05. 简单验证

下面对上述的结论进行简单的验证,首先我的机器就是基于x64指令体系的,先看下字节码,在此使用Intellij IDEA中自定义的External tool中添加的javap -p -v查看该源文件的字节码文件信息,发现除了标记变量为ACC_VOLATILE外并五特别之处。

img

为了了解底层实现,需要用到一些工具查看汇编代码,其中用得最多的就是hsdis,它是一个甲骨文推荐 HotSpot 虚拟机JIT编译代码的反汇编插件。我们有了这个插件后,通过 JVM 参数-XX:+PrintAssembly就可以加载这个hsdis插件,然后为我们把 JIT 动态生成的那些本地代码还原成汇编代码,然后打印出来。然而这个插件的官方网站貌似已经不存在了,网上找不到其源头,估计甲骨文有将其闭源发财的意思,所以只好捞一些残留资源做替代品。

如果是64位JDK,下载完解压后将其中的hsdis-amd64.dll贴到$JAVA_HOME/jre/bin/client即可,将.java源文件编译成字节码文件后可以针对该字节码文件设定一些参数获取到其汇编信息,如在命令行输入以下命令,可将上述的.class文件的汇编信息重定向到当前工作目录的VolatileBarrierExample.asm文件中:

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp github/jordon/asm/concurrency/VolatileBarrierExample > VolatileBarrierExample.asm

注意若使用maven project构建项目或测试环境源码路径与之类似时,需在src/main/java下执行命令,同时注意包名及左右斜杆,否则很有可能会得到烦人的无法加载主类的错误提示。除了上述将全部的字节码信息输出外,还可以设定针对某个方法的参数,如下面将VolatileBarrierExamplereadAndWrite方法的字节码信息输出到out.put文件中:

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*VolatileBarrierExample.readAndWrite -XX:CompileCommand=compileonly,*VolatileBarrierExample.readAndWrite com.earnfish.VolatileBarrierExample > out.put

下面是readAndWrite方法的部分汇编代码,其中getField部分对应两个volatile读操作和局部变量ij的赋值,putField操作对应两个volatile写操作。查询相关汇编语言指令资料给出一些简单的描述:

  • je 32d27eah: 当寄存器值等于32d27eah进行跳转。
  • mov esi,dword ptr [rdx+10h] : 将 [rdx + 10h] 中的值复制到ESI寄存器,即getField,对应volatile读
  • mov dword ptr [r10+70h],r11d : 把变量的值写到 [r10 + 70h],从而使之对其他线程可见。
  • lock add dword ptr [rsp],0h: 锁定堆栈指针rsp表示的内存地址,并将0添加到其中。这充当了StoreLoad屏障。这是怎么回事?毕竟,这个语句是一个伪语句,它向堆栈指针中添加0,实际上什么都没做。

img

之所以在读取volatile变量之前不存在内存障碍,是因为x86具有强大的内存模型,它仅会对写-读操作做重排序。而不会对读-读读-写写-写操作做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义,而这一屏障是用Lock前缀汇编指令实现的。

如我们所见,使用volatile修饰的共享变量进行写操作的时候会多出一条Lock前缀汇编指令,通过查 英特尔® 64 位和 IA-32 架构开发人员手册 可知,Lock前缀的指令在多核处理器下会引发了两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

  • Lock前缀指令会引起处理器缓存回写到内存Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。对于Intel486Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为缓存锁定,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据
  • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效IA-32处理器Intel 64处理器使用MESI缓存一致性协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在PentiumP6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

而这次实验的机器是在Intel 64位操作系统,基于X64指令体系的处理器,因为x64架构基于x86,是为了让x86架构CPU兼容64位计算而产生的技术。x64架构的设计是采用直接简单的方法将目前的x86指令集扩展。这个方法与当初的由16位扩展至32位的情形很相似。优点在于用户可以自行选择x86平台或x64平台,兼容性高,自然地,得到的结果大致和《Java并发编程艺术》中描述的x86处理器volatile读写的内存屏障插入策略·一样。

由于没有基于其他架构的机器做测试,暂且了解到此,如果使用IA64处理器测试,可以参考这篇文章,在其中看到IA64处理器会使用mf(memory fence)汇编指令进行插入内存屏障,由于没有相应的机器进行验证,暂且看别人的文章将信将疑,待日后有机会再回来做些小实验,just move on。

0x06. JSR-133 为何要增强 volatile 的内存语义

旧的Java内存模型虽然不允许volatile变量之间重排序,但允许volatile变量与普通变量重排序。对于上述的VolatileExample示例子,在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果是:当一个读线程执行4时,不一定能看到另一个写线程在执行1时对共享变量的修改。

因此,在旧的内存模型中,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义

0x07. 参考

0x08. 相关文章