【并发编程】Volatile原理和使用场景解析


volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。一个硬币具备两面,volatile不会形成上下文切换的开销,可是它也并能像synchronized那样保证全部场景下的线程安全。所以咱们须要在合适的场景下使用volatile机制。java

咱们先使用一个列子来引出volatile的使用场景。编程


一个简单列子

public class VolatileDemo {

    boolean started = false;

    public void startSystem(){
        System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
        started = true;
        System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
    }

    public void checkStartes(){
        if (started){
            System.out.println("system is running, time:"+System.currentTimeMillis());
        }else {
            System.out.println("system is not running, time:"+System.currentTimeMillis());
        }
    }

    public static void main(String[] args) {
        VolatileDemo demo = new VolatileDemo();
        Thread startThread = new Thread(new Runnable() {
            @Override
            public void run() {
                demo.startSystem();
            }
        });
        startThread.setName("start-Thread");

        Thread checkThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    demo.checkStartes();
                }
            }
        });
        checkThread.setName("check-Thread");
        startThread.start();
        checkThread.start();
    }

}

上面的列子中,一个线程来改变started的状态,另一个线程不停地来检测started的状态,若是是true就输出系统启动,若是是false就输出系统未启动。那么当start-Thread线程将状态改为true后,check-Thread线程在执行时是否能当即“看到”这个变化呢?答案是不必定能当即看到。这边我作了不少测试,大多数状况下是能“感知”到started这个变量的变化的。可是偶尔会存在感知不到的状况。请看下下面日志记录:缓存

上面的现象可能会让人比较困惑,为何有时候check-Thread线程能感知到状态的变化,有时候又感知不到变化呢?这个要从Java的内存模型提及。安全

Java内存模型

咱们知道,计算机在执行程序时,每条指令都是在CPU中执行的。而执行指令过程当中,势必涉及到数据的读取和写入。程序运行过程当中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,因为CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,所以若是任什么时候候对数据的操做都要经过和内存的交互来进行,会大大下降指令执行的速度。为了解决这个问题,“巨人们”就设计了CPU高速缓存。多线程

下面举个列子来讲明下CPU高速缓存的工做原理:并发

i = i+1;

当线程执行这个语句时,会先从主存当中读取i的值,而后复制一份到高速缓存当中,而后CPU执行指令对i进行加1操做,而后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。ide

这个代码在单线程中运行是没有任何问题的,可是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不一样的CPU中,所以每一个线程运行时有本身的高速缓存(对单核CPU来讲,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文咱们以多核CPU为例,下面举个列子:性能

同时有2个线程执行上面这段代码,假如初始时i的值为0,那么从直观上看最后i的结果应该是2。可是事实可能不是这样。
可能存在下面一种状况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,而后线程1进行加1操做,而后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值仍是0,进行加1操做以后,i的值为1,而后线程2把i的值写入内存。最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。一般称这种被多个线程访问的变量为共享变量。测试

缓存不一致问题

上面的列子说明了共享变量在CPU中可能会出现缓存不一致问题。为了解决缓存不一致性问题,一般来讲有如下2种解决方法:

  • 经过在总线加LOCK#锁的方式;
  • 经过缓存一致性协议;

这2种方式都是硬件层面上提供的方式。

在早期的CPU当中,是经过在总线上加LOCK#锁的形式来解决缓存不一致的问题的。由于CPU和其余部件进行通讯都是经过总线来进行的,若是对总线加LOCK#锁的话,也就是说阻塞了其余CPU对其余部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。好比上面例子中 若是一个线程在执行 i = i +1,若是在执行这段代码的过程当中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码彻底执行完毕以后,其余CPU才能从变量i所在的内存读取变量,而后进行相应的操做。这样就解决了缓存不一致的问题。可是上面的方式会有一个问题,因为在锁住总线期间,其余CPU没法访问内存,致使效率低下

因此就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每一个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,若是发现操做的变量是共享变量,即在其余CPU中也存在该变量的副本,会发出信号通知其余CPU将该变量的缓存行置为无效状态,所以当其余CPU须要读取这个变量时,发现本身缓存中缓存该变量的缓存行是无效的,那么它就会从内存从新读取。

经过上面对Java内存模型的讲解,咱们发现每一个线程都有各自对共享变量的副本拷贝,代码执行是对共享变量的修改,其实首先修改的是CPU中高速缓存中副本的值。而这个修改对其余线程是不可见的,只有当这个修改刷新回主存中(刷新的时机不必定)而且其余线程从新读取这个主存中的值时,这个修改才对其余线程可见。这个也就解释了上面列子中的现象。check-Thread线程缓存了started的值是false,start-Thread线程将started副本的值改变成true后并无立马刷新到主存中去,因此当check-Thread线程再次执行时拿到的started值仍是false。

并发编程中的“三性”

在正式讲volatile以前,咱们先来解释下并发编程中常常遇到的“三性”。

  1. 可见性
    可见性是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其余线程可以当即看获得修改的值。

  2. 原子性
    原子性是指一个操做或者多个操做要么所有执行而且执行的过程不会被任何因素打断,要么就都不执行。

  3. 有序性
    有序性是指程序执行的顺序按照代码的前后顺序执行。

使用volatile来解决共享变量可见性

上面的列子中存在的问题是:start-Thread线程将started状态改变以后,check-Thread线程不能立马感知这个变化。也就是说这个共享变量的变化在线程之间是不可见的。那怎么来解决共享变量的可见性问题呢?Java中提供了volatile关键字这种轻量级的方式来解决这个问题的。volatile的使用很是简单,只须要用这个关键字修饰你的共享变量就好了:

private volatile boolean started = false;

volatile能达到下面两个效果:

  • 当一个线程写一个volatile变量时,JMM会把该线程对应的本地内存中的变量值强制刷新到主内存中去;
  • 这个写会操做会致使其余线程中的这个共享变量的缓存失效,重新去主内存中取值。

volatile和指令重排(有序性)

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。

重排序在单线程模式下是必定会保证最终结果的正确性,可是在多线程环境下,可能就会出问题。仍是用上面相似的列子:

public class VolatileDemo {

    int value = 1;
    private boolean started = false;

    public void startSystem(){
        System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
        value = 2;
        started = true;
        System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
    }

    public void checkStartes(){
        if (started){
            //关注点
            int var = value+1;  
            System.out.println("system is running, time:"+System.currentTimeMillis());
        }else {
            System.out.println("system is not running, time:"+System.currentTimeMillis());
        }
    }
}

上面的代码咱们并不能保证代码执行到“关注点”处,var变量的值必定是3。由于在startSystem方法中的两个复制语句并不存在依赖关系,因此在编译器进行代码编译时可能进行指令重排。也就是先执行
started = true;执行完这个语句后,线程立马执行checkStartes方法,此时value值仍是1,那么最后在关注点处的var值就是2,而不是咱们想象中的3。

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

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

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

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

volatile和原子性

volatile并非在全部场景下都能保证线程安全的。下面举个列子:

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);
    }
}

上面的代码中,每一个线程都对共享变量num加了10000次,一共有30个线程,那么感受上num的最后应该是300000。可是执行下来,大几率最后的结果不是300000(你们能够本身执行下这个代码)。这是由于什么缘由呢?

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

  • step1:从主存中读取最新的num值,并在CPU中存一份副本;
  • step2:对CPU中的num的副本值加1;
  • step3:赋值。

加入如今有两个线程在执行,线程1在执行到step2的时候被阻断了,CPU切换给线程2执行,线程2成功地将num值加1并刷新到内存。CPU又切会线程1继续执行step2,可是此时不会再去拿最新的num值,step2中的num值是已通过期的num值。

上面代码的执行结果和咱们预期不符的缘由就是相似num++这种操做并非原子操做,而是分几步完成的。这些执行步骤可能会被打断。在中状况下volatile就不能保证线程安全了,须要使用锁等同步机制来保证线程安全。

volatile使用场景

 synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些状况下性能要优于synchronized,可是要注意volatile关键字是没法替代synchronized关键字的,由于volatile关键字没法保证操做的原子性。一般来讲,使用volatile必须具有如下2个条件:

  • 对变量的写操做不依赖于当前值;
  • 该变量没有包含在具备其余变量的不变式中。

下面列举两个使用场景

  • 状态标记量(本文中代码的列子)
  • 双重检查(单例模式)
class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

volatile使用总结

  • volati是Java提供的一种轻量级同步机制,能够保证共享变量的可见性和有序性(禁止指令重排);
  • volatile对于单个的共享变量的读/写(好比a=1;这种操做)具备原子性,可是像num++这种复合操做,volatile没法保证其原子性;
  • volatile的使用场景不是不少,使用时须要深刻考虑下当前场景是否适用volatile。常见的使用场景有多线程下的状态标记量和双重检查等。

参考

  • https://www.cnblogs.com/dolphin0520/p/3920373.html
  • https://www.cnblogs.com/chengxiao/p/6528109.html
相关文章
相关标签/搜索