聊一聊volatile的可见性和有序性

众所周知,volatile无法保证原子性,但是可以保证可见性和有序性,今天就结合实际案例聊一聊volatile的可见性和有序性,同时详细说一下happens-before原则中关于volatile的部分,最后说一下稍带同步的概念。

1、可见性

基于对JMM的了解,线程从主内存中加载变量(比如实例变量)副本到自己的工作内存,后面使用的都是工作内存中的值,在单线程环境下,这是没有问题的。但是,在多线程环境下就可能会产生数据不一致了。比如,线程1和线程2先读取变量a到自己的工作内存,然后线程1修改了变量a的值,这个修改并不会立即更新到主内存,就算立即更新到了主内存,线程2也可能不会立即从主内存去拿变量a的最新值,这样就会导致线程2使用的变量a还是其工作内存中的旧值,就出现了数据不一致的现象,也叫线程1对变量a的修改对线程2不可见。结合下面的代码再详细阐述一下:

/**
 * @author debo
 * @date 2020-04-25
 */
public class VisibleTest {

    public static class MyThread extends Thread {
        public boolean isStop = false;

        @Override
        public void run() {
            int i = 0;
            // (2)
            while (!isStop) {
                i++;
            }
            System.out.println("thread has stopped, i=" + i);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MyThread t = new MyThread();
        t.start();
        Thread.sleep(1000);
        // (1)
        t.isStop = true;
    }
}

使用 -server JVM参数运行程序,发现程序永远无法停止,虽然主线程在代码1处设置isStop=true,但是子线程在代码2处使用的isStop变量还是其工作内存中的旧值,子线程并不能立即感知到主线程对isStop变量的修改。

为了解决这个问题,需要使用volatile关键字修饰isStop变量,执行修改过后的代码,发现程序可以正常结束。

这就是volatile的可见性。使用volatile修饰的变量在被修改时,会立即更新到主内存,其他线程再次使用该变量时,也会直接从主内存中读取变量的最新值而不是使用工作内存中的旧值。

2、有序性

对于这样的代码:

/**
 * @author debo
 * @date 2020-04-25
 */
public class OrderedTest {
    private Object obj;
    private int a;
    private boolean isAllInit;

    public void init() {
        // (1)
        obj = new Object();
        // (2)
        a = 1;
        // (3)
        isAllInit = true;
    }

    public void use() {
        // (4)
        if (isAllInit) {
            System.out.println(a);
            System.out.println(obj.toString());
        }
    }

    public static void main(String[] args) {
        final OrderedTest orderedTest = new OrderedTest();
        new Thread(new Runnable() {
            @Override
            public void run() {
                orderedTest.init();
            }
        }, "线程1").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                orderedTest.use();
            }
        }, "线程2").start();
    }
}

代码3一定会在代码1和代码2之后执行吗?这个不一定,最极端的情况下,经过编译器指令重排优化以后,代码的执行顺序变成3,1,2

        // (3)
        isAllInit = true;
        // (1)
        obj = new Object();
        // (2)
        a = 1;

指令重排序对于单线程没什么问题,但是在多线程环境下,线程1执行到代码3处时,线程2刚好进入代码4的if代码块,打印的a的值将会是0,同时还会抛出NPE异常,那这个问题就大了。

要想保证代码3一定会在代码1和2之后执行,就要使用volatile关键字修饰变量 isAllInit ,这样的话代码3绝对不会先于代码1和2执行(但是不保证代码1一定会先于代码2执行),那么如果线程2能进入代码4的if代码块,就一定能保证读取的是被初始化过的变量a和obj。这就是volatile的局部有序性,或者叫局部禁止指令重排序。

总结性概括就是,对volatile变量的读或写操作一定会后于该操作之前的代码执行,同时也一定会先于该操作之后的代码执行。

3、有关volatile的happens-before原则

happens-before原则中有两条我想单独拎出来讲讲。

  • 原则一:对一个volatile变量的写操作happens-before对此变量的读或写操作
  • 原则二:如果A操作happens-before B操作,B操作happens-before C操作,那么A操作happens-before C操作

原则二就是happens-before的传递性原则,原则一不大好理解,其实翻译成大白话就是线程1对变量v的修改操作以及对该操作之前其它变量的修改,都对线程2可见。

在前面第2小节中有一个结论:那么如果线程2能进入代码4的if代码块,就一定能保证读取的是被初始化过的变量a和obj,这个结论下的有点牵强,为什么这样说呢?因为根据volatile的可见性和有序性,只能得出线程1初始化了变量obj和a,但是无法确定线程2能拿到最新的变量obj和a的值,因为这两个变量没有被volatile修饰,线程1修改了这两个变量后不需要立即写入主内存,那么线程2有可能从主内存中读取的变量obj等于null,a等于0(如果之前已经使用过这两个变量,那么后面就有可能直接读工作内存中的旧值了)。但是,有happens-before原则一的保证,一切就变得不同了。虽然变量obj和a没有被volatile修饰,但是线程1将变量isAllInit的修改写回主内存时,会将变量obj和a的修改一并写回。这时候线程2读取isAllInit的值时,需要从主内存读取最新值,同时也将变量obj和a的最新值读到工作内存。

关于原则二,我们可以简述成如果hb(A, B)且hb(B, C),那么有hb(A, C)成立。看一下以下两个代码片段,其中变量nonsense是volatile实例变量。

片段1:

a = 1;
nonsense = true;

片段2:

System.out.println(nonsense);
b = a;

在多线程环境中假设有以下执行顺序,其中t1~t4表示执行时间的先后顺序

线程1 线程2
t1 a = 1
t2 nonsense = true
t3 System.out.println(nonsense)
t4 b = a

那么b能否被赋予a的最新值呢?已知hb(t1, t2),hb(t3, t4),又根据原则一得出hb(t2, t3),所以根据传递性原则,hb(t1, t4),所以b等于1。(其实根据原则一就能得出b等于1,可以认为传递性原则依赖于原则一)

这里t2, t3可以不用volatile来举例,只要是任何满足hb(t2, t3)的原则(比如t2是对并发集合的写,t3是对并发集合的读),都能保证最终的传递性。

但是,如果多线程的执行顺序是以下情况:

线程1 线程2
t1 a = 1
t2 System.out.println(nonsense)
t3 nonsense = true
t4 b = a

此时b能否被赋予a的最新值呢?由于hb(t2, t3)已经不成立了,无法满足传递性原则,所以不能保证b一定等于1。

由此,我们可以引申出一个稍带同步的概念,如下代码段中,result只是普通的实例变量(无volatile修饰),且hb(method1, method2),同时method1没有执行完时method2会阻塞等待

    public Object get() {
        method2();
        return result;
    }

    public void set() {
        result = newValue;
        method1();
    }

这种情况下,在多线程并发执行get, set方法时,能保证一个线程能读取到另一个线程修改的result的最新值,虽然result变量并没有进行任何同步控制。这就是稍带同步(piggybacking on synchronization) 的概念,在JUC并发包中有很多地方用到了这样的变量同步方式,也许是基于性能考虑,因为哪怕是给变量加一个volatile关键字那也比不加时性能低啊!但是我们自己写代码的话,肯定不建议使用稍带同步,这个不好理解同时维护难度也大。

4、参考资料

  • happens-before俗解
  • Java并发编程:volatile关键字解析

你可能感兴趣的:(Java基础)