谈谈Java中的volatile

内存可见性java

留意复合类操做编程

解决num++操做的原子性问题缓存

禁止指令重排序多线程

总结并发

内存可见性

  volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized一般称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销,假若能恰当的合理的使用volatile,天然是美事一桩。性能

  为了能比较清晰完全的理解volatile,咱们一步一步来分析。首先来看看以下代码优化

public class TestVolatile {
    boolean status = false;

    /**
     * 状态切换为true
     */
    public void changeStatus(){
        status = true;
    }

    /**
     * 若状态为true,则running。
     */
    public void run(){
        if(status){
            System.out.println("running....");
        }
    }
}

  上面这个例子,在多线程环境里,假设线程A执行changeStatus()方法后,线程B运行run()方法,能够保证输出"running....."吗?spa

  答案是NO! 操作系统

  这个结论会让人有些疑惑,能够理解。由于假若在单线程模型里,先运行changeStatus方法,再执行run方法,天然是能够正确输出"running...."的;可是在多线程模型中,是无法作这种保证的。由于对于共享变量status来讲,线程A的修改,对于线程B来说,是"不可见"的。也就是说,线程B此时可能没法观测到status已被修改成true。那么什么是可见性呢?线程

  所谓可见性,是指当一条线程修改了共享变量的值,新值对于其余线程来讲是能够当即得知的。很显然,上述的例子中是没有办法作到内存可见性的。

  Java内存模型

  为何出现这种状况呢,咱们须要先了解一下JMM(java内存模型)

  java虚拟机有本身的内存模型(Java Memory Model,JMM),JMM能够屏蔽掉各类硬件和操做系统的内存访问差别,以实现让java程序在各类平台下都能达到一致的内存访问效果。

  JMM决定一个线程对共享变量的写入什么时候对另外一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每一个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存中的变量。这三者之间的交互关系以下

 

  须要注意的是,JMM是个抽象的内存模型,因此所谓的本地内存,主内存都是抽象概念,并不必定就真实的对应cpu缓存和物理内存。固然若是是出于理解的目的,这样对应起来也无不可。

  大概了解了JMM的简单定义后,问题就很容易理解了,对于普通的共享变量来说,好比咱们上文中的status,线程A将其修改成true这个动做发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B缓存了status的初始值false,此时可能没有观测到status的值被修改了,因此就致使了上述的问题。那么这种共享变量在多线程模型中的不可见性如何解决呢?比较粗暴的方式天然就是加锁,可是此处使用synchronized或者Lock这些方式过重量级了,有点炮打蚊子的意思。比较合理的方式其实就是volatile

  volatile具有两种特性,第一就是保证共享变量对全部线程的可见性。将一个共享变量声明为volatile后,会有如下效应:

    1.当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量强制刷新到主内存中去;

    2.这个写会操做会致使其余线程中的缓存无效。

上面的例子只需将status声明为volatile,便可保证在线程A将其修改成true时,线程B能够马上得知

 volatile boolean status = false;

留意复合类操做

  可是须要注意的是,咱们一直在拿volatile和synchronized作对比,仅仅是由于这两个关键字在某些内存语义上有共通之处,volatile并不能彻底替代synchronized,它依然是个轻量级锁,在不少场景下,volatile并不能胜任。看下这个例子:

package test;

import java.util.concurrent.CountDownLatch;

/**
 * Created by chengxiao on 2017/3/18.
 */
public class Counter {
    public static volatile int num = 0;
    //使用CountDownLatch来等待计算线程执行完
    static CountDownLatch countDownLatch = new CountDownLatch(30);
    public static void main(String []args) throws InterruptedException {
        //开启30个线程进行累加操做
        for(int i=0;i<30;i++){
            new Thread(){
                public void run(){
                    for(int j=0;j<10000;j++){
                        num++;//自加操做
                    }
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待计算线程执行完
        countDownLatch.await();
        System.out.println(num);
    }
}

执行结果:

224291

针对这个示例,一些同窗可能会以为疑惑,若是用volatile修饰的共享变量能够保证可见性,那么结果不该该是300000么?

问题就出在num++这个操做上,由于num++不是个原子性的操做,而是个复合操做。咱们能够简单讲这个操做理解为由这三步组成:

  1.读取

  2.加一

  3.赋值

  因此,在多线程环境下,有可能线程A将num读取到本地内存中,此时其余线程可能已经将num增大了不少,线程A依然对过时的num进行自加,从新写到主存中,最终致使了num的结果不合预期,而是小于30000。

解决num++操做的原子性问题

  针对num++这类复合类的操做,可使用java并发包中的原子操做类原子操做类是经过循环CAS的方式来保证其原子性的。

/**
 * Created by chengxiao on 2017/3/18.
 */
public class Counter {
  //使用原子操做类
public static AtomicInteger num = new AtomicInteger(0); //使用CountDownLatch来等待计算线程执行完 static CountDownLatch countDownLatch = new CountDownLatch(30); public static void main(String []args) throws InterruptedException { //开启30个线程进行累加操做 for(int i=0;i<30;i++){ new Thread(){ public void run(){ for(int j=0;j<10000;j++){ num.incrementAndGet();//原子性的num++,经过循环CAS方式 } countDownLatch.countDown(); } }.start(); } //等待计算线程执行完 countDownLatch.await(); System.out.println(num); } }

执行结果

300000

关于原子类操做的基本原理,会在后面的章节进行介绍,此处再也不赘述。

禁止指令重排序

volatile还有一个特性:禁止指令重排序优化。

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。可是重排序也须要遵照必定规则:

  1.重排序操做不会对存在数据依赖关系的操做进行重排序。

    好比:a=1;b=a; 这个指令序列,因为第二个操做依赖于第一个操做,因此在编译时和处理器运行时这两个操做不会被重排序。

  2.重排序是为了优化性能,可是无论怎么重排序,单线程下程序的执行结果不能被改变

    好比:a=1;b=2;c=a+b这三个操做,第一步(a=1)和第二步(b=2)因为不存在数据依赖关系,因此可能会发生重排序,可是c=a+b这个操做是不会被重排序的,由于须要保证最终的结果必定是c=a+b=3。

  重排序在单线程模式下是必定会保证最终结果的正确性,可是在多线程环境下,问题就出来了,来开个例子,咱们对第一个TestVolatile的例子稍稍改进,再增长个共享变量a

public class TestVolatile {
    int a = 1;
    boolean status = false;

    /**
     * 状态切换为true
     */
    public void changeStatus(){
        a = 2;//1
        status = true;//2
    }

    /**
     * 若状态为true,则running。
     */
    public void run(){
        if(status){//3
            int b = a+1;//4
            System.out.println(b);
        }
    }
}

  假设线程A执行changeStatus后,线程B执行run,咱们能保证在4处,b必定等于3么?

  答案依然是没法保证!也有可能b仍然为2。上面咱们提到过,为了提供程序并行度,编译器和处理器可能会对指令进行重排序,而上例中的1和2因为不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达4处,而线程A中a=2这个操做还未被执行,因此b=a+1的结果也有可能依然等于2。

  使用volatile关键字修饰共享变量即可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

  volatile禁止指令重排序也有一些规则,简单列举一下:

  1.当第二个操做是voaltile写时,不管第一个操做是什么,都不能进行重排序

  2.当地一个操做是volatile读时,无论第二个操做是什么,都不能进行重排序

  3.当第一个操做是volatile写时,第二个操做是volatile读时,不能进行重排序

总结:

  简单总结下,volatile是一种轻量级的同步机制,它主要有两个特性:一是保证共享变量对全部线程的可见性;二是禁止指令重排序优化。同时须要注意的是,volatile对于单个的共享变量的读/写具备原子性,可是像num++这种复合操做,volatile没法保证其原子性,固然文中也提出了解决方案,就是使用并发包中的原子操做类,经过循环CAS地方式来保证num++操做的原子性。关于原子操做类,会在后续的文章进行介绍。

相关文章
相关标签/搜索