【并发】volatile详解

一. volatile语义

1. 保证可见性

volatile保证变量对所有线程的可见性,“可见性”是指当某个线程修改了变量的值,新值对于其他线程来说是可以立即得知的,但是volatile变量无法保证“原子性”。

示例如下:

public class VolatileTest {

    public static volatile int race = 0;
    private static final int THREADS_COUNT = 20;
    private static CountDownLatch countDownLatch = new CountDownLatch(THREADS_COUNT);

    public static void increase() {
        race++;
    }

    @SneakyThrows
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                    countDownLatch.countDown();
                }
            });
            threads[i].start();
        }

        countDownLatch.await();
        System.out.println(race);
    }
}

如果能够正确并发的话,最后输出的结果应该是200000,但运行得到的结果小于200000。

问题就出在race++并非原子操作,使用javap反编译这段代码,可以发现只有一行代码的increase方法在Class文件中是由4条字节码指令构成(return指令不是由race++产生)。

public static synchronized void increase();
    Code:
       0: getstatic     #3                  // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #3                  // Field race:I
       8: return

从字节码层面上可以分析出并发失败的原因:当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1, iadd这些指令时,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步回主内存中。

2. 保证有序性(禁止重排序)

普通变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:

  • 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  • 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。

二. 使用条件

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized, 锁或原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

volatile经常用于两个场景:状态标记、double check

三. 使用规则

  • 要求在工作内存中,每次使用volatile变量前都必须先从主内存刷新最新的值,用于保证能看到其他线程对volatile变量所做的修改。
  • 要求在工作内存中,每次修改volatile变量后必须立刻同步回主内存中,用于保证其他线程可以看到本线程对volatile变量所做的修改。
  • 要求volatile变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。

四. volatile原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用lock指令来实现的。

此处使用HSDIPJITWatch来分析volatile的底层实现原理。

1. 示例代码
package org.example;

public class Singleton {

    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (null == instance) {
            synchronized (Singleton.class) {
                if (null == instance) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }
}
  • 注意代码的包路径,下面会用到。
  • 需要进行编译,此处使用maven编译,编译后的class文件位于target目录下。
2. 安装HSDIP插件
链接: https://pan.baidu.com/s/1izSh7z9RRLn7ppt8SJFFMQ  
密码: 9qpc

上述插件适用于MAC系统,下载完毕后将插件放入/Library/Java/JavaVirtualMachines/jdk1.8.0_251.jdk/Contents/Home/jre/lib目录下。

执行以下指令可以在控制台输出汇编代码:

java -XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly 
-Xcomp 
-XX:CompileCommand=dontinline,org/example/Singleton::getInstance
-XX:CompileCommand=compileonly,org/example/Singleton::getInstance 
org/example/Singleton

如果需要使用JITWatch进行分析,则需要输出日志文件,以下指令可以将结果输出到test.log文件中。

java -XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly 
-Xcomp 
-XX:CompileCommand=dontinline,org/example/Singleton::getInstance
-XX:CompileCommand=compileonly,org/example/Singleton::getInstance
-XX:+TraceClassLoading 
-XX:+LogCompilation 
-XX:LogFile=./test.log 
org/example/Singleton
3. 安装JITWatch

JITWatch的安装启动可参见官方github说明: https://github.com/AdoptOpenJDK/jitwatch

启动成功后,需要先配置源码路径以及class路径,
本项目路径为/workspace/hello,因此源码路径配置为/workspace/hello/src/main/javaclass路径配置为/workspace/hello/target/classes

配置完成后,导入test.log文件,并点击start按钮,通过TriView界面即可看到源码、字节码以及汇编代码。

在这里插入图片描述

接下来将上述代码的volatile关键词去掉,重新编译并且导出文件,通过JITWatch工具可再次获得汇编代码。

在这里插入图片描述

4. 原理分析

通过对比发现,关键变化在于有volatile修饰的变量,赋值后多执行了一个lock addl $0x0,(%rsp)操作,这个操作的作用相当于一个内存屏障(Memory BarrierMemory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置)。

指令中的lock addl $0x0,(%rsp)(把RSP寄存器的值加0)显然是一个空操作,之所以用这个空操作而不是空操作专用指令nop,是因为lock前缀不允许配合nop指令使用。这里的关键在于lock前缀,它的作用是将本处理器的缓存写入内存,该写入动作也会引起别的处理器或者别的内核无效化其缓存,通过这样一个空操作,可让前面volatile变量的修改对其他处理器立即可见。

那为何说它禁止指令重排序呢?从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理,但并不是说指令任意重排,处理器必须能正确处理指令依赖保障程序能得出正确的执行结果。

所以在同一个处理器中,重排序过的代码看起来仍然是有序的。因此lock addl $0x0,(%rsp)指令把修改同步到内存中,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。

五. 参考资料

《深入理解Java虚拟机第三版》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值