The Myth of volatile

作者: 大宽宽 | 来源:发表于2017-12-18 13:11 被阅读187次
    Gollum
    历史成为了传说,传说又成为了神话,两千五百多年来,无人得知至尊魔戒的下落。直到,当机缘来临,它又诱惑了一个新的持有者。
    我——的——宝——贝————
    

    这段文字是指环王的开篇旁白。但我觉得用来形容volatile关键字却再合适不过了。volatile的字面意思是“易变的,反复无常的”,但它实际的意思却复杂得多。大量的初学者面对着它无比渴求,希望一窥究竟,却很难在实际项目中用对。同时,最令人讨厌的是面试时还经常被问到它。

    本文尝试为众生梳理梳理Java的volatile。如果你觉得本文内容比较长,请直接跳到结论。

    JDK1.5之前的volatile

    JDK1.5之前,volatile还是比较好理解的,即volatile是设计被用来简单解决变量可见性的。听上去很玄乎?容我来说明一下。

    多核CPU访问内存模型

    上图是一个一般的CPU和内存的体系结构的简化示意图。程序运行时,每个线程会被调度到某一个CPU内核上。为了提高性能,CPU内核都有自己的缓存。所以当线程1访问某个主存内的变量A时,该变量会被复制到核内缓存上。这就意味着,如果同一个进程的不同线程表面上访问一个变量时,实际上访问的变量是不同的。如果是只读,一切安好。但一旦某个线程改变了变量的值,默认的行为是:这个变化只会发生在核内缓存,不会立刻更新到主内存,更不会让运行在其他核的线程看到。至于什么时候这个变化能让其他线程知道是无法定义的,也许一会就可以了,也许直到进程结束也不会。

    例如,原来这个变量是3。线程1和2都读取了变量。然后线程1将变量修改为8。这个变化只有他自己能看得见。

    多核CPU对内存的修改不直接对外可见

    这个问题被简称为变量的可见性问题volatile关键字用来解决这个问题。一旦一个变量被标记为volatile,编译器就要求该变量被写入时生成一组代码,使得

    • 这个变量在写入本核缓存的同时也被写入主存
    • 标记所有其他核的包含同一个变量的Cache为失效。这样下次其他线程读取变量时,自然就会通过主存取到最新值。

    简单来说,将一个变量声明为volatile就保证了一个共享变量的可见性

    public class SharedObj {
      public volatile int sharedVar = 1; // 该变量对所有线程可见
    }
    

    但是,保证一个变量的可见性并不代表代码就是正确的。例如,考虑以下代码。假设有两个任务,一个计算;一个等待计算完成,并显示计算结果。

    public class SharedObj {
      public int calcResult = -1; // 表示计算结果, -1表示还没计算呢
      public volatile boolean jobDone = false; // 表示计算任务是否已经做完
    }
    
    public CalcJob extends Thread {
      private SharedObj sharedObj;
      public CalJob(SharedObj so) {
        sharedObj = so;
      }
      
      public void run() {
        // 耗时很长的计算任务
        so.calcResult = xxxx; // 设置计算结果
        so.jobDone = true; // 表示任务已经完成了
      }
    }
    
    public ShowJob extends Thread {
      private SharedObj sharedObj;
      public ShowJob(SharedObj so) {
        sharedObj = so;
      }
      
      public void run() {
        while(!so.jobDone) {
          // 如果job没完,则等待。注意实际中尽量避免使用sleep。这里仅仅为示例。
          Thread.sleep(10000); 
        };
        System.out.println(so.calcResult);
      }
    }
    
    // ... 拼接代码
    SharedObj so = new SharedObj;
    new CalcJob(so).start();
    new ShowJob(so).start();
    

    看起来还不错,但是在JDK1.5之前,这段代码有可能打不出正确的结果。这是为什么?这是出于两个原因:

    • volatile只保证声明为volatile关键字的变量,但是不会管其他变量。你可以留意到caclResult并没被声明为volatile。所以它对于其他线程不保证可见。
    • volatile不保证代码顺序

    在JDK1.5之前CaclJob的代码

    public void run() {
        // 耗时很长的计算任务
        so.calcResult = xxxx; // 设置计算结果
        so.jobDone = true; // 表示任务已经完成了
    }
    

    在编译时可能会把so.jobDone = true这条指令编译到了设置计算结果之前!而ShowJob的代码也会出现类似的问题——代码可能会被编译成是“先输出结果,再进入循环等待”。编译器之所以这样做是为了提高程序性能——编译器应该在保证结果正确的情况下找到执行效率最高的代码执行顺序。在单线程时,这种优化很好,calcResultjobDone哪个被先设置都不影响最终结果。因为只有当方法CalcJob#run结束了,才轮得到其他代码执行;而在多线程执行时,这个前提明显不成立,因此可能会引起灾难性后果。

    这么一个半残的volatile显然对于实际开发没有什么用处。为此,JDK1.5做了一个巨大的改进。

    JDK1.5之后的volatile

    JDK1.5实现了一个规范"JSR133"——Java Memory Model and Thread Specification Revision。JSR133内容很多,其中一个要点是引入了一个名词"Happens-Before"。这实际上是两条规则:

    • 第一点,编译器可以生成与原始代码顺序不同的代码,但是乱序不可以跨越对声明为voltaile的变量读写。即在volatile变量访问前的代码不可以乱序到访问后;访问后的代码不可以乱序到访问前。
    volatile保证编译不会肆意乱序!

    这样一来,上一节提到的两个线程交互的例子成为可能。

    • 第二点,如果线程1写入一个声明为volatile共享变量,线程2读取这个共享变量。那么在线程1写入共享变量之前,所有对线程1可见的变量,在线程2读取共享变量之后的代码均可见

    这个规则读起来比较绕,但关键点在于,volatile不再是只影响1个变量,而是会影响多个变量的可见性。假设SharedObj有a,b,c,d4个普通变量,和一个声明为volatile的x。

    ThreadA: 
        so.a = 1, so.b = 2, so.c = 3, so.d = 4;
        so.x = 10;
    ThreadB:
        int x = so.x;
        System.out.println(String.format("%d %d %d %d", so.a, so.b, so.c, so.d));
    

    对于上面的代码,假如实际执行的顺序是ThreadA先执行完,ThreadB再执行的,那么最终一定会输出1,2,3,4。ThreadA设置了so.x=10后,a,b,c,d还不一定对外可见。ThreadB在尚未执行int x = so.x时,不一定能看见a,b,c,d;但是一旦ThreadB执行了int x = so.x, "Happens-Before"保证,后续代码一定能看到a,b,c,d最新被修改的值。

    你可能想知道为什么这个保证能够得到实现。似乎一个关键字在满足了一个复杂的条件下,达成一个很反人类常识的结果。这超出了本文的范围,也许可以找个时间专门探究一下。不过目前相信你能够理解,通过volatile关键字可以保证JDK1.5之前会出错的代码可以正确执行。

    volatile足够了吗?

    答案明显是否定的。即便提供了如此复杂的Happens-Before保证,使得volatile更好地解决了变量的可见性。但是可见性问题并不普遍。业务中更加普遍的问题是竞争条件。一个简单的例子就是计数器——不同线程对同一个变量执行“读取、+1、写入”操作。

    可见性不能解决竞争问题

    图中两个线程都看到了变量值为5,都尝试对其加1,然后都尝试写入主存。这时主存里的值就变成了6,而非期望的7。这个6对两个线程都可见。于是乎下次再更新,又会重复上面的过程。计数的值会一直错下去。

    对于存在竞争条件的场景,唯一的办法是使用锁来同步。volatile此时完全帮不上忙。

    volatile VS 锁

    很多同学纠结于,volatile的实现比锁要快。所以能用volatile不用锁的地方就尽量不用锁。这样说对也不对。

    的确,volatile的实现是基于缓存一致性协议,说白了就是缓存和内存数据的同步和失效控制。这套机制不影响多核并发工作——这个核在刷缓存时,别的核该干啥干啥。而锁的底层实现需要锁总线,即总线被一个CPU核独占,其他CPU核都停止与总线的交互。这样的性能的损失的确是比较大的。但是,Java的锁一般都会实现成“先尝试用CAS+volatile机制尝试乐观锁,实在抢不到锁再上LOCK大杀器”的方式。所以在竞争不是很激烈时,锁的运行效率并不是很差。一般业务上的竞争都不会特别激烈,否则就要重新设计业务使其独立性更好。

    但在实现高性能同步机制时,volatile是必要的,而且往往是关键所在。但这要伴随着精巧的设计(比如,如何将复杂问题拆解为一个个可见性问题?),和严格的压力测试。有兴趣的同学可以参考LMAX Distruptor的设计实现。

    在业务上锁有一个好处是,volatile能实现的功能,锁都可以实现。锁同步后数据访问一定是同步的,总是能得到变量的最新值。这样在业务上的“容忍度”就更好——业务变得更复杂时,锁还是那个锁,没有变化。基于锁实现的各种同步工具(如BlockingQueue、Semphore等)也能更容易的融入业务设计。而用使用volatile就有相关代码彻底重写的可能性。

    总之,在不能用证据判定volatile成为系统瓶颈之前,尽量不要使用它。

    结论

    现在你也许可以明白volatile之所以难用,是因为它的应用场景非常窄——仅用于多线程之间的变量可见性同步。为了使用它,开发者必须识别出要解决的问题刚好是一个可见性的问题。我的从业生涯中,遇到的问题的比例大概如下图所示。如果没有做过系统及开发,大概率后两种问题都不会遇到。

    可见性问题在实际业务中偏窄

    业务需求往往变化很快。如果一个业务场景,一开始是一个可见性问题,使用volatile实现了一套代码;但后边业务需求变化升级为一个竞争条件问题。那么之前所有的开发全部要废弃,改用锁同步重新实现。这样看起来得不偿失。

    此外,业务开发工程师往往会将大部分注意力放在业务模型和业务细节处理上,很容易忽略volatile的局限性。并发的代码也很难测试,数据量少的时候测试环境里的bug也许只是偶发出现,不能必然复现。追查起来也更加耗费精力。但是到了生产环境,数据量大了之后,就会频频出现问题,令用户抱怨。

    因此,我的建议是:

    • 如果你是个业务开发工程师,每天处理接口、业务逻辑、用户请求等,请忘记volatile。如果你真的需要线程间需要传递一些信息,尽量用更高级的同步工具,单机的如BlockingQueue,Semphore、CountDownLatch;分布式的如Message Queue、数据库、redis等。
    • 如果你需要解决一个volatile很合适的场景,请反复调查一下还有没有其他更成熟更直接的高层工具可以使用。比如如果你的任务真的是一个管理线程A,需要根据某些指令停掉工作线程B、C、D,请优先考虑InteruptException而不是volatile
    • 如果你是个系统工程师,在编写队列中间件、RPC等基础设施。请在开工之前反复阅读JSR133,并保证自己深刻理解了各种概念;鉴于volatile比较底层,尽量使用其封装一些工具(比如一个根据业务定制设计的锁),而非直接将其使用于数据同步。

    嗯,在白话了这么久之后的结论就是,volatile要慎用慎用再慎用

    相关文章

      网友评论

        本文标题:The Myth of volatile

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