引言
在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内存模型会插入一个读内存屏障,使得该操作之后的所有读写操作都在该屏障之后开始,从而能看到最新的数据。一个比较直观的流程图如下:
代码语言:javascript复制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有如下的要求:
- 在每个 volatile 写操作之前插入一个写内存屏障(StoreStore | StoreLoad);
- 在每个 volatile 写操作之后插入一个写内存屏障(StoreStore | StoreLoad);
- 在每个 volatile 读操作之前插入一个读内存屏障(LoadLoad | LoadStore);
- 在每个 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.cpp
的ARRAY_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...
}
值得注意的就是putfield
和putstatic
操作了,现在对这两个字节码操作进行JDK的溯源分析:
下面这段代码主要处理Java字节码中的putfield
和putstatic
操作,它们分别用于设定对象的实例字段值和静态字段值:
// 设定对象的实例字段值和静态字段值
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
字段的可见性。
在JDK17u中cache->is_volatile()
方法被变更至了srchotspotshareoopscpCache.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 ...
剩下的switch/case
判断都被委派给了srchotspotshareoopsoop.cpp
中的void oopDesc::release_byte_field_put(int offset, jbyte value)
函数。从这里开始jdk17u的源码与jdk1.8的源码就大不相同了:
- 在jdk1.8中是调用了
hotspotsrcsharevmruntimeorderAccess.hpp
中的OrderAccess::release_store
函数 - 在jdk17u中则是调用了
srchotspotshareoopsaccess.hpp
中的HeapAccess<MO_RELEASE>::store_at
函数
虽然这两个版本的源码变更了,但是它们实现的最终目的是一致的:为开发者提供了相同的内存顺序保证。
OrderAccess
尽管实现不同,但还是回到orderAccess
(srchotspotshareruntimeorderAccess.hpp
)上。从第31行开始到235行重点介绍了Java Hotspot VM的内存访问顺序模型,这部分文档注释介绍了内存屏障(memory barrier)操作,其用于保证多线程环境下的内存访问顺序,防止重排序。
通过官方的解释可以得到更详细且严谨的4种内存屏障操作的解释:
- LoadLoad:确保 Load1 完成后再执行 Load2 以及所有后续的 load 操作。Load1 之前的加载操作不能下浮到 Load2 和所有后续的加载操作之后。
- StoreStore:确保 Store1 完成后再执行 Store2 和所有后续的 store 操作。Store1 之前的存储操作不能下浮到 Store2 和所有后续的存储操作之后。
- LoadStore:确保 Load1 完成后再执行 Store2 和所有后续的 store 操作。Load1 之前的加载操作不能下浮到 Store2 和所有后续的存储操作之后。
- StoreLoad:确保 Store1 完成后再执行 Load2 和所有后续的 load 操作。Store1 之前的存储操作不能下浮到 Load2 和所有后续的加载操作之后。
两个进一步的内存屏障操作是:acquire
和release
。这两个内存屏障操作常用于发布(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的运行至关重要。具体地,假设构造函数按照fence
、lock
、acquire
的顺序执行,析构函数按照release
、unlock
的顺序执行。如果这些实现改变了,将导致大量代码出现问题。
最后,定义了一个instruction_fence
操作,它确保指令围栏之后的所有指令在指令围栏完成后才从缓存或内存中获取。 总而言之,这段文档描述了内存屏障在多线程内存访问中的重要性和用法,以及在不同硬件架构下的具体实现。
汇编层面
很多目前可供参考的文献中都提到了“查看volatile汇编层面的实现”一方法,但他们似乎都止步于了lock addl $0x0, (%rsp)
这个汇编指令。对于再深一步的汇编原理,感兴趣的读者可以继续与博主一起向下探索。
我们回到Java Hotspot VM源码中的,以Intel x86架构的计算机为例,在srchotspotcpux86assembler_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
指令。所以在使用lock
和addl
时,可能会像这样:
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的指令重排。
其他内存屏障函数
如果读者继续向下阅读源码会发现另外三个与内存屏障相关的函数,lfence
、mfence
、sfence
:
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架构下的内存屏障指令,分别是LFENCE
、MFENCE
、SFENCE
,这三个函数更具体的作用如下:
lfence
:这个函数生成了LFENCE
指令。LFENCE
是Load Fence,这是一种内存屏障,确保在LFENCE
之前的所有读操作在LFENCE
指令完成之前都完成。也就是说,它阻止了在LFENCE
之前的加载(load)操作被重排序到LFENCE
之后。mfence
:这个函数生成了MFENCE
指令。MFENCE
是Memory Fence,这是一种更强的内存屏障,它确保在MFENCE
之前的所有读写操作在MFENCE
指令完成之前都完成。也就是说,它阻止了在MFENCE
之前的加载(load)和存储(store)操作被重排序到MFENCE
之后。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
指令能防止这种重排序。
lfence
、mfence
、sfence
这三个函数在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/3276523[enter link description here](https://doi.org/10.1145/3276523)
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)