1.
volatile
的作用在 Java 中,volatile
是一个关键字,用于修饰变量。它有以下两个主要作用:
保证可见性:当一个线程修改了 volatile
变量时,其他线程能够立即看到这个修改。
防止指令重排:保证 volatile
变量的操作不会被线程内的指令重排优化。
指令重排是指在程序执行时,编译器、运行时环境或 CPU 可能会调整指令的执行顺序,以提高性能。例如:
int a = 1; // 指令1
int b = 2; // 指令2
在某些情况下,指令2可能会被提前执行,变成:
int b = 2; // 指令2
int a = 1; // 指令1
这种调整通常是安全的,但在多线程环境下,可能会导致问题。
为了防止指令重排,Java 在 volatile
变量操作前后插入了内存屏障(Memory Barrier)。内存屏障是一种特殊的指令,用于限制指令的执行顺序。
内存屏障的种类
StoreStore(SS)屏障:屏障前的写操作必须全部完成,才能执行后续的写操作。
StoreLoad(SL)屏障:屏障前的写操作必须全部完成,才能执行后续的读操作。
LoadLoad(LL)屏障:屏障前的读操作必须全部完成,才能执行后续的读操作。
LoadStore(LS)屏障:屏障前的读操作必须全部完成,才能执行后续的写操作。
这些屏障的作用是防止指令重排,确保程序的执行顺序符合预期。
volatile
的内存屏障实现当一个变量被 volatile
修饰时,Java 会在其操作前后插入相应的内存屏障。具体来说:
写操作:在写 volatile
变量之前插入 StoreStore(SS)屏障,在写操作之后插入 StoreLoad(SL)屏障。
读操作:在读 volatile
变量之前插入 LoadLoad(LL)屏障,在读操作之后插入 LoadStore(LS)屏障。
在 X86 架构中,CPU 内部已经实现了一些内存屏障的功能。例如:
mfence
指令:用于实现全内存屏障,防止指令重排。
lock
指令:用于实现原子操作,并且隐含了内存屏障的效果。
因此,X86 架构主要针对 StoreLoad(SL)屏障 做了额外的支持,通过调用 mfence
或 lock
指令来实现。
3.1 mfence
代码解释inline void OrderAccess::fence() {
if (os::is_MP()) {
// always use locked addl since mfence is sometimes expensive
#ifndef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" ::: "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" ::: "cc", "memory");
#endif
}
}
以上代码为C++ 函数,名为 OrderAccess::fence
,它的作用是确保内存操作的顺序性,防止指令重排
3.1.1 函数定义:
inline void OrderAccess::fence()
:定义了一个内联函数 fence
,属于 OrderAccess
类。这个函数没有参数,返回类型是 void
。
3.1.2 多处理器检查:
if (os::is_MP())
:检查当前系统是否是多处理器(Multi-Processor,MP)系统。os::is_MP()
是一个函数,用于检测系统是否支持多处理器。
3.1.3 条件编译:
#ifndef AMD64
和 #else
:这部分代码使用了条件编译指令。AMD64
是一个宏如果,定义了 AMD64
,则表示编译目标是 64 位系统;否则,表示是 32 位系统。
3.1.4 内存屏障实现:
__asm__ volatile
:这是一个内联汇编指令,用于直接在 C++ 代码中插入汇编代码。volatile
关键字防止编译器对汇编代码进行优化。
"lock; addl $0,0(%%rsp)"
和 "lock; addl $0,0(%%esp)"
:这是两条汇编指令。
lock
:这是一个前缀,用于确保接下来的指令在多处理器系统中是原子操作,防止指令重排。
addl $0,0(%%rsp)
和 addl $0,0(%%esp)
:这是两条加法指令,将寄存器 rsp
或 esp
的值加 0,实际上没有改变任何值,但这个操作被 lock
前缀修饰,因此具有内存屏障的效果。rsp
是 64 位寄存器,esp
是 32 位寄存器。
::: "cc", "memory"
:这是内联汇编的约束和修饰符。
"cc"
:表示影响条件码寄存器。
"memory"
:表示影响内存。
在X86架构中,LOCK
前缀用于确保随后的指令以原子方式执行,这在多处理器或多线程环境中特别重要。LOCK
前缀的工作原理包括总线锁定、内存屏障和处理器的缓存一致性机制。
3.2.1 总线锁定
当处理器执行带有LOCK
前缀的指令时,它会对系统总线进行锁定。这意味着在执行该指令期间,其他处理器无法访问被锁定的内存地址。这种锁定机制确保了在执行原子操作时,其他处理器无法对同一内存位置进行读写操作,从而避免了数据竞争。
3.2.2 内存屏障
LOCK
前缀还会在执行时插入内存屏障。这确保了在LOCK
指令之前的所有读写操作在该指令执行之前完成,并且在该指令之后的所有读写操作在该指令完成之后才能执行。这有助于确保内存的可见性,避免因缓存一致性问题导致的错误。
3.2.3 缓存一致性协议
现代CPU通常使用更高效的方法来实现LOCK
前缀的效果。如果数据不跨越缓存行,CPU核心可以内部锁定缓存行,从而避免阻塞所有其他核的读/写访问。这种机制利用了MESI缓存一致性协议。
示例代码
以下是一个使用LOCK
前缀的示例代码,它展示了如何在C++中使用内联汇编来实现原子操作:
movl 4(%esp), %ecx ; 将要增加的变量地址从栈中加载到ECX寄存器
lock ; "lock"前缀,确保以下操作的原子性
incl (%ecx) ; 将ECX寄存器指向的内存变量加1
mov $0,%eax ; 将EAX寄存器设置为0
setne %al ; 如果加法结果不等于0,将AL寄存器(EAX的低字节)设置为1
ret ; 返回
这段代码首先将要增加的变量地址从栈中加载到ECX寄存器,然后使用lock
前缀和incl
指令来原子性地增加该变量。随后的两条指令设置EAX寄存器(函数的返回值)为0,如果该变量的新值为0,则设置AL寄存器为1,即返回值为1。即:如果内存地址中的原始值为0,并且 incl
指令将其增加到1,那么 %al
将被设置为1。如果内存地址中的原始值不为0,那么 %al
将保持为0。
volatile
示例代码和图示假设我们有一个场景:线程 A 写入一个 volatile
变量 flag
,线程 B 读取这个变量。
public class VolatileExample {
private volatile boolean flag = false;
public void writer() {
// 写操作
flag = true; // volatile 写操作
}
public void reader() {
// 读操作
if (flag) {
System.out.println("Flag is true");
}
}
}
在 flag = true
操作前后,Java 会插入内存屏障:
写操作:
在写 flag
之前插入 StoreStore(SS)屏障。
在写 flag
之后插入 StoreLoad(SL)屏障。
读操作:
在读 flag
之前插入 LoadLoad(LL)屏障。
在读 flag
之后插入 LoadStore(LS)屏障。
以下是 volatile
写操作和读操作的内存屏障插入示意图:
线程 A(写操作):
-----------------
| |
| flag = true | <- volatile 写操作
| |
|---------------|
| StoreStore | <- SS 屏障
|---------------|
| StoreLoad | <- SL 屏障
-----------------
线程 B(读操作):
-----------------
| |
| if (flag) | <- volatile 读操作
| |
|---------------|
| LoadLoad | <- LL 屏障
|---------------|
| LoadStore | <- LS 屏障
-----------------
想象一下,你正在写一封重要的信,然后把它放进一个特殊的信封(volatile
变量)。在你把信放进信封之前,你必须确保所有之前的准备工作(比如写信的内容)都已经完成(SS 屏障)。当你把信放进信封之后,你还需要确保这封信不会被提前寄出(SL 屏障)。
另一方面,当别人收到这封信并打开信封(读操作)时,他们必须确保在打开信封之前,所有的准备工作(比如确认信封是否完整)都已经完成(LL 屏障)。在读完信之后,他们还需要确保不会提前做后续的事情(比如回复信件)(LS 屏障)。
通过这种方式,volatile
确保了操作的顺序性,防止了指令重排的问题。