什么是 volatile 和指令重排?

1.volatile 的作用

在 Java 中,volatile 是一个关键字,用于修饰变量。它有以下两个主要作用:

  • 保证可见性:当一个线程修改了 volatile 变量时,其他线程能够立即看到这个修改。

  • 防止指令重排:保证 volatile 变量的操作不会被线程内的指令重排优化。

指令重排

指令重排是指在程序执行时,编译器、运行时环境或 CPU 可能会调整指令的执行顺序,以提高性能。例如:

int a = 1;  // 指令1
int b = 2;  // 指令2

在某些情况下,指令2可能会被提前执行,变成:

int b = 2;  // 指令2
int a = 1;  // 指令1

这种调整通常是安全的,但在多线程环境下,可能会导致问题。


2. 内存屏障的作用及种类

为了防止指令重排,Java 在 volatile 变量操作前后插入了内存屏障(Memory Barrier)。内存屏障是一种特殊的指令,用于限制指令的执行顺序。

内存屏障的种类

  • StoreStore(SS)屏障:屏障前的写操作必须全部完成,才能执行后续的写操作。

  • StoreLoad(SL)屏障:屏障前的写操作必须全部完成,才能执行后续的读操作。

  • LoadLoad(LL)屏障:屏障前的读操作必须全部完成,才能执行后续的读操作。

  • LoadStore(LS)屏障:屏障前的读操作必须全部完成,才能执行后续的写操作。

这些屏障的作用是防止指令重排,确保程序的执行顺序符合预期。


3. volatile 的内存屏障实现

当一个变量被 volatile 修饰时,Java 会在其操作前后插入相应的内存屏障。具体来说:

  • 写操作:在写 volatile 变量之前插入 StoreStore(SS)屏障,在写操作之后插入 StoreLoad(SL)屏障

  • 读操作:在读 volatile 变量之前插入 LoadLoad(LL)屏障,在读操作之后插入 LoadStore(LS)屏障

在 X86 架构中,CPU 内部已经实现了一些内存屏障的功能。例如:

  • mfence 指令:用于实现全内存屏障,防止指令重排。

  • lock 指令:用于实现原子操作,并且隐含了内存屏障的效果。

因此,X86 架构主要针对 StoreLoad(SL)屏障 做了额外的支持,通过调用 mfencelock 指令来实现。

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):这是两条加法指令,将寄存器 rspesp 的值加 0,实际上没有改变任何值,但这个操作被 lock 前缀修饰,因此具有内存屏障的效果。rsp 是 64 位寄存器,esp 是 32 位寄存器。

::: "cc", "memory":这是内联汇编的约束和修饰符。

"cc":表示影响条件码寄存器。

"memory":表示影响内存。

3.2  lock指令

在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。


4. 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 屏障
-----------------

5. 通俗解释

想象一下,你正在写一封重要的信,然后把它放进一个特殊的信封(volatile 变量)。在你把信放进信封之前,你必须确保所有之前的准备工作(比如写信的内容)都已经完成(SS 屏障)。当你把信放进信封之后,你还需要确保这封信不会被提前寄出(SL 屏障)。

另一方面,当别人收到这封信并打开信封(读操作)时,他们必须确保在打开信封之前,所有的准备工作(比如确认信封是否完整)都已经完成(LL 屏障)。在读完信之后,他们还需要确保不会提前做后续的事情(比如回复信件)(LS 屏障)。

通过这种方式,volatile 确保了操作的顺序性,防止了指令重排的问题。

 

你可能感兴趣的:(JVM,jvm)