并发编程之volatile

写在前面

前面一章我们讲了了java原子性的相关概念和知识点,介绍了用于共享变量线程隔离的ThreadLocal,也知道了synchronized是一个重量级的锁,而我们今天要讲的volatile则是轻量级的synchronized,主要是因为它不会引起线程上下文的切换。在讲volatile到底是什么,它能够解决什么样的问题之前,首先不得不提一下Java的内存模型。

JMM内存模型

在Java中,所有实例域,静态域和数组对象都存在于堆中,是所有线程共享的。局部变量,方法参数和异常处理参数不是线程共享,不会存在内存可见性问题。

Java线程之间的通信由Java(简称JMM)内存模型来控制,它决定了对一个变量的写入何时对另一个线程可见。JMM规定了所有的共享变量都存在于主内存中,而每条线程又有自己的工作内存,工作内存内保存了对于主内存中共享变量的拷贝。我们对一个变量的读写是在工作内存中完成的,同时线程间的通信是由工作内存将修改的变量刷新到主内存来进行传递的。下面是JMM的抽象示意图:

并发编程之volatile_第1张图片
01.png

从图上看,线程A和B进行通信的话分成两步:

  • 线程A将修改后的共享变量刷新到主内存
  • 线程B从主内存读取线程A已更新过的共享变量

由于线程B每次都是从主内存拿变量,并不能实时地获取线程A修改的变量值,可能读取的是之前的值,从而出现脏读,这就是不满足可见性的问题。

可见性问题

可见性是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,另一个线程立马能够读取到修改后的值。

举个栗子:

//线程1
int i = 0;
i = 10;
//线程2
j=i;

画个图:

并发编程之volatile_第2张图片
02.png

由图可知,假如线程A,B按照这种时间顺序执行的话,j最后的值是0。这就是可见性问题,线程A对变量i修改的值,没有立即对线程B可见。

有序性问题

有序性:即程序执行的顺序按照代码的先后顺序执行。

举个栗子:

public class VolatileDemo {
    private static boolean flag;
    private static int num;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
                while (!flag) {
                    Thread.yield();
                }
                System.out.println(num);
        });
        t1.start();
        num = 5;
        flag = true;
    }
}

这段代码,尽管num=5是写在flag=true前面,但是最终打印的结果有可能是0哦。也就是说,写在前面的代码并没有先执行,对于这种不按书写顺序执行的情况称作指令重排序。大多数现代处理器都支持指令重排,为的是直接运行当前能运行的指令,而不去顺序等待,这种乱序的执行方式打打提高了处理器的效率。

戏说不是胡说,改编不是乱编,指令重排也不是随便排,它是根据代码的依赖关系,在不影响单线程环境下的执行结果的前提下进行重排序的。例如:

a=1;
b=2;
c=a+b;

这段代码,c=a+b是不会重排到啊a,b之前的,因为c的值对a,b都有依赖。但是,a,b的赋值语句可能会重排。这种指令重排在单线程环境中没有任何问题,但是在多线程的环境下,就将会出现数据的不确定性。

volatile保证可见性和有序性,但不能保证原子性

在Java中,大佬们给我们提供了volatile关键字来保证可见性和有序性。这个说法有两种语义:

  • 保证了不同线程对变量操作时的可见性,即一个线程修改了变量的值,对另一个线程是立马可见的。

  • 禁止指令重排序,即在volatile语句之前的代码不会重排序到volatile语句之后去执行。

    volatile保证了可见性,是因为使用volatile关键字会强制把值写入主内存,同时将其他线程工作内存中的缓存行无效,这样其他线程在获取变量值的时候,发现了自己的缓存行无效后,在对应的主内存地址被更新后就会去主内存中取。

    volatile保证了有序性,如果两个操作的执行次序无法从happens-befor原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。volatile底层是通过内存屏障来完成一系列的有序性功能的。

    注:synchronizedlock也能保证有序性。

最后要强调一点的是,volatile不能保证原子性。

public class Test {
    public volatile int inc = 0;
 
    public void increase() {
        inc++;
    }
 
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
 
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

这段代码最终的输出结果将小于10000,原因就在于inc++它不是个原子性操作,尽管volatile能保证对inc的修改立即被其他线程感知,但是对于inc的并发读取不会触发强制刷新主内存,也不会导致其他线程的缓存行无效,这样就导致多个线程读取到同样的值。

一般这种情况可以用synchronizedlock来解决,也可以通过无锁CAS方式的AtomicInteger来解决。

所以说,重量级锁还是比较稳的,volatile不能完全替代synchronized,使用volatile必须具备两个条件:

  • 对变量的写入不依赖当前值
  • 该变量没有包含在其他变量的不变式中

参考资料

  1. 方腾飞:《Java并发编程的艺术》
  2. 指令重排序
  3. 深入分析volatile的实现原理

你可能感兴趣的:(并发编程之volatile)