不得不提的volatile及指令重排序(happen-before)

 

微信公众号【黄小斜】大厂程序员,互联网行业新知,终身学习践行者。关注后回复「Java」、「Python」、「C++」、「大数据」、「机器学习」、「算法」、「AI」、「Android」、「前端」、「iOS」、「考研」、「BAT」、「校招」、「笔试」、「面试」、「面经」、「计算机基础」、「LeetCode」 等关键字能够获取对应的免费学习资料。 html

 

 

                     

1、不得不提的volatile

volatile是个很老的关键字,几乎伴随着JDK的诞生而诞生,咱们都知道这个关键字,但又不太清楚何时会使用它;咱们在JDK及开源框架中随处可见这个关键字,但并发专家又每每建议咱们远离它。好比Thread这个很基础的类,其中很重要的线程状态字段,就是用volatile来修饰,见代码前端

 /* Java thread status for tools,
     * initialized to indicate thread 'not yet started'
     */
 
    private volatile int threadStatus = 0;

如上面所说,并发专家建议咱们远离它,尤为是在JDK6的synchronized关键字的性能被大幅优化以后,更是几乎没有使用它的场景,但这仍然是个值得研究的关键字,研究它的意义不在于去使用它,而在于理解它对理解Java的整个多线程的机制是颇有帮助的。java

1. 例子

先来体会一下volatile的做用,从下面代码开始linux

   1:  public class VolatileExample extends Thread{
   2:      //设置类静态变量,各线程访问这同一共享变量
   3:      private static boolean flag = false;
   4:      
   5:      //无限循环,等待flag变为true时才跳出循环
   6:      public void run() {while (!flag){};}
   7:      
   8:      public static void main(String[] args) throws Exception {
   9:          new VolatileExample().start();
  10:          //sleep的目的是等待线程启动完毕,也就是说进入run的无限循环体了
  11:          Thread.sleep(100);
  12:          flag = true;
  13:      }
  14:  }

这个例子很好理解,main函数里启动一个线程,其run方法是一个以flag为标志位的无限循环。若是flag为true则跳出循环。当main执行到12行的时候,flag被置为true,按逻辑分析此时线程该结束,即整个程序执行完毕。程序员

执行一下看看是什么结果?结果是使人惊讶的,程序始终也不会结束。main是确定结束了的,其缘由就是线程的run方法未结束,即run方法中的flag仍然为false。面试

把第3行加上volatile修饰符,即算法

private static volatile boolean flag = false;

再执行一遍看看?结果是程序正常退出,volatile生效了。编程

咱们再修改一下。去掉volatile关键字,恢复到起始的例子,而后把while(!flag){}改成while(!flag){System.out.println(1);},再执行一下看看。按分析,没有volatile关键字的时候,程序不会执行结束,虽然加上了打印语句,但没有作任何的关键字/逻辑的修改,应该程序也不会结束才对,但执行结果倒是:程序正常结束。缓存

有了这些感性认识,咱们再来分析volatile的语义以及它的做用。微信

2.volatile语义

volatile的第一条语义是保证线程间变量的可见性,简单地说就是当线程A对变量X进行了修改后,在线程A后面执行的其余线程能看到变量X的变更,更详细地说是要符合如下两个规则:

  • 线程对变量进行修改以后,要马上回写到主内存。
  • 线程对变量读取的时候,要从主内存中读,而不是缓存。

要详细地解释这个问题,就不得不提一下Java的内存模型(Java Memory Model,简称JMM)。Java的内存模型是一个比较复杂的话题,属于Java语言规范的范畴,我的水平有限,不能在有限篇幅里完整地讲述清楚这个事,若是要清晰地认识,请学习《深刻理解Java虚拟机-JVM高级特性与最佳实践》和《The Java Language Specification, Java SE 7 Edition》,这里简单地引用一些资料略加解释。

Java为了保证其平台性,使Java应用程序与操做系统内存模型隔离开,须要定义本身的内存模型。在Java内存模型中,内存分为主内存和工做内存两个部分,其中主内存是全部线程所共享的,而工做内存则是每一个线程分配一份,各线程的工做内存间彼此独立、互不可见,在线程启动的时候,虚拟机为每一个内存分配一块工做内存,不只包含了线程内部定义的局部变量,也包含了线程所须要使用的共享变量(非线程内构造的对象)的副本,即为了提升执行效率,读取副本比直接读取主内存更快(这里能够简单地将主内存理解为虚拟机中的堆,而工做内存理解为栈(或称为虚拟机栈),栈是连续的小空间、顺序入栈出栈,而堆是不连续的大空间,因此在栈中寻址的速度比堆要快不少)。工做内存与主内存之间的数据交换经过主内存来进行,以下图:QQ截图20131228132842

同时,Java内存模型还定义了一系列工做内存和主内存之间交互的操做及操做之间的顺序的规则(这规则比较多也比较复杂,参见《深刻理解Java虚拟机-JVM高级特性与最佳实践》第12章12.3.2部分),这里只谈和volatile有关的部分。对于共享普通变量来讲,约定了变量在工做内存中发生变化了以后,必需要回写到工做内存(早晚要回写但并不是立刻回写),但对于volatile变量则要求工做内存中发生变化以后,必须立刻回写到工做内存,而线程读取volatile变量的时候,必须立刻到工做内存中去取最新值而不是读取本地工做内存的副本,此规则保证了前面所说的“当线程A对变量X进行了修改后,在线程A后面执行的其余线程能看到变量X的变更”。

大部分网上的文章对于volatile的解释都是到此为止,但我以为仍是有遗漏的,提出来探讨。工做内存能够说是主内存的一份缓存,为了不缓存的不一致性,因此volatile须要废弃此缓存。但除了内存缓存以外,在CPU硬件级别也是有缓存的,即寄存器。假如线程A将变量X由0修改成1的时候,CPU是在其缓存内操做,没有及时回写到内存,那么JVM是没法X=1是能及时被以后执行的线程B看到的,因此我以为JVM在处理volatile变量的时候,也一样用了硬件级别的缓存一致性原则(CPU的缓存一致性原则参见《Java的多线程机制系列:(二)缓存一致性和CAS》。

volatile的第二条语义:禁止指令重排序。关于指令重排序请参见后面的“指令重排序”章节。这是volatile目前主要的一个使用场景。

3. volatile不能保证原子性

介绍volatile不能保证原子性的文章比较多,这里就不举详细例子了,你们能够去网上查阅相关资料。在多线程并发执行i++的操做结果来讲,i加与不加volatile都是同样的,只要线程数足够,必定会出现不一致。这里就其为何不能保证原子性的原理说一下。

上面提到volatile的两条语义保证了线程间共享变量的及时可见性,但整个过程并无保证同步(参见《Java的多线程机制系列:(一)总述及基础概念》中对“锁”的两种特性的描述),这是与volatile的使命有关的,创造它的背景就是在某些状况下能够代替synchronized实现可见性的目的,规避synchronized带来的线程挂起、调度的开销。若是volatile也能保证同步,那么它就是个锁,能够彻底取代synchronized了。从这点看,volatile不可能保证同步,也正基于上面的缘由,随着synchronized性能逐渐提升,volatile逐渐退出历史舞台。

为何volatile不能保证原子性?以i++为例,其包括读取、操做、赋值三个操做,下面是两个线程的操做顺序2

假如说线程A在作了i+1,但未赋值的时候,线程B就开始读取i,那么当线程A赋值i=1,并回写到主内存,而此时线程B已经再也不须要i的值了,而是直接交给处理器去作+1的操做,因而当线程B执行完并回写到主内存,i的值仍然是1,而不是预期的2。也就是说,volatile缩短了普通变量在不一样线程之间执行的时间差,但仍然存有漏洞,依然不能保证原子性。

这里必需要提的是,在本章开头所说的“各线程的工做内存间彼此独立、互不可见,在线程启动的时候,虚拟机为每一个内存分配一块工做内存,不只包含了线程内部定义的局部变量,也包含了线程所须要使用的共享变量(非线程内构造的对象)的副本,即为了提升执行效率”并不许确。现在的volatile的例子已是很难重现,如本文开头时只有在while死循环时才体现出volatile的做用,哪怕只是加了System.out.println(1)这么一小段,普通变量也能达到volatile的效果,这是什么缘由呢?原来只有在对变量读取频率很高的状况下,虚拟机才不会及时回写主内存,而当频率没有达到虚拟机认为的高频率时,普通变量和volatile是一样的处理逻辑。如在每一个循环中执行System.out.println(1)加大了读取变量的时间间隔,使虚拟机认为读取频率并不那么高,因此实现了和volatile的效果(本文开头的例子只在HotSpot24上测试过,没有在JRockit之类其他版本JDK上测过)。volatile的效果在jdk1.2及以前很容易重现,但随着虚拟机的不断优化,现在的普通变量的可见性已经不是那么严重的问题了,这也是volatile现在确实不太有使用场景的缘由吧。

4. volatile的适用场景

并发专家建议咱们远离volatile是有道理的,这里再总结一下:

  • volatile是在synchronized性能低下的时候提出的。现在synchronized的效率已经大幅提高,因此volatile存在的意义不大。
  • 现在非volatile的共享变量,在访问不是超级频繁的状况下,已经和volatile修饰的变量有一样的效果了。
  • volatile不能保证原子性,这点是你们没太搞清楚的,因此很容易出错。
  • volatile能够禁止重排序。

因此若是咱们肯定能正确使用volatile,那么在禁止重排序时是一个较好的使用场景,不然咱们不须要再使用它。这里只列举出一种volatile的使用场景,即做为标识位的时候(好比本文例子中boolean类型的flag)。用专业点更普遍的说法就是“对变量的写操做不依赖于当前值且该变量没有包含在其余具体变量的不变式中”,具体参见《Java 理论与实践: 正确使用 Volatile 变量》。

 

2、指令重排序(happen-before)

指令重排序是个比较复杂、以为有些难以想象的问题,一样是先以例子开头(建议你们跑下例子,这是实实在在能够重现的,重排序的几率仍是挺高的),有个感性的认识

/**
 * 一个简单的展现Happen-Before的例子.
 * 这里有两个共享变量:a和flag,初始值分别为0和false.在ThreadA中先给a=1,而后flag=true.
 * 若是按照有序的话,那么在ThreadB中若是if(flag)成功的话,则应该a=1,而a=a*1以后a仍然为1,下方的if(a==0)应该永远不会为真,永远不会打印.
 * 但实际状况是:在试验100次的状况下会出现0次或几回的打印结果,而试验1000次结果更明显,有十几回打印.
 */
public class SimpleHappenBefore {
    /** 这是一个验证结果的变量 */
    private static int a=0;
    /** 这是一个标志位 */
    private static boolean flag=false;
 
    public static void main(String[] args) throws InterruptedException {
        //因为多线程状况下未必会试出重排序的结论,因此多试一些次
        for(int i=0;i<1000;i++){
            ThreadA threadA=new ThreadA();
            ThreadB threadB=new ThreadB();
            threadA.start();
            threadB.start();
 
            //这里等待线程结束后,重置共享变量,以使验证结果的工做变得简单些.
            threadA.join();
            threadB.join();
            a=0;
            flag=false;
        }
    }
 
    static class ThreadA extends Thread{
        public void run(){
            a=1;
            flag=true;
        }
    }
 
    static class ThreadB extends Thread{
        public void run(){
            if(flag){
                a=a*1;
            }
            if(a==0){
                System.out.println("ha,a==0");
            }
        }
    }
}
例子比较简单,也添加了注释,再也不详细叙述。
 
什么是指令重排序?有两个层面:
  • 在虚拟机层面,为了尽量减小内存操做速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照本身的一些规则(这规则后面再叙述)将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽量充分地利用CPU。拿上面的例子来讲:假如不是a=1的操做,而是a=new byte[1024*1024](分配1M空间),那么它会运行地很慢,此时CPU是等待其执行结束呢,仍是先执行下面那句flag=true呢?显然,先执行flag=true能够提早使用CPU,加快总体效率,固然这样的前提是不会产生错误(什么样的错误后面再说)。虽然这里有两种状况:后面的代码先于前面的代码开始执行;前面的代码先开始执行,但当效率较慢的时候,后面的代码开始执行并先于前面的代码执行结束。无论谁先开始,总以后面的代码在一些状况下存在先结束的可能。
  • 在硬件层面,CPU会将接收到的一批指令按照其规则重排序,一样是基于CPU速度比缓存速度快的缘由,和上一点的目的相似,只是硬件处理的话,每次只能在接收到的有限指令范围内重排序,而虚拟机能够在更大层面、更多指令范围内重排序。硬件的重排序机制参见《从JVM并发看CPU内存指令重排序(Memory Reordering)

重排序很很差理解,上面只是简单地提了下其场景,要想较好地理解这个概念,须要构造一些例子和图表,在这里介绍两篇介绍比较详细、生动的文章《happens-before俗解》和《深刻理解Java内存模型(二)——重排序》。其中的“as-if-serial”是应该掌握的,即:无论怎么重排序,单线程程序的执行结果不能被改变。编译器、运行时和处理器都必须遵照“as-if-serial”语义。拿个简单例子来讲,

public void execute(){
    int a=0;
    int b=1;
    int c=a+b;
}

 

这里a=0,b=1两句能够随便排序,不影响程序逻辑结果,但c=a+b这句必须在前两句的后面执行。

 

从前面那个例子能够看到,重排序在多线程环境下出现的几率仍是挺高的,在关键字上有volatile和synchronized能够禁用重排序,除此以外还有一些规则,也正是这些规则,使得咱们在平时的编程工做中没有感觉到重排序的坏处。

  • 程序次序规则(Program Order Rule):在一个线程内,按照代码顺序,书写在前面的操做先行发生于书写在后面的操做。准确地说应该是控制流顺序而不是代码顺序,由于要考虑分支、循环等结构。
  • 监视器锁定规则(Monitor Lock Rule):一个unlock操做先行发生于后面对同一个对象锁的lock操做。这里强调的是同一个锁,而“后面”指的是时间上的前后顺序,如发生在其余线程中的lock操做。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操做发生于后面对这个变量的读操做,这里的“后面”也指的是时间上的前后顺序。
  • 线程启动规则(Thread Start Rule):Thread独享的start()方法先行于此线程的每个动做。
  • 线程终止规则(Thread Termination Rule):线程中的每一个操做都先行发生于对此线程的终止检测,咱们能够经过Thread.join()方法结束、Thread.isAlive()的返回值检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupte()方法的调用优先于被中断线程的代码检测到中断事件的发生,能够经过Thread.interrupted()方法检测线程是否已中断。
  • 对象终结原则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity):若是操做A先行发生于操做B,操做B先行发生于操做C,那就能够得出操做A先行发生于操做C的结论。

正是以上这些规则保障了happen-before的顺序,若是不符合以上规则,那么在多线程环境下就不能保证执行顺序等同于代码顺序,也就是“若是在本线程中观察,全部的操做都是有序的;若是在一个线程中观察另一个线程,则不符合以上规则的都是无序的”,所以,若是咱们的多线程程序依赖于代码书写顺序,那么就要考虑是否符合以上规则,若是不符合就要经过一些机制使其符合,最经常使用的就是synchronized、Lock以及volatile修饰符。

相关文章
相关标签/搜索