Loading

Volatile 原理和使用场景解析


本博客系列是学习并发编程过程中的记录总结。由于文章比较多,写的时间也比较散,所以我整理了个目录贴(传送门),方便查阅。

并发编程系列博客传送门


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

我们先使用一个列子来引出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){
                    System.out.println("loop check...");
                    demo.checkStartes();
                }
            }
        });
        checkThread.setName("check-Thread");
        startThread.start();
        checkThread.start();
    }

}

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


start-Thread begin to start system, time:1577079553515
start-Thread success to start system, time:1577079553516  
loop check...
system is not running, time:1577079553516   ==>此处start-Thread线程已经将状态设置成true,但是check-Thread线程还是没检测到
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553516
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553517
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519
system is running, time:1577079553519

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

Java内存模型

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

下面举个列子来说明下CPU高速缓存的工作原理:

i = i+1;

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

这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核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会把该线程对应的本地内存中的变量值强制刷新到主内存中去;
  • 这个写会操作会导致其他线程中的这个共享变量的缓存失效,要使用这个变量的话必须重新去主内存中取值。

不知道你有没有思考过这样一个问题:在同一时刻,CPU1上面的线程对volatile变量进行写操作,CPU2上面的线程对同一个volatile变量进行读操作会发生什么样的结果?

自己在网上查了下资料:

目前的x86/x64的多核/多处理器系统是SMP结构,共享主存,内存是共享设备,多个处理器/核心要访问内存,首先要获得内存总线的控制权,任何时刻只有一个处理器/核心能获得内存总线的控制权,所以单就内存来说,不会出现多个处理器/核心同时访问一个内存地址的情况。

根据上面的介绍,就是同一个时刻,只能有一个线程读或者写同一块内存的地址。也就不存在真正意义上的同时读写一块内存。所以如果写线程争抢到内存总线的话,读线程就能读到最新值,如果读线程先争抢到内存总线的话,读到的就是旧的值。

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) { // 1
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();  //2
            }
        }
        return instance;
    }
}

上述的Instance类变量是没有用volatile关键字修饰的,会导致这样一个问题:
在线程执行到第1行的时候,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
造成这种现象主要的原因是重排序。重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

第二行代码可以分解成以下几步

emory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory;  // 3:设置instance指向刚分配的内存地址

根源在于代码中的2和3之间,可能会被重排序。例如:


memory = allocate();  // 1:分配对象的内存空间
instance = memory;  // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象

这种重排序可能就会导致一个线程拿到的instance是非空的但是还没初始化完全。

volatile的实现原理

通过上面的介绍,我们知道volatile可以实现内存的可见性和防止指令重排序。那么volatile的这些功能是怎么实现的呢?其实volatile的这些内存语意是通过内存屏障技术实现的。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。同时内存屏障还能保证内存的可见性。

关于内存屏障的具体内容,要讲的话需要花很大的篇幅来讲解。这边就不具体展开了。大家感兴趣的可以自己了解下。

volatile使用总结

  • volati是Java提供的一种轻量级同步机制,可以保证共享变量的可见性和有序性(禁止指令重排),volatile的实现原理是基于处理器的Lock指令的,这个指令会使得对变量的修改立马刷新回主内存,同时使得其他CPU中这个变量的副本失效;
  • volatile对于单个的共享变量的读/写(比如a=1;这种操作)具有原子性,但是像num++或者a=b;这种复合操作,volatile无法保证其原子性;
  • volatile的使用场景不是很多,使用时需要深入考虑下当前场景是否适用volatile(记住“对变量的写操作不依赖于当前值”、“该变量没有包含在具有其他变量的不变式中”这两个使用条件)。常见的使用场景有多线程下的状态标记量和双重检查等。

众多保障并发安全的工具中选用volatile的意义——它能让我们的代码比使用其他的同步工具更快吗?在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里面的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说volatile就会比synchronized快上多少。如果让volatile自己与自己比较,那可以确定一个原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下volatile的总开销仍然要比锁来得更低。我们在volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景的需求

参考

posted @ 2019-11-06 17:47  程序员自由之路  阅读(5182)  评论(0编辑  收藏  举报