天天看点

深度解析volatile关键字,就是这么简单

点击上方 "程序员小乐"关注, 星标或置顶一起成长

后台回复“大礼包”有惊喜礼包!

深度解析volatile关键字,就是这么简单
关注订阅号「程序员小乐」,收看更多精彩内容
           

每日英文

Sometimes, you don't get over things. You just learn to live with the pain.-有时候,我们并非走出了伤痛,不过是学会了带着伤痛继续生活。

每日掏心话

有时候你把什么放下了,不是因为突然就舍得了,是因为期限到了,任性够了,成熟多了,也就知道这一页该翻过去了。

来自:谭嘉俊 | 责编:乐乐

链接:juejin.im/user/2400989124522446

程序员小乐(ID:study_tech)第 1036 次推文

往日回顾:快手公司厕所装坑位计时器,网友:再也不能带薪拉屎了!

   正文   

/   开始   /

本文章讲解的内容是深入了解volatile关键字,建议对着示例项目阅读文章,示例项目链接如下:

VolatileDemo

https://github.com/TanJiaJunBeyond/VolatileDemo

查看汇编代码的hsdis-amd64.dylib文件链接如下:

hsdis-amd64.dylib

https://github.com/TanJiaJunBeyond/VolatileDemo/blob/master/hsdis-amd64.dylib

关键字volatile是Java虚拟机提供的最轻量级的同步机制,当一个变量被关键字volatile修饰之后,它有如下两个特性:

  • 保证了这个变量对所有线程的可见性
  • 禁止指令重排序优化

/   保证变量对所有线程的可见性   /

关键字volatile可以保证变量对所有线程的可见性,也就是当一个线程修改了这个变量的值,其他线程能够立即得到修改的值。普通变量是做不到这样,普通变量的值需要通过主内存在线程之间传递。

举个例子:线程A修改一个普通变量的值,然后传送给主内存,另外一个线程B需要等到传送完主内存后才能够从主内存进行读取操作,这样变量最新的值才会对线程B可见。先看下如下例子,代码如下所示:

/**
 * Created by TanJiaJun on 2020-08-16.
 */
class VolatileDemo {

    private static final int THREADS_COUNT = 10;

    private static volatile int value = 0;

    private static void increase() {
        // 对value变量进行自增操作
        value++;
    }

    public static void main(String[] args) {
        // 创建10个线程
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++)
                    // 每个线程对value变量进行1000次自增操作
                    increase();
            });
            threads[i].start();
        }
        // 主线程等待子线程运行结束
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("value的值:" + value);
    }

}

           

这段代码的意思是发起10个线程,然后每个线程对value变量进行1000次自增操作,如果这段代码正确地并发操作,最后的结果value的值应该是10000,但是实际上多次运行后,value的值都是小于等于10000的值。

搜索公众号程序员小乐回复关键字“Java”,获取Java面试题和答案。

这段代码中increase方法调用i++,也就是i = i + 1,它不是原子性操作,Java内存模型直接保证的原子性变量操作包括read、load、assign、use、store和write,我们可以认为基本数据类型的读写都具备原子性,有个例外就是long和double的非原子性协定,不过我们无须太过在意,虽然Java内存模型允许虚拟机不把long和double的变量的读写实现为原子性操作。

但是现在的商用虚拟机都几乎把这些操作实现为原子性操作,原子性操作是指执行一系列操作,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况,举个例子:i = 1就是个原子性操作,但是i = i + 1就不是原子性操作,因为这个操作是由多条字节码指令构成的,我用Javap反编译上面的示例代码,先找到生成的Class文件,路径是

/Users/tanjiajun/IdeaProjects/VolatileDemo/out/production/VolatileDemo/VolatileDemo.class

就是在VolatileDemo目录下的out文件夹中,然后执行javap -p -v VolatileDemo命令,生成如下字节码:

(由于源码过长,想详细了解的可以到原文章阅读)

然后找到对应的increase方法的字节码,字节码如下所示:

private static void increase();
    descriptor: ()V
    flags: (0x000a) ACC_PRIVATE, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #7                  // Field value:I
         3: iconst_1
         4: iadd
         5: putstatic     #7                  // Field value:I
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8

           

可以看到value++是由四条指令构成的,分别是getstatic、iconst_1、iadd和putstatic,getstatic指令是获取静态字段value的值并且放入操作栈顶,iconst_1指令是把常量1放入操作栈顶,iadd指令是把当前操作栈顶中两个值相加并且把结果放入操作栈顶,putstatic指令是把操作栈顶的结果赋值给静态变量value,关键字volatile可以保证执行getstatic指令后的值是正确的。

如果在并发环境下,可能有其他线程在执行iconst_1指令或者iadd指令时,增加了value的值,导致操作栈顶的值就变成了过期的数据,在执行putstatic指令后可能把较小的value的值同步回主内存中,导致不能得到正确的结果。

从上面的例子可以得知,volatile变量只保证可见性,以下两条规则的运算环境可以保证这些操作的原子性:

  • 只有单条线程修改变量的值,运算结果不依赖变量当前的值,也就是说不依赖产生的中间结果。
  • 变量不需要与其他的状态变量共同参与不变约束。

如果不符合以上两条规则的话,就需要通过加锁来保证这些操作的原子性,可以使用关键字synchronized或者java.util.concurrent中的原子类。

/   禁止指令重排序优化   /

Java内存模型中的一个语义是线程内表现为串行的语义(Within-Thread As-If-Serial Semantics),它是指普通变量只能保证在该方法在执行过程中所有依赖赋值结果的地方都能得到正确的结果,但是不保证变量的赋值操作的顺序和程序代码中的执行顺序是一致的。举个例子,代码如下所示:

int i = 1;
int j = 2;
int k = i + j;

           

上面这段代码大概执行了以下步骤:

  1. 将常量1赋值给i
  2. 将常量2赋值给j
  3. 取到i的值
  4. 取到j的值
  5. 将i的值和j的值相加后赋值给k

在上面这五个步骤中,步骤1可能会和步骤2和步骤4重排序,步骤2可能会和步骤1和步骤3重排序,步骤3可能会和步骤2和步骤4重排序,步骤4可能会和步骤1和步骤3重排序,但是步骤1、步骤3和步骤5之间不能重排序,步骤2、步骤4和步骤5之间不能重排序,因为它们之间存在依赖关系,一旦重排序,线程表现为串行的语义将无法得到保证。

再看个例子,使用双重检查锁定(DCL)实现单例模式,代码如下所示:

/**
 * Created by TanJiaJun on 2020/8/23.
 */
class Singleton {

    // 用关键字volatile修饰变量sInstance,禁止指令重排序优化
    private static volatile Singleton sInstance;

    // 私有构造方法
    private Singleton() {
        // 防止通过反射调用构造方法导致单例失效
        if (sInstance != null)
            throw new RuntimeException("Cannot construct a singleton more than once.");
    }

    // 获取单例的方法
    public static Singleton getInstance() {
        // 第一次判断sInstance是否为空,用于判断是否需要同步,提高性能和效率
        if (sInstance == null) {
            // 使用synchronized修饰代码块,取Singleton的Class对象作为锁对象
            synchronized (Singleton.class) {
                // 第二次判断sInstance是否为空,用于判断是否已经创建实例
                if (sInstance == null) {
                    // 创建Singleton对象
                    sInstance = new Singleton();
                }
            }
        }
        // 返回sInstance
        return sInstance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }

}

           

然后使用HSDIS插件反汇编上面的代码,我只截取了对变量sInstance赋值(第25行)的那部分汇编代码,如果想要看全部的汇编代码,可以在查看SingletonAssemblyCodeWithVolatile.log,汇编代码如下所示:

0x000000011b33f4c7:   mov    0x38(%rsp),%rax
  0x000000011b33f4cc:   movabs $0x61ff0ac48,%rdx            ;   {oop(a &apos;java/lang/Class&apos;{0x000000061ff0ac48} = &apos;Singleton&apos;)}
  0x000000011b33f4d6:   movsbl 0x30(%r15),%esi
  0x000000011b33f4db:   cmp    $0x0,%esi
  0x000000011b33f4de:   jne    0x000000011b33f6e9
  0x000000011b33f4e4:   mov    %rax,%r10
  0x000000011b33f4e7:   shr    $0x3,%r10
  0x000000011b33f4eb:   mov    %r10d,0x70(%rdx)
  0x000000011b33f4ef:   lock addl $0x0,-0x40(%rsp)
  0x000000011b33f4f5:   mov    %rdx,%rsi
  0x000000011b33f4f8:   xor    %rax,%rsi
  0x000000011b33f4fb:   shr    $0x15,%rsi
  0x000000011b33f4ff:   cmp    $0x0,%rsi
  0x000000011b33f503:   jne    0x000000011b33f708           ;*putstatic sInstance {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - Singleton::[email protected] (line 25)

           

然后把代码中的关键字volatile去掉,再生成汇编代码,我只截取了对变量sInstance赋值(第25行)的那部分汇编代码,如果想要看全部的汇编代码,可以在查看SingletonAssemblyCodeWithNoVolatile.log,汇编代码如下所示:

搜索公众号程序员小乐回复关键字“offer”,获取算法面试题和答案。

0x0000000116f2a4c7:   mov    0x38(%rsp),%rax
  0x0000000116f2a4cc:   movabs $0x61ff0acb8,%rdx            ;   {oop(a &apos;java/lang/Class&apos;{0x000000061ff0acb8} = &apos;Singleton&apos;)}
  0x0000000116f2a4d6:   movsbl 0x30(%r15),%esi
  0x0000000116f2a4db:   cmp    $0x0,%esi
  0x0000000116f2a4de:   jne    0x0000000116f2a6e1
  0x0000000116f2a4e4:   mov    %rax,%r10
  0x0000000116f2a4e7:   shr    $0x3,%r10
  0x0000000116f2a4eb:   mov    %r10d,0x70(%rdx)
  0x0000000116f2a4ef:   mov    %rdx,%rsi
  0x0000000116f2a4f2:   xor    %rax,%rsi
  0x0000000116f2a4f5:   shr    $0x15,%rsi
  0x0000000116f2a4f9:   cmp    $0x0,%rsi
  0x0000000116f2a4fd:   jne    0x0000000116f2a700           ;*putstatic sInstance {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - Singleton::[email protected] (line 25)

           

通过对比可以发现,如果变量sInstance被关键字volatile修饰,会在赋值(mov    %r10d,0x70(%rdx))后多执行一个lock addl $0x0,-0x40(%rsp)指令,这个指令是一个内存屏障(Memory Barrier),它可以使内存屏障前的指令和内存屏障后的指令不会因为系统优化而导致乱序执行,后面会详细讲解,lock addl $0x0,-0x40(%rsp)(%rsp是堆栈指针寄存器,通常会指向栈顶位置,堆栈的pop操作和push操作是通过改变%rsp的值来移动堆栈指针的位置来实现)是一个空操作。

查询IA32手册可得知,使用这个空操作,而不是使用空操作指令nop是因为前缀lock不允许配合nop指令使用,其中前缀lock,查询IA32手册可得知,它的作用是使得本CPU的缓存写入内存,相当于对缓存中的变量执行store操作和write操作,这个写入动作可以让其他CPU或者别的内核无效化(Invalidata)其缓存,可以让前面对被关键字volatile修饰的变量的修改对其他线程立即可见。

/   内存屏障   /

内存屏障(Memory Barrier),也称为内存栅栏、内存栅障和屏障指令等,是一类同步屏障指令,它使得CPU或者编译器在对内存进行操作的时候,严格按照一定的顺序执行,大多数现代计算机为了提高性能而采用乱序执行,它就可以使内存屏障前的指令和内存屏障后的指令不会因为系统优化而导致乱序执行。

内存屏障的语义是内存屏障前的所有写操作都要写入内存,内存屏障后的所有读操作都可以获得同步屏障之前的读操作的结果。

内存屏障可以分为以下四种类型:

LoadLoad屏障

序列:①Load1②LoadLoad③Load2

确保Load1要载入的数据能够在被Load2和后面的load指令载入数据前载入。

StoreStore屏障

序列:①Store1②StoreStore③Store2

确保Store1要存储的数据能够在Store2和后面的store指令同步回主内存前对其它处理器可见。

LoadStore屏障

序列:①Load1②LoadStore③Store2

确保Load1要载入的数据能够在Store2和后面的store指令同步回主内存前载入。

StoreLoad屏障

序列:①Store1②StoreLoad③Load2

确保Store1要存储的数据能够在Load2和后面的load指令载入数据前对其它处理器可见。它是这四种内存屏障中开销最大的,它也是一个万能屏障,具有其它三种内存屏障的功能。

下图展示了这些内存屏障如何符合JSR-133排序规则:

深度解析volatile关键字,就是这么简单

举个例子,代码如下所示:

/**
 * Created by TanJiaJun on 2020/8/23.
 */
class MemoryBarrierTest {

    private int a, b;
    private volatile int c, d;

    private void test() {
        int i, j;
        i = a; // load a
        j = b; // load b
        i = c; // load c
        // LoadLoad
        j = d; // load d
        // LoadStore
        a = i; // store a
        b = j; // store b
        // StoreStore
        c = i; // store c
        // StoreStore
        d = j; // store d
        // StoreLoad
        i = d; // load d
        // LoadLoad
        // LoadStore
        j = b; // load b
        a = i; // store a
    }

}

           

另外,为了保证关键字final的特殊语义,会在下面的序列中加入内存屏障:

①x.finalField = v;②StoreStore③sharedRef = x;

/   总结   /

总结下Java内存模型中对被关键字volatile修饰的变量进行read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)和write(写入)操作定义的特殊规则:

假设有一个线程A,有一个被关键字volatile修饰的变量i;只有当线程A对变量i执行的前一个操作是load操作的时候,线程A才能对变量i进行use操作;并且,只有线程A对变量i执行的后一个操作是use操作的时候,线程A才能对变量i执行load操作,也就是说,线程A对变量i执行use操作是和对其执行read操作和load操作相关联的,它们都必须要连续一起出现。

这条规则要求在工作内存中,每次使用volatile变量都必须从主内存中刷新最新的值,用于保证能看见其他线程对volatile变量的修改后的值。

假设有一个线程A,有一个被关键字volatile修饰的变量i;只有当线程A对变量i执行的前一个操作是assign操作的时候,才能对其进行store操作;并且,只有线程A对变量i执行后一个操作是store操作的时候,线程A才能对变量i进行assign操作,也就是说,线程A对变量i执行assign操作是和对其执行store操作和write操作相关联的,它们都必须要连续一起出现。

这条规则要求在工作内存中,每次修改volatile变量时都要立刻同步回主内存,用于保证其他线程能看见volatile变量修改后的值。

假设有一个线程A,有两个被关键字volatile修饰的变量,分别为i和j;假定动作A是线程A对volatile变量i执行use操作或者assign操作,假定动作B是和动作A相关联的load操作或者store操作,假定动作C是和动作B相关联的read操作或者write操作;假定动作D是线程A对volatile变量j执行use操作或者assign操作,假定动作E是和动作D相关联的load操作或者store操作,假定动作F是和动作E相关联的read操作或者write操作;如果动作A先于动作D,那么动作C先于动作F。

这条规则要求被关键字volatile修饰的变量不会被指令重排序优化,保证了代码的执行顺序和程序的顺序相同。

深度解析volatile关键字,就是这么简单

欢迎在留言区留下你的观点,一起讨论提高。如果今天的文章让你有新的启发,欢迎转发分享给更多人。欢迎加入程序员小乐加群”或者“阿里、腾讯、百度、华为、京东最新面试题汇集

delete后加 limit是个好习惯么 !

Centos7搭建k8s环境教程,一次性成功,收藏了!

还在用if(obj!=null)做非空判断?带你快速上手Optional实战性理解!

嘿,你在看吗?

深度解析volatile关键字,就是这么简单