volatile实现内存可见性分析:字节码版本

本文深入探讨了Java中volatile变量的实现原理,包括其在字节码层面的标记,C++ volatile关键字的作用,以及JVM如何利用内存屏障保证volatile变量的可见性和有序性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

声明一个volatile变量,并赋值

public class VolatileTest {

    static volatile int i;

    public static void main(String[] args){
        i = 10;
    }
}

看看加了volatile之后,编译出来的字节码有什么不同,执行 javap -verbose VolatileTest 之后,结果如下
在这里插入图片描述

没有找类似关键字synchronize编译之后的字节码指令(monitorenter、monitorexit),volatile编译之后的赋值指令putstatic没有什么不同,唯一不同是变量i的修饰flags多了一个ACC_VOLATILE标识。

全局搜下ACC_VOLATILE,无从下手的时候,先看看关键字在哪里被使用了,果然在accessFlags.hpp文件中找到类似的名字。
在这里插入图片描述
通过is_volatile()可以判断一个变量是否被volatile修饰,然后再全局搜"is_volatile"被使用的地方,最后在bytecodeInterpreter.cpp文件中,找到
在这里插入图片描述putstatic字节码指令的解释器实现,里面有is_volatile()方法。
当然了,在正常执行时,并不会走这段逻辑,都是直接执行字节码对应的机器码指令,这段代码可以在debug的时候使用,不过最终逻辑是一样的。

其中cache变量是java代码中变量i在常量池缓存中的一个实例,因为变量i被volatile修饰,所以cache->is_volatile()为真,给变量i的赋值操作由release_int_field_put方法实现。

再来看看release_int_field_put方法
在这里插入图片描述
内部的赋值动作被包了一层,OrderAccess::release_store是核心关键,可以让其它线程读到变量i的最新值。

在这里插入图片描述

奇怪,在OrderAccess::release_store的实现中,第一个参数强制加了一个volatile,很明显,这是c/c++的关键字。

c/c++中的volatile关键字,用来修饰变量,通常用于语言级别的 memory barrier,在"The C++ Programming Language"中,对volatile的描述如下:

A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.

volatile是一种类型修饰符,被volatile声明的变量表示随时可能发生变化,每次使用时,都必须从变量i对应的内存地址读取,编译器对操作该变量的代码不再进行优化,下面写两段简单的c/c++代码验证一下

#include <iostream>

int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
    // insert code here...
    a = 2;
    a = foo + 10;
    int b = a + 20;
    return b;
}

代码中的变量i其实是无效的,执行g++ -S -O2 main.cpp得到编译之后的汇编代码如下:
在这里插入图片描述
可以发现,在生成的汇编代码中,对变量a的一些无效负责操作果然都被优化掉了,如果在声明变量a时加上volatile

#include <iostream>

int foo = 10;
volatile int a = 1;
int main(int argc, const char * argv[]) {
    // insert code here...
    a = 2;
    a = foo + 10;
    int b = a + 20;
    return b;
}

再次生成汇编代码如下:
在这里插入图片描述

和第一次比较,有以下不同:

1、对变量a赋值2的语句,也保留了下来,虽然是无效的动作,所以volatile关键字可以禁止指令优化,其实这里发挥了编译器屏障的作用;

编译器屏障可以避免编译器优化带来的内存乱序访问的问题,也可以手动在代码中插入编译器屏障,比如下面的代码和加volatile关键字之后的效果是一样

#include <iostream>

int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
    // insert code here...
    a = 2;
    __asm__ volatile ("" : : : "memory"); //编译器屏障
    a = foo + 10;
    __asm__ volatile ("" : : : "memory");
    int b = a + 20;
    return b;
}

编译之后,和上面类似
在这里插入图片描述

2、其中_a(%rip)是变量a的每次地址,通过movl $2, _a(%rip)可以把变量a所在的内存设置成2,关于RIP,可以查看 x64下PIC的新寻址方式:RIP相对寻址

所以,每次对变量a的赋值,都会写入到内存中;每次对变量的读取,都会从内存中重新加载。

让我们回到JVM的代码中来。
在这里插入图片描述

执行完赋值操作后,紧接着执行OrderAccess::storeload(),这又是啥?

其实这就是经常所说的内存屏障,之前只知道念,却不知道是如何实现的。从CPU缓存结构分析中已经知道:一个load操作需要进入LoadBuffer,然后再去内存加载;一个store操作需要进入StoreBuffer,然后再写入缓存,这两个操作都是异步的,会导致不正确的指令重排序,所以在JVM中定义了一系列的内存屏障来指定指令的执行顺序。

JVM中定义的内存屏障如下,JDK1.7的实现
在这里插入图片描述

1、loadload屏障(load1,loadload, load2)
2、loadstore屏障(load,loadstore, store)

这两个屏障都通过acquire()方法实现
在这里插入图片描述

其中__asm__,表示汇编代码的开始。
volatile,之前分析过了,禁止编译器对代码进行优化。
最后的"memory"是编译器屏障的作用。

在LoadBuffer中插入该屏障,清空屏障之前的load操作,然后才能执行屏障之后的操作,可以保证load操作的数据在下个store指令之前准备好

3、storestore屏障(store1,storestore, store2)
通过"release()"方法实现:
在这里插入图片描述
在StoreBuffer中插入该屏障,清空屏障之前的store操作,然后才能执行屏障之后的store操作,保证store1写入的数据在执行store2时对其它CPU可见。

4、storeload屏障(store,storeload, load)
对java中的volatile变量进行赋值之后,插入的就是这个屏障,通过"fence()"方法实现:
在这里插入图片描述
看到这个有没有很兴奋?

通过os::is_MP()先判断是不是多核,如果只有一个CPU的话,就不存在这些问题了。

storeload屏障,完全由下面这些指令实现

__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");

为了试验这些指令到底有什么用,我们再写点c++代码编译一下

#include <iostream>

int foo = 10;

int main(int argc, const char * argv[]) {
    // insert code here...
    volatile int a = foo + 10;
    // __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
    volatile int b = foo + 20;

    return 0;
}

为了变量a和b不被编译器优化掉,这里使用了volatile进行修饰,编译后的汇编指令如下:
在这里插入图片描述
从编译后的代码可以发现,第二次使用foo变量时,没有从内存重新加载,使用了寄存器的值。

把__asm__ volatile ***指令加上之后重新编译

在这里插入图片描述

相比之前,这里多了两个指令,一个lock,一个addl。
lock指令的作用是:在执行lock后面指令时,会设置处理器的LOCK#信号(这个信号会锁定总线,阻止其它CPU通过总线访问内存,直到这些指令执行结束),这条指令的执行变成原子操作,之前的读写请求都不能越过lock指令进行重排,相当于一个内存屏障。

还有一个:第二次使用foo变量时,从内存中重新加载,保证可以拿到foo变量的最新值,这是由如下指令实现

asm volatile ( : : : “cc”, “memory”);
同样是编译器屏障,通知编译器重新生成加载指令(不可以从缓存寄存器中取)。
读取volatile变量
同样在bytecodeInterpreter.cpp文件中,找到getstatic字节码指令的解释器实现。
在这里插入图片描述
通过obj->obj_field_acquire(field_offset)获取变量值
在这里插入图片描述
最终通过OrderAccess::load_acquire实现

inline jint OrderAccess::load_acquire(volatile jint* p) { return *p; }
底层基于C++的volatile实现,因为volatile自带了编译器屏障的功能,总能拿到内存中的最新值。

总结:
volatile编译后JVM 会加上ACC_VOLATILE标识
putstatic字节码指令的解释器里面会使用C++的方法自带了编译器屏障的功能,总能拿到内存中的最新值。
在JVM 种通过acquire()方法实现内存屏障,最终还是反应到了内存屏障

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值