前言

在java多线程中,volatile是一个不得不提的关键字。

volatile,主要应用于定义变量的时候,本身的含义是“可变的,易变的”。

顾名思义,volatile的作用,就是标识一个变量的是可变的。

volatile的作用,是能保证线程之间的可见性,以及防止指令的重排序

本文将从volatile这两个作用开始,探讨一些相关的底层问题,以及volatile是如何解决这两个问题的。

volatile的作用

保证线程的可见性

要讨论volatile在线程可见性中起到的重要作用,首先要明白什么是线程可见性。

线程可见性,就是指一个线程对某个变量的改变能不能被另外的线程看到。

下面举一个例子:

public class VolatileDemo {
    boolean flag = true;

    public void method1() {
        System.out.println("print start");
        while(flag) {

        }
        System.out.println("print end");
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileDemo v = new VolatileDemo();

        (new Thread(new Runnable() {
            @Override
            public void run() {
                v.method1();
            }
        })).start();

        Thread.sleep(1000);

        (new Thread(new Runnable() {
            @Override
            public void run() {
                v.flag = false;
            }
        })).start();
    }
}

上面这段代码,有一个变量flag 用来标识线程thread1中的while循环,只要flag是true,那么thread1会一直运行。

然后又起了一个线程thread2 来改变flag的值。

那么按照常理来说,只要thread2 完成了对flag的值的改变,thread1就会完成操作并退出。

但是最后的结果是thread1一直在运行。

也就是说,thread2对变量的改变,并没有反应在thread1中。 这就是线程间的不可见。

那么是什么原因导致了线程之间的不可见呢?

我们从线程的内存开始说起。

如下图所示:

java volatile 设置无效 java的volatile的作用_java volatile 设置无效

线程之间是共享堆空间的(heap),这里也是初始时的flag所在的空间。

然后当线程需要用到某个变量的时候,就会在自己的线程空间里,拷贝一份原始的变量。

当这个线程完成了对变量的修改后,会回写到原始空间。但是注意:

什么时候回写新值是不确定的

这就导致了,虽然变量可能已经被某个线程改成了新值,但由于新值还没有被回写到共享空间,导致其他线程读到的还是旧值。

那么volatile的作用就呼之欲出了:

volatile能保证变量发生改变后能及时回写到共享空间中。保证所有线程读到的都是最新的值。

禁止指令的重排序

volatile另一个作用是禁止指令的重排序。

指令重排序是发生在CPU级别的操作。

为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU。

换句话说,在CPU上执行的指令的顺序,可能跟我们编写代码的顺序并不一致。

我们用Double-Checked Lock版的单例模式来举一个极端的例子。

这个版本的单例模式的具体信息在这篇文章,这里单独把代码挑出来:

public static LazyLoading_DCL getInstance() {
		private static LazyLoading_DCL INSTANCE;
		
		if (INSTANCE == null) {
			synchronized(LazyLoading_DCL.class) {
				if (INSTANCE == null) {
					INSTANCE = new LazyLoading_DCL();
				}
			}
		}
		
		return INSTANCE;
	}

这里先说一个结论:

在JVM中,创建一个新对象会被编译成三条指令,并从上往下依次执行:

  1. 在内存中开辟一块区域,所有的成员变量是初始值
  2. 将成员变量的值改成我们设置的值
  3. 将这个对象的引用指向该内存空间

由于指令重排序的存在,上面的指令可能并不会从上往下依次执行

那么我们假设发生的指令顺序是 1 - 3 - 2。 即先将引用指向开辟的空间,然后在把成员变量设置成正常值。

然后我们把这个假设带入到上面的代码中。

超级超级,巨大巨大的巧合下,一个线程正在初始化INSTANCE并且按照指令重排序的顺序,把半初始化的对象赋值给了INSTANCE变量。

那么另外一个线程申请得到这个单例的时候,INSTANCE 不等于null,然后直接返回了半成品INSTANCE, 这就会造成难以预计的错误。

那么我们就要给INSTANCE加上volatile,从而保证在初始化这个对象的时候,不会发生指令重排序。从而100%保证这个变量的可靠性。

volatile与内存屏障

其实volatile的底层实现涉及到内存屏障,有了内存屏障的存在,就能保证屏障前后的操作不发生重排序。

JVM中有四种屏障,分别是

  • Load_Load屏障:该屏障前后的读操作不会发生重排序。
  • Load_Store屏障:该屏障前面的读操作与屏障后面的写操作不会发生重排序。
  • Store_Load屏障:该屏障前的写操作与后面的读操作不会发生重排序。
  • Store_Store屏障:该屏障前后的写操作不会发生重排序。

volatile就是基于上面几种屏障来实现的。

对于volatile的读写操作,JVM分别在前后加了如下的屏障,来保障volatile不会发生重排序。

读操作:
-----volatile读操作------
----Load_Load屏障----
----Load_Store屏障----

写操作:
----Store_Store屏障----
-----volatile写操作------
----Store_Load屏障----

通过两个屏障的共同作用,保证了临界区中的任何读写操作不可能被重排序到临界区之外。

总结

本文主要讲了volatile在保证线程可见性和指令顺序执行方面的作用,并且从底层分析了一下这个关键字是如何解决了这两个问题。

但是我们也需要注意,volatile只能保证操作的可见性,但不能保证操作的原子性。保证操作的原子性,还是需要使用synchronized关键字。

volatilesynchronized并不是相互替代的关系,而是互为补充。