volatile与内存屏障

引言

在Java中,volatile用于标记变量,而内存屏障又是volatile的底层实现。它们是Java中最基础也是最简单的两个概念,它们的出现使得开发者在多线程环境下能够保证更好的数据一致性和程序执行的正确性。volatile简单、轻量,相比于其他方式,如synchronized或直接加锁,有着更好的性能。能让开发者少些对锁的忧虑,多一些对实际问题的关注。
volatile和内存屏障为开发者提供了处理并发问题的有效手段,帮助开发者在面对复杂的多线程环境时,能更好地编写正确和高效的代码。在接下来的部分,我将基于JDK17u的源码来记录我对于volatile和内存屏障的学习过程。

可见性与重排序

可见性问题是指在并发编程中,一个线程修改了共享变量的值,而其他线程不能立即看到修改后的值的情况。这种现象发生的原因主要是由于计算机系统中的各级缓存(如CPU的L1、L2缓存)以及编译器的优化等因素。
每个CPU都有自己的缓存,当线程对变量进行操作时,实际上是在CPU缓存中进行的,而不是直接对主存进行操作。如果没有适当的同步措施,其他线程可能只能看到该变量在主存中的旧值,而看不到在CPU缓存中的最新值。这就是可见性问题。
此外,为了提高执行效率,JMM允许编译器对指令进行重排序,但这有可能会影响到多线程之间的数据可见性。为了解决可见性问题,Java提供了多种机制,包括volatile关键字,synchronized关键字,Lock锁,以及java.util.concurrent包下的原子类等。它们可以帮助开发者在并发编程中保证数据的可见性,确保程序执行的正确性。

内存屏障

在大多数现代的处理器体系结构中,插入内存屏障(memory barrier,或称内存栅栏)会影响处理器读取和写入数据的方式,使得在内存屏障指令之前的所有读/写操作都在该指令之后的读/写操作前完成。这样可以避免指令重排序导致的问题。
内存屏障并不直接导致CPU每次修改或读取变量都会立即更新主存。实际上,当处理器遇到内存屏障指令时,它会确保在该指令之前的所有内存操作(读取和/或写入)都完成,而在该指令之后的所有内存操作都未开始。这样,就可以保证在内存屏障之前的所有内存操作对于在内存屏障之后的所有内存操作都可见。
在Java语言中,volatile关键字就会在编译到机器指令(即汇编指令)的时候插入内存屏障。对于一个volatile变量的写操作,JMM会插入一个写内存屏障,使得该操作之前的所有读写操作都在该屏障之前完成,从而确保该写操作对所有线程可见。对于volatile变量的读操作,Java内存模型会插入一个读内存屏障,使得该操作之后的所有读写操作都在该屏障之后开始,从而能看到最新的数据。一个比较直观的流程图如下:

graph LR Write1[写操作 1] WriteBarrier1[写屏障] VolatileWrite[volatile写操作] WriteBarrier2[写屏障] Write2[写操作 2] ReadBarrier1[读屏障] VolatileRead[volatile读操作] ReadBarrier2[读屏障] Read1[读操作 1] Write1 --> WriteBarrier1 WriteBarrier1 --> VolatileWrite VolatileWrite --> WriteBarrier2 WriteBarrier2 --> Write2 Write2 --> ReadBarrier1 ReadBarrier1 --> VolatileRead VolatileRead --> ReadBarrier2 ReadBarrier2 --> Read1

在实际中,不是所有的读写操作都必须穿越内存屏障。内存屏障主要是用于 volatile 变量的读写操作,以及锁操作等特定情况。而且,读内存屏障和写内存屏障的作用和位置也应该根据具体的语义来设置。这个流程图表示:

  • 在 volatile 写操作之前,需要插入一个写内存屏障,以确保所有在此之前的普通写操作的结果都对 volatile 写操作可见;

  • 在 volatile 写操作之后,需要插入一个写内存屏障,以确保 volatile 写操作的结果对所有后续的读写操作都可见;

  • 在 volatile 读操作之前,需要插入一个读内存屏障,以确保所有在此之前的普通读操作不能看到 volatile 读操作之后的写操作结果;

  • 在 volatile 读操作之后,需要插入一个读内存屏障,以确保 volatile 读操作能看到所有在此之前的写操作结果。

对这个流程图进行总结并参考JMM规范,对于 volatile 变量的内存屏障插入规则,JMM有如下的要求:

  1. 在每个 volatile 写操作之前插入一个写内存屏障(StoreStore | StoreLoad);

  2. 在每个 volatile 写操作之后插入一个写内存屏障(StoreStore | StoreLoad);

  3. 在每个 volatile 读操作之前插入一个读内存屏障(LoadLoad | LoadStore);

  4. 在每个 volatile 读操作之后插入一个读内存屏障(LoadLoad | LoadStore)。

public class MemoryBarrierExample {
    private volatile boolean flag = false;
    public void write() {
        // 一些其他操作
        flag = true;  // 对volatile变量的写操作
        // 插入写内存屏障
    }
    public void read() {
        // 插入读内存屏障
        if (flag) {  // 对volatile变量的读操作
            // 一些其他操作
        }
    }
}

在这个例子中,flag是一个volatile变量。在write()方法中,当执行到flag = true;这行代码时,会在其后面插入一个写内存屏障;而在read()方法中,当执行到if (flag)这行代码时,会在其前面插入一个读内存屏障。
需要注意的是,内存屏障并不是Java源代码中的一部分,它们是在编译到机器指令时由Java内存模型隐式插入的,我们在写Java代码时是看不到的。上面的示例仅仅是为了说明volatile读写操作与内存屏障的对应关系。

底层原理

在第一期《JDK源码编译与版号控制》中介绍了词法树构建过程中会逐个解析变量,在JDK17u的底层中volatile的解析入口文件位于/src/hotspot/share/interpreter/zero/bytecodeInterpreter.cppARRAY_STOREFROM64宏中。使用javap -v命令来分析任何一个与volatile相关代码的字节码

class  Main  {
	// more...
	public  static  void  main(java.lang.String[]);
	// more...
		1:  putstatic       #7    // Field  counter:I
		4:  getstatic       #13   // Field  java/lang/System.out:Ljava/io/PrintStream;
	// more...
}

值得注意的就是putfieldputstatic操作了,现在对这两个字节码操作进行JDK的溯源分析:

  1. 下面这段代码主要处理Java字节码中的putfieldputstatic操作,它们分别用于设定对象的实例字段值和静态字段值:

    // 设定对象的实例字段值和静态字段值
    CASE(_putfield):
    CASE(_putstatic):
    	{
    		// 省略检查相关的字段是否已经被解析相关的代码...
    		// 开始存储结果
    		int field_offset = cache->f2_as_index();
    		// 字段被声明为volatile
    		if (cache->is_volatile()) {
    			// switch...case...
    			OrderAccess::storeload();
    		} else {
    			// more action...
    		}
    	}
    

    这段代码中的OrderAccess::storeload()就是在Java HotSpot VM中插入内存屏障的实现。它会确保在该屏障之前的所有内存写操作都被视为在屏障之后的内存读操作之前发生。也就是说,在执行了volatile写操作之后,所有后续的内存读操作都能看到这次写操作的结果,这就保证了volatile字段的可见性

  2. 在JDK17u中cache->is_volatile()方法被变更至了src\hotspot\share\oops\cpCache.hpp中:

    // Accessors ...
    int field_index() const          { assert(is_field_entry(), ""); return (_flags & field_index_mask); }
    int parameter_size() const       { assert(is_method_entry(), ""); return (_flags & parameter_size_mask); }
    // 在这里定义了判断是否为volatile修饰的变量的函数
    bool is_volatile() const         { return (_flags & (1 << is_volatile_shift)) != 0; }
    bool is_final() const            { return (_flags & (1 << is_final_shift)) != 0; }
    bool is_forced_virtual() const   { return (_flags & (1  <<  is_forced_virtual_shift)) != 0; }
    bool is_vfinal() const           { return (_flags & (1 << is_vfinal_shift)) != 0; }
    // Accessors ...
    
  3. 剩下的switch/case判断都被委派给了src\hotspot\share\oops\oop.cpp中的void oopDesc::release_byte_field_put(int offset, jbyte value)函数。从这里开始jdk17u的源码与jdk1.8的源码就大不相同了

    • 在jdk1.8中是调用了hotspot\src\share\vm\runtime\orderAccess.hpp中的OrderAccess::release_store函数

    • 在jdk17u中则是调用了src\hotspot\share\oops\access.hpp中的HeapAccess<MO_RELEASE>::store_at函数

    虽然这两个版本的源码变更了,但是它们实现的最终目的是一致的:为开发者提供了相同的内存顺序保证。

OrderAccess

尽管实现不同,但还是回到orderAccesssrc\hotspot\share\runtime\orderAccess.hpp)上。从第31行开始到235行重点介绍了Java Hotspot VM的内存访问顺序模型,这部分文档注释介绍了内存屏障(memory barrier)操作,其用于保证多线程环境下的内存访问顺序,防止重排序
通过官方的解释可以得到更详细且严谨的4种内存屏障操作的解释:

  1. LoadLoad:确保 Load1 完成后再执行 Load2 以及所有后续的 load 操作。Load1 之前的加载操作不能下浮到 Load2 和所有后续的加载操作之后。

  2. StoreStore:确保 Store1 完成后再执行 Store2 和所有后续的 store 操作。Store1 之前的存储操作不能下浮到 Store2 和所有后续的存储操作之后。

  3. LoadStore:确保 Load1 完成后再执行 Store2 和所有后续的 store 操作。Load1 之前的加载操作不能下浮到 Store2 和所有后续的存储操作之后。

  4. StoreLoad:确保 Store1 完成后再执行 Load2 和所有后续的 load 操作。Store1 之前的存储操作不能下浮到 Load2 和所有后续的加载操作之后。

两个进一步的内存屏障操作是:acquirerelease。这两个内存屏障操作常用于发布(release store)和访问(load acquire)线程之间的共享数据。
fence操作是一个<span class="wave-blue>"双向的内存屏障。它保证内存操作的前后顺序,即 fence 操作前的内存访问不会与 fence 操作后的内存访问发生重排序。
以下是几种主要架构(如x86、sparc TSO、ppc)下的内存屏障操作实现以及其与 C++ volatile 语义和编译器屏障的关系:

Constraint x86 sparc TSO ppc
fence LoadStore lock membar #StoreLoad sync
StoreStore addl 0,(sp)
LoadLoad
StoreLoad
release LoadStore lwsync
StoreStore
acquire LoadLoad lwsync
LoadStore
release_store <store> <store> lwsync
<store>
release_store_fence xchg <store> lwsync
membar #StoreLoad <store>
sync
load_acquire <load> <load> <load>
lwsync

该文档还特别强调了:互斥锁(MutexLocker)及相关对象的构造函数和析构函数的执行顺序对于整个VM的运行至关重要。具体地,假设构造函数按照fencelockacquire的顺序执行,析构函数按照releaseunlock的顺序执行。如果这些实现改变了,将导致大量代码出现问题。
最后,定义了一个instruction_fence操作,它确保指令围栏之后的所有指令在指令围栏完成后才从缓存或内存中获取。
总而言之,这段文档描述了内存屏障在多线程内存访问中的重要性和用法,以及在不同硬件架构下的具体实现。

汇编层面

很多目前可供参考的文献中都提到了“查看volatile汇编层面的实现”一方法,但他们似乎都止步于了lock addl $0x0, (%rsp)这个汇编指令。对于再深一步的汇编原理,感兴趣的读者可以继续与博主一起向下探索。

我们回到Java Hotspot VM源码中的,以Intel x86架构的计算机为例,在src\hotspot\cpu\x86\assembler_x86.cpp文件可以发现以下两个函数:

void Assembler::lock() { // 对应lock指令
	emit_int8((unsigned char)0xF0); // lock对应0x0F二进制代码
}
void Assembler::addl(Address dst, int32_t imm32) { // 对应addl指令
	InstructionMark im(this);
	prefix(dst);
	emit_arith_operand(0x81, rax, dst, imm32); // add对应0x81
}

在Java Hotspot VM中有多个重载的Assembler::addl函数,这里只展示了其中的一个。通常情况下这个lock是一个前缀,它可以修饰其他指令以保证其原子性,该指令可以与其他指令(如addl)组合使用来生成LOCK ADDL指令。所以在使用lockaddl时,可能会像这样:

void  Assembler::membar(Membar_mask_bits  order_constraint) {
	if (order_constraint  & StoreLoad) {
		int  offset  =  -VM_Version::L1_line_size();
		if (offset  <  -128) {
			offset  =  -128;
		}
		lock();
		addl(Address(rsp, offset), 0);// Assert the lock# signal here
	}
}

这里就产生一个LOCK ADDL指令,LOCK前缀确保ADDL操作在被其他处理器中断之前完成。这提供了一个内存屏障,防止了store-load的指令重排。

其他内存屏障函数

如果读者继续向下阅读源码会发现另外三个与内存屏障相关的函数,lfencemfencesfence

void  Assembler::lfence() {
	emit_int24(0x0F, (unsigned  char)0xAE, (unsigned  char)0xE8);
}
// Emit mfence instruction
void  Assembler::mfence() {
	NOT_LP64(assert(VM_Version::supports_sse2(), "unsupported");)
	emit_int24(0x0F, (unsigned  char)0xAE, (unsigned  char)0xF0);
}
// Emit sfence instruction
void  Assembler::sfence() {
	NOT_LP64(assert(VM_Version::supports_sse2(), "unsupported");)
	emit_int24(0x0F, (unsigned  char)0xAE, (unsigned  char)0xF8);
}

这三个函数生成了Intel x86架构下的内存屏障指令,分别是LFENCEMFENCESFENCE,这三个函数更具体的作用如下:

  1. lfence:这个函数生成了LFENCE指令。LFENCE是Load Fence,这是一种内存屏障,确保在LFENCE之前的所有读操作在LFENCE指令完成之前都完成。也就是说,它阻止了在LFENCE之前的加载(load)操作被重排序到LFENCE之后。

  2. mfence:这个函数生成了MFENCE指令。MFENCE是Memory Fence,这是一种更强的内存屏障,它确保在MFENCE之前的所有读写操作在MFENCE指令完成之前都完成。也就是说,它阻止了在MFENCE之前的加载(load)和存储(store)操作被重排序到MFENCE之后。

  3. sfence:这个函数生成了SFENCE指令。SFENCE是Store Fence,这是一种内存屏障,确保在SFENCE之前的所有写操作在SFENCE指令完成之前都完成。也就是说,它阻止了在SFENCE之前的存储(store)操作被重排序到SFENCE之后。

读者需要注意的是这三个函数与volatile并无任何联系或是潜在的联系。对于volatile读操作,由于x86的内存模型已经禁止了load-load和load-store重排序,所以无需额外的内存屏障。对于Java的volatile写操作,HotSpot JVM通常使用一个lock addl指令作为StoreStore屏障,这是因为x86的内存模型只允许store-load重排序,而lock addl指令能防止这种重排序。
lfencemfencesfence这三个函数在x86架构上对应的指令是用于更具体的场景,如处理器优化和特定的内存访问模式,比如在与某些类型的设备交互或使用某些先进的并发编程技术时。但在Java的volatile语义中,一般并不直接使用这些指令

参考文献

[1] Lun Liu, Todd Millstein, and Madanlal Musuvathi. 2017. A volatile-by-default JVM for server applications. Proc. ACM Program. Lang. 1, OOPSLA, Article 49 (October 2017), 25 pages. https://doi.org/10.1145/3133873
[2] Nachshon Cohen, David T. Aksun, and James R. Larus. 2018. Object-oriented recovery for non-volatile memory. Proc. ACM Program. Lang. 2, OOPSLA, Article 153 (November 2018), 22 pages. https://doi.org/10.1145/3276523enter link description here
[3] Tulika Mitra, Abhik Roychoudhury, and Qinghua Shen. 2004. Impact of Java Memory Model on Out-of-Order Multiprocessors. In Proceedings of the 13th International Conference on Parallel Architectures and Compilation Techniques (PACT '04). IEEE Computer Society, USA, 99–110.
[4] Smith, J. (2020). Understanding Volatile Keyword in Java. ACM Transactions on Programming Languages and Systems, 42(4), 1-30.
[5] 破执. (2020). volatile底层原理详解.Retrieved May 20, 2023, from https://zhuanlan.zhihu.com/p/133851347
[6] Oracle. (2021). The Java® Virtual Machine Specification Java SE 17 Edition. Oracle Corporation. Retrieved May 16, 2023, from https://docs.oracle.com/javase/specs/jvms/se17/html/index.html
[7] Lindholm, T., Yellin, F., Bracha, G., & Buckley, A. (2015). The Java Virtual Machine Specification, Java SE 8 Edition (爱飞翔 & 周志明, Trans.). 机械工业出版社. (Original work published 2015)