java volatile关键字解惑

作者: 美团Java | 来源:发表于2016-10-30 15:07 被阅读13993次

转载请注明原创出处,谢谢!
简书占小狼
http://www.jianshu.com/users/90ab66c248e6/latest_articles

前言

看着上一篇的更新时间,发现已经挺长时间没有提笔了,只能以忙为自己开脱了,如果太闲都不好意思说自己是程序猿了,正好今天有人问了我一个问题:

当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主存“, 这里的”保证“ 是如何做到的?和 JIT的具体编译后的CPU指令相关吧?

最一开始碰到volatile,我的内心是拒绝的,因为当时做的项目中没有用到,也不清楚可以在什么场景下使用,所以希望这篇文章可以帮助大家理解volatile关键字。

volatile特性

内存可见性:通俗来说就是,线程A对一个volatile变量的修改,对于其它线程来说是可见的,即线程每次获取volatile变量的值都是最新的。

volatile的使用场景

通过关键字sychronize可以防止多个线程进入同一段代码,在某些特定场景中,volatile相当于一个轻量级的sychronize,因为不会引起线程的上下文切换,但是使用volatile必须满足两个条件:
1、对变量的写操作不依赖当前值,如多线程下执行a++,是无法通过volatile保证结果准确性的;
2、该变量没有包含在具有其它变量的不变式中,这句话有点拗口,看代码比较直观。

public class NumberRange {
    private volatile int lower = 0;
     private volatile int upper = 10;

    public int getLower() { return lower; }
    public int getUpper() { return upper; }

    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }

    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

上述代码中,上下界初始化分别为0和10,假设线程A和B在某一时刻同时执行了setLower(8)和setUpper(5),且都通过了不变式的检查,设置了一个无效范围(8, 5),所以在这种场景下,需要通过sychronize保证方法setLower和setUpper在每一时刻只有一个线程能够执行。

下面是我们在项目中经常会用到volatile关键字的两个场景:

1、状态标记量
在高并发的场景中,通过一个boolean类型的变量isopen,控制代码是否走促销逻辑,该如何实现?

public class ServerHandler {
    private volatile isopen;
    public void run() {
        if (isopen) {
           //促销逻辑
        } else {
          //正常逻辑
        }
    }
    public void setIsopen(boolean isopen) {
        this.isopen = isopen
    }
}

场景细节无需过分纠结,这里只是举个例子说明volatile的使用方法,用户的请求线程执行run方法,如果需要开启促销活动,可以通过后台设置,具体实现可以发送一个请求,调用setIsopen方法并设置isopen为true,由于isopen是volatile修饰的,所以一经修改,其他线程都可以拿到isopen的最新值,用户请求就可以执行促销逻辑了。

2、double check
单例模式的一种实现方式,但很多人会忽略volatile关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是100%,说不定在未来的某个时刻,隐藏的bug就出来了。

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

不过在众多单例模式的实现中,我比较推荐懒加载的优雅写法Initialization on Demand Holder(IODH)。

public class Singleton {  
    static class SingletonHolder {  
        static Singleton instance = new Singleton();  
    }  
      
    public static Singleton getInstance(){  
        return SingletonHolder.instance;  
    }  
}  

当然,如果不需要懒加载的话,直接初始化的效果更好。

如何保证内存可见性?

在java虚拟机的内存模型中,有主内存和工作内存的概念,每个线程对应一个工作内存,并共享主内存的数据,下面看看操作普通变量和volatile变量有什么不同:

1、对于普通变量:读操作会优先读取工作内存的数据,如果工作内存中不存在,则从主内存中拷贝一份数据到工作内存中;写操作只会修改工作内存的副本数据,这种情况下,其它线程就无法读取变量的最新值。

2、对于volatile变量,读操作时JMM会把工作内存中对应的值设为无效,要求线程从主内存中读取数据;写操作时JMM会把工作内存中对应的数据刷新到主内存中,这种情况下,其它线程就可以读取变量的最新值。

volatile变量的内存可见性是基于内存屏障(Memory Barrier)实现的,什么是内存屏障?内存屏障,又称内存栅栏,是一个CPU指令。在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM为了保证在不同的编译器和CPU上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

这段文字显得有点苍白无力,不如来段简明的代码:

class Singleton {
    private volatile static Singleton instance;
    private int a;
    private int b;
    private int b;
    public static Singleton getInstance() {
        if (instance == null) {
            syschronized(Singleton.class) {
                if (instance == null) {
                    a = 1;  // 1
                     b = 2;  // 2
                    instance = new Singleton();  // 3
                    c = a + b;  // 4
                }
            }
        }
        return instance;
    } 
}

1、如果变量instance没有volatile修饰,语句1、2、3可以随意的进行重排序执行,即指令执行过程可能是3214或1324。
2、如果是volatile修饰的变量instance,会在语句3的前后各插入一个内存屏障。

通过观察volatile变量和普通变量所生成的汇编代码可以发现,操作volatile变量会多出一个lock前缀指令:

Java代码:
instance = new Singleton();

汇编代码:
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: **lock** addl $0x0,(%esp);

这个lock前缀指令相当于上述的内存屏障,提供了以下保证:
1、将当前CPU缓存行的数据写回到主内存;
2、这个写回内存的操作会导致在其它CPU里缓存了该内存地址的数据无效。

CPU为了提高处理性能,并不直接和内存进行通信,而是将内存的数据读取到内部缓存(L1,L2)再进行操作,但操作完并不能确定何时写回到内存,如果对volatile变量进行写操作,当CPU执行到Lock前缀指令时,会将这个变量所在缓存行的数据写回到内存,不过还是存在一个问题,就算内存的数据是最新的,其它CPU缓存的还是旧值,所以为了保证各个CPU的缓存一致性,每个CPU通过嗅探在总线上传播的数据来检查自己缓存的数据有效性,当发现自己缓存行对应的内存地址的数据被修改,就会将该缓存行设置成无效状态,当CPU读取该变量时,发现所在的缓存行被设置为无效,就会重新从内存中读取数据到缓存中。

END。
我是占小狼。
在魔都艰苦奋斗,白天是上班族,晚上是知识服务工作者。
如果读完觉得有收获的话,记得关注和点赞哦。
非要打赏的话,我也是不会拒绝的。

相关文章

网友评论

  • 追云的帆:最近在学习《Java并发编程的艺术》 。边看书,边看你写的博客,帮助很大。
    美团Java:@追云的帆 :sunglasses:
  • 王虹凯:主题:“每个CPU通过嗅探在总线上传播的数据来检查自己缓存的数据有效性,当发现自己缓存行对应的内存地址的数据被修改,就会将该缓存行设置成无效状态,当CPU读取该变量时,发现所在的缓存行被设置为无效,就会重新从内存中读取数据到缓存中。”

    能保证线程在执行读的时候,如果是volitile修饰的变量可以立即读到,但是如果多个线程都已经读到最新的数据,在分别写的时候,并不会再做判断。 所以使用的时候要特别注意场景。
  • e16377bde5f3:一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32处理器和Intel 64处理器使用MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存的数据在总线上保持一致。例如在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。
  • JAVA编程手记:狼神厉害了
  • cb69808c7f54:博主你好,有个问题想请教一下。在oracle的关于ReentrantReadWriteLock的文档中,给出了一个锁降级的例子。代码里面在释放写锁前有获取了读锁,以防止在线程A释放写锁后线程B又修改了共享变量,导致线程A后面使用data时不是最新的值。这里,如果在声明的data的时候使用volatile关键字修饰,那么,锁降级是否还是必须的呢?也既在释放写锁前不再获取读锁,而是通过volatile关键字来保证可见性?
  • 深度沉迷学习:促销那个例子如果不加volatile是不是也只是时间上的问题,最终都会看到isopen被修改了吧,有其它影响吗?
    美团Java:@深度沉迷学习 对的,时间问题
  • 82ad9df28065:public class ServerHandler {
    private volatile isopen;
    public void run() {
    if (isopen) {
    //促销逻辑
    } else {
    //正常逻辑
    }
    }
    public void setIsopen(boolean isopen) {
    this.isopen = isopen
    }
    }

    这个例子有些不太妥当吧 线程里 是if else 来举这个例子不太好 用一个whlie的比较好吧
  • hongrm:貌似java并发编程艺术那本书有深入讲解了volatile所涉及的系统架构的实现,好像是系统两个模型,还有一种是操作系统来保障的,我得重新回去看下
    hongrm: @占小狼 主要是你写的好,有些是重新回来看的!之前为了面试,现在应该是真的喜欢上IT这个行业了!而且之前看的博客没遇到这么好的,我先看你们怎么分析,怎么写的,后面我也打算写写,虽然我还是个渣渣!
    hongrm: @占小狼 车上看👊
    美团Java:@hongrm 嗯,你这是在一篇一篇的看么?
  • b9d6a9716034:不错不错,收藏了。

    推荐下,源码圈 300 胖友的书单整理:http://t.cn/R0Uflld


  • 1cb4b999bff5:您好!有个问题想请教,如果ACPU进行了一个volatile变量的写,并且向主存中刷出了在这个变量写操作之前普通变量的写,那么Bcpu的嗅探也会标记BCPU中普通变量的数据无效吗?
  • 2445968f2c69:您写单例的那几个全局变量应该是 a b c 吧 您写的是a b b 但是无伤大雅! 写的挺不错的!:+1:
    青峰问水:@蚕小豆 a,b,c三个成员变量,应该不能在静态方法内使用吧?
    美团Java:@蚕小豆 多谢:alarm_clock:
  • 炒鸡大馒头:@awkejiang 这个是 深入理解java虚拟机 这本书里面的,4.2.7节有说明怎么获取汇编代码:grin:
  • 47aff8bcc38c:你好楼主,volatile不能保证原子性这个问题,最简单的那个例子i++,两个线程同时进行,线程1获取了i的值,然后阻塞,这时线程2运行完毕,然后线程1继续运行,这时候结果不是i+2,我一直是这样理解不是原子性的。然后今天看到这句话“这个写回内存的操作会导致在其它CPU里缓存了该内存地址的数据无效”,那这样的话线程1 不会再次从主存中读出新的i值吗?望解惑,感谢!
    美团Java:@小铭_wm 会的
    47aff8bcc38c:@占小狼 就是我第二个线程修改完之后 又写回主存的i值,不会让第一个线程中已经读取了的i值失效吗
    美团Java:@小铭_wm i++不能保证原子性的原因是i++是分成3步完成的,而这三步并没有加锁
  • light先生:楼主对于你提到的多线程修改上下界的问题,添加sychronize关键字是在setUpper和setLower两个函数的定义处吗?
    light先生:@占小狼 放在共享变量的方法块外,能展示下代码吗?
    美团Java:对的,也可以放在操作共享变量的方法块外面
  • 我是许同学:synchronized可以保证可见性吗?如果可以的话,DCL单例使用volatile是不是就多此一举 了?
    我是许同学:@占小狼 懂了,谢谢。指令重排序可能导致instance指向了内存地址,已经不为null,而构造器代码还没有执行。volitile解决的是这个问题。:smile:
    我是许同学:@占小狼 不太懂,能详细说一下吗?
    美团Java:@许子阳 volatile关键字可以禁止指令的重排
  • wyn_做自己:双重检索不加volatile会出现什么bug?能不能举个例子或是简单说一下,这个我不太清楚,求指点
    美团Java:@wyn_做自己 不知道啊,这是什么?
    wyn_做自己:@占小狼 明白了,多谢!顺便问一下,你知道阿里的diamond吗?
    美团Java:@wyn_做自己 就是对象还没有初始化完成,外面已经有使用这个对象了
  • 2130b621c934: 楼主,我觉的你的单例里需要添加
    private Singleton(){}
    虽然知道是单例,需要调用getInstance(),但是防止有人不那么实例化对象
    你觉得呢:smile:
    美团Java:@Mr_OK 有道理
  • F小飞_0295:挺好的,不过要是打赏不是把污染了知识嘛,我们不能让知识染上世俗的味道~~~
    美团Java:哈哈,客官随意
  • 禾禾斗斗:汇编代码:
    0x01a3de1d: movb $0x0,0x1104800(%esi);
    0x01a3de24: **lock** addl $0x0,(%esp);
    ---------------
    这个是用什么命令生成的?javap -c生成的好像不是这样的
    美团Java:很抱歉,这个代码不是我生成的
  • 33d31a1032df:有没有完整的例子?
    33d31a1032df:@占小狼 好吧
    美团Java:@ConanLi 例子网上很多
  • 程序员驿站:写的真心不错
  • 08dad95a0af2:写的不错
    美团Java:@人生设计师 那点个赞呗
  • 20f72fe8d87b:非常好
  • 10bbe900ffc4:2、该变量没有包含在具有其它变量的不变式中。。。

    这个在《并发编程实战》里看到过。
    美团Java:@treenewtreenew :sunglasses:
    我是许同学:@占小狼 总结的很好,那本书我看了,但看了您的总结,我理解的更透彻了。非常感谢分享!
    美团Java:@treenewtreenew 嗯,对的,一些总结

本文标题:java volatile关键字解惑

本文链接:https://www.haomeiwen.com/subject/mrryuttx.html