深入理解Java虚拟机之----volatile关键字

volatile 关键字


被 volatile 修饰的变量具备两种特性:

1、保证该变量对所有线程的可见性;

2、禁止指令重排序优化。
1、保证该变量对所有线程的可见性

可见性是指,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,同时其他线程每次使用前都会从主内存读取,从而新值对于其他线程来说是可以立即得知的。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存来完成。

在 Java 内存模型(深入理解Java虚拟机之----Java内存模型)中,可以知道,在多线程中,每一个线程都有各自的工作内存,工作内存是该线程使用到的变量的主内存副本拷贝,对变量的所有操作(读取、赋值等)都必须在工作内存中进行,并且不会立即更新至主内存中。这就可能造成一个变量在一个线程中修改了,还没来得及更新至主内存,主内存中该变量的值就被其他线程使用了,而此时变量的值为修改前的旧值。

例如:线程 A 和线程 B 都用到了变量 C,线程 A 中对变量 C 执行了 +1 操作,由于是在线程 A 的工作内存中修改的,并且没有立即回写至主内存,而在此时刻线程 B 从主内存读取变量 C 的值,得到的是过时的结果----即线程 A 对变量 C 的修改对线程 B 来说不可见。

volatile 变量不一样,它拥有特殊的访问规则:

(1)使用前必须先从主内存刷新最新的值;

(2)修改后必须立刻同步回主内存中。

以上两条特殊规则,给人以 volatile 变量不是存在于工作内存而是存在于主内存中 的假象,从而保证了 volatile 变量对所有线程的可见性。

虽然 volatile 变量对所有线程是立即可见的,但是,基于 volatile 变量的运算在并发下不一定是安全的。例如:(注意:该例在 IDEA 中只能以 debug 模式运行,以 run 模式运行会陷入死循环,原因可以参考一下这个:在阅读《深入理解Java虚拟机》一书中,执行代码清单12-1的例子时,疑似发现bug?

public class VolatileTest {

    private static final int THREADS_COUNT = 20;

    private static volatile int race = 0;

    private static void increase() {
        race++;
    }

    public static void main(String[] args) {

        Thread[] threads = new Thread[THREADS_COUNT];

        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });

            threads[i].start();
        }

        while (Thread.activeCount() > 1) {
            Thread.yield();
        }

        System.out.println("race = " + race);

    }

}
output:
	//这个输出应该每次都不一样,都是一个小于200000的数字。
	race = 189716

如果能够正确并发的话,最后输出结果应该是 200000,然后结果却是小于 200000 的一个数,问题出在哪呢?其实就在 race++ 中,race++ 看似只有一行代码,其实是有三个操作:

a)读取 race 的值;
b)将 race 值加一;
c)将 race 值写回内存。

volatile 关键字能够保证 a)操作读取的 race 的值在这一时刻是正确的,但在执行 b)、c)操作时,可能有其他的线程已经对 race 的值进行了修改,导致了 c)操作可能把较小的 race 值同步回主内存之中。所以要想保证结果的正确性,需要在 increase() 方法加锁才行。

以下是适用 volatile 变量的两种运算场景:

  • 运算结果并不依赖变量的当前值,或者能够保证确保只有单一的线程修改变量的值。

  • 变量不需要与其他的状态变量共同参与不变约束。

像下面的代码就很适合使用 volatile 变量来控制并发:

    volatile boolean shutdownRequested;

    public void shutDown() {
        shutdownRequested = true;
    }

    public void doWork() {
        while (!shutdownRequested) {
            //do stuff
        }
    }

但遇到不符合上述两条规则的运算场景时,就需要加锁来保证原子性。

2、禁止指令重排序优化。

为了尽可能的减小内存操作速度远慢于 CPU 运行速度所带来的 CPU 空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱,而这一打乱,就可能干扰程序的并发执行。

例如:

    Map configOptions;
    char[] configText;
    //此变量必须定义为 volatile
    volatile boolean initialized = false;

    //假设以下代码在线程 A 中运行
    //模拟读取配置信息, 当读取完成后将 initialized 设置为 true 以告知其他线程配置可用
    configOptions = new HashMap();
    configText = readConfigFile(fileName);
    processConfigOptions(configText, configOptions);
    initialized = true;
    
    
    
    //假设以下代码在线程 B 中运行
    //等待 initialized 为 true, 代表线程 A 已经把配置信息初始化完成
    while(!initialized) {
        sleep();
    }
    //使用线程 A 中初始化好的配置信息
    doSomethingWithConfig();

假如上面伪代码中 initialized 没有用 volatile 修饰,就可能由于指令重排序的优化,导致位于线程 A 中最后一句代码 initialized = true; 被提前执行,这样在线程 B 中使用配置信息的代码就可能出错。

再比如,double-check locking 中:

public class Singleton {

    private static volatile 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();
    }

这里 volatile 修饰变量的作用在于禁止指令重排序,而不是它的可见性。

Double-checked_locking 中提到:

Due to the semantics of some programming languages, the code generated by the compiler is allowed to update the shared variable to point to a partially constructed object before A has finished performing the initialization. For example, in Java if a call to a constructor has been inlined then the shared variable may immediately be updated once the storage has been allocated but before the inlined constructor initializes the object.[6]

由于某些编程语言的语义,允许编译器生成的代码在 A 完成初始化之前更新共享变量以指向部分构造的对象。例如:在 Java 中,如果共享变量的引用已经和构造函数的调用内联了,即使构造器未完成初始化,共享变量也可能立即被更新。

对应于 double-check locking 例子来说就是:

instance = new Singleton(); 这一行代码分三个步骤:

(1)在堆上分配内存;
(2)赋初值;
(3)将 instance 引用指向堆地址。

假如是以(1)(3)(2)的顺序执行,其他线程就可能得到一个未完成初始化的 instance ,导致程序报错。而添加 volatile 修饰之后可以阻止指令的重排序(Java 1.5 及以后的版本),从而避免了这种 case。

volatile 关键字是 Java 虚拟机提供的最轻量级的同步机制,使用volatile可能比锁更快,但在某些情况下它不起作用。在 Java 5 中扩展了 volatile 有效的情况范围。 特别是,双重检查加锁机制现在可以正常工作。

参考资料:

(1)《深入理解java虚拟机》周志明 著.

(2)并发关键字volatile(重排序和内存屏障)

(3)Double-checked_locking

发布了35 篇原创文章 · 获赞 24 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/haihui_yang/article/details/82290183
今日推荐