Java并发编程之volatile关键字解析

引言

volatile关键字虽然从字面上理解起来比较简单,可是要用好不是一件容易的事情。本文咱们就从JVM内存模型开始,了解一下volatile的应用场景。java

JVM内存模型

在了解volatile以前,咱们有必要对JVM的内存模型有一个基本的了解。Java的内存模型规定了全部的变量都存储在主内存中(即物理硬件的内存),每条线程还具备本身的工做内存(工做内存可能位于处理器的高速缓存之中),线程的工做内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的全部操做(读取,赋值等)都必须在工做内存中进行,而不能直接读写主内存中的变量)。不一样的线程之间没法直接访问对方工做内存之间的变量,线程间变量值的传递须要经过主内存来完成。git

JVM内存模型

p.s: 对于上面提到的副本拷贝,好比假设线程中访问一个10MB的对象,并不会把这10MB的内存复制一份拷贝出来,实际上这个对象的引用,对象中某个在线程访问到的字段是有可能存在拷贝的,但不会有虚拟机实现把整个对象拷贝一次。github

在并发编程中,咱们一般会遇到如下三个问题:原子性,可见性,有序性,下面咱们咱们来具体看一下这三个特性与volatile之间的联系:编程

有序性

public class TestCase {
    public static int number;
    public static boolean isinited;

    public static void main(String[] args) {
        new Thread(
                () -> {
                    while (!isinited) {
                        Thread.yield();
                    }
                    System.out.println(number);
                }
        ).start();
        number = 20;
        isinited = true;
    }
}

对于上面的代码咱们上面的本意是想输出20,可是若是运行的话能够发现输出的值可能会是0。这是由于有时候为了提供程序的效率,JVM会作进行及时编译,也就是可能会对指令进行重排序,将isInited = true;放在number = 20;以前执行,在单线程下面这样作没有任何问题,可是在多线程下则会出现重排序问题。若是咱们将number声名为volatile就能够很好的解决这个问题,这能够禁止JVM进行指令重排序,也就意味着number = 20;必定会在isInited = true前面执行。缓存

可见性

好比对于变量a,当线程一要修改变量a的值,首先须要将a的值从主存复制过来,再将a的值加一,再将a的值复制回主存。在单线程下面,这样的操做没有任何的问题,可是在多线程下面,好比还有一个线程二,在线程一修改a的值的时候,也从主存将a的值复制过来进行加一,随后线程一和线程二前后将a的值复制回主存,可是主存中a的值最终将只会加一而不是加二。多线程

使用volatile能够解决这个问题,它能够保证在线程一修改a的值以后当即将修改值同步到主存中,这样线程二拿到的a的值就是线程一已经修改过的a的值了。对volatile变量执行写操做时,会在写操做后加入一条store屏障指令,对volatile变量执行读操做时,会在写操做后加入一条load屏障指令。并发

线程写volatile变量过程:函数

  1. 改变线程工做内存中volatile变量副本的值;atom

  2. 将改变后的副本的值从工做内存刷新到主内存。spa

线程读volatile变量过程:

  1. 从主内存中读取volatile变量的最新值到工做内存中;

  2. 从工做内存中读取volatile变量副本。

原子性

原子性是指CPU在执行一条语句的时候,不会中途转去执行另外的语句。好比i = 1就是一个原子操做,可是++i就不是一个原子操做了,由于它要求首先读取i的值,而后修改i的值,最后将值写入主存中。

可是volatile却不能保证程序的原子性,下面咱们经过一个实例来验证一下:

public class TestCase {
    public volatile int v = 0;
    public static final int threadCount = 20;

    public void increase() {
        v++;
    }

    public static void main(String[] args) {
        TestCase testCase = new TestCase();
        for (int i=0; i<threadCount; i++) {
            new Thread(
                    () -> {
                        for (int j=0; j<1000; j++) {
                            testCase.increase();
                        }
                    }
            ).start();
        }

        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(testCase.v);
    }
}

输出结果:

18921

上面咱们的本意是想让输出20000,可是运行程序后,结果可能会小于20000。由于v++它自己并非一个原子操做,它是分为多个步骤的,并且volatile自己也并不能保证原子性。

上面的程序使用synchronzied则能够很好的解决,只须要声明public synchronized void increase()就好了。

或者使用lock也行:

Lock lock = new ReentrantLock();

public void increase() {
    lock.lock();
    try {
        v++;
    } finally {
        lock.unlock();
    }
}

或者将v声明为AtomicInteger v = new AtomicInteger();。在java 1.5的java.util.concurrent.atomic包下提供了一些原子操做类,即对基本数据类型的自增,自减,以及加法操做,减法操做进行了封装,保证这些操做是原子性操做。

单例模式

下面咱们经过单例模式来看一下volatile的一个具体应用:

class Singleton {
    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

上面instance必需要用volatile修饰,由于new Singleton是分为三个步骤的:

  1. 给instance指向的对象分配内存,并设置初始值为null(根据JVM类加载机制的原理,对于静态变量这一步应该在new Singleton以前就已经完成了)。

  2. 执行构造函数真正初始化instance

  3. 将instance指向对象分配内存空间(分配内存空间以后instance就是非null了)

在咱们的步骤2, 3之间的顺序是能够颠倒的,若是线程一在执行步骤3以后并无执行步骤2,可是被线程二抢占了,线程二获得的instance是非null,可是instance却尚未初始化。而使用volatile则能够保证程序的有序性。

References

UNDERSTANDING THE JVM
JAVA CONCURRENCY IN PRACTICE

Contact

GitHub: https://github.com/ziwenxie
Blog: https://www.ziwenxie.site

本文为做者原创,转载请声明博客出处:)

相关文章
相关标签/搜索