深入理解volatile(Java)

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第11天,点击查看活动详情

前言

除了上篇文章讲到的关键字synchronize关键字外可以实现同步外,java中还有另一个关键字volatile可以实现一些简单的同步。 synchronized知识的了解可查看深入理解synchronized(一)——初识synchronized

定义

volatile是一个特征修饰符.volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。其在许多语言中都有应用,在java中是一个关键字,其作用主要有以下两点:

  1. 保证此变量对所有的线程的可见性。即一个线程对volatile修改后,其他线程能够立马感知到此变量的新值。
  2. 禁止指令重排序优化。指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理,volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障。

下面我们谈谈volatile实现上述功能的原理,为了更好的理解,我们先讲解volatile如何让禁止指令重排序。

volatile与指令重排序

首先我们了解下指令重排序的概念。

指令重排序

大家有没有想过,我们编写的java代码,机器在执行的时候一定按我们编写的顺序一条一条执行吗?答案是否定的,因为我们编写的代码顺序很可能不是机器cpu执行较优的顺序,因此java内存模型允许编译器和处理器对在编译器或运行时对指令重排序以提高性能,并且只会对不存在的数据依懒性的指令进行重排序。

虽然指令排序能提高性能,但不代表所有的语句都会进行指令重排序,上面也说了指令间需要不存在数据依赖性,数据依赖性有以下3种类型:

类型 代码举例 说明
写后读 a=1;b=a 写一个变量后,在读这个变量
写后写 a=1;a=2 写一个变量后,又改变(写)这个变量的值
读后写 b=a;a=2 读一个变量后,又改变(写)这个变量的值
对于上面这三种类型,如果改变代码的执行顺序,很明显执行结果不符合预期。所以,编译器和处理器在重排顺序的时候,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与器在顺序执行下的效果一致,否则这种优化便会失去意义。这句话有个专业术语叫做as-if-serial语义(有兴趣的同学可查略资料进一步了解)。

听起来指令重排序能提高性能,岂不妙哉,但与此同时也会带来一些问题,很容易想到在多线程情况下,会出现由于指令重排序导致一些非预期结果的出现。 而被volatile修饰的变量,在汇编层指令会对volatile变量操作的指令加一个lock前缀的汇编指令。若变量被修改后,会立刻将变量由工作内存回写到主存中。那么意味了之前的操作已经执行完毕。这就是内存屏障。

可见性

我们首先看看下面的例子来理解变量对所有线程的可见性,我们定义了一个类变量num初始值为0,使用addNum()对num加1,当我们开启N个线程(n>2)去执行addNum(),发现结果num的值并不一定等于N,当调大N时可以明显感知到结果很可能小于N。

/**
 * 测试指令重排序
 */
public class ReadThread {

    private static int num = 0;

    public static void addNum() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            Thread addThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    addNum();
                }
            });
            addThread.start();
        }
        System.out.println(num);

    }

}
复制代码

运行结果不等于N的原因,是因为num++并不是一个原子操作,反编译后可以看到i++的执行指令如下:

image.png 各指令含义如下:

  • getstatic:获取指定类的静态域, 并将其压入栈顶
  • iconst_1:将int型1推送至栈顶
  • iadd:将栈顶两int型数值相加并将结果压入栈顶
  • putstatic:为指定类的静态域赋值
  • return:从当前方法返回void

通过上面指令解释大概可以猜测问题出现指令getstatic上,线程获取num值时,没有获取到num被其他线程修改后的最新值,导致最终结果不一致,这里面原因与java内存模型JMM有关,JMM中规定所有的变量都存储在主内存(Main Memory)中,每条线程都有自己的工作内存(Work Memory),线程的工作内存中保存了该线程所使用的变量的从主内存中拷贝的副本。线程对于变量的读、写都必须在工作内存中进行,而不能直接读、写主内存中的变量。同时,本线程的工作内存的变量也无法被其他线程直接访问,必须通过主内存完成。

image.png 对于普通共享变量,线程A将变量修改后,体现在此线程的工作内存。在尚未同步到主内存时,若线程B使用此变量,从主内存中获取到的是修改前的值,便发生了共享变量值的不一致,也就是出现了线程的可见性问题,这也是上述代码结果不符合预期的原因。

而当我们给变量num加上volatile后,便可以解决此问题,这是因为当对volatile变量执行写操作后,JMM会把工作内存中的最新变量值强制刷新到主内存,并且写操作会导致其他线程中的缓存无效。使得其他线程从主存中获取到volatile变量的最新值。

volatile为什么没有原子性

volatile保证了读写一致性。但是当线程2已经使用旧值完成了运算指令,且将要回写到内存时,是不能保证原子性的。

小例子:日常开发使用git或svn开发项目时存在主干和分支,有一个全项目都使用的枚举类,当小红修改了该类立即提交主干,并通知其他小伙伴:“你们使用这个类时需要在主干上拉取一下”,但是此时小明在旧版本开发完毕并且正在提交这个类,导致了冲突。

volatile读写性能

volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

结语

本文介绍了volatile的作用,主要有可见性与禁止指令重排两大作用,以及其具备可见性的原因,至于如何实现禁止指令重排序,未作详细介绍,涉及到Java代码、字节码、Jdk源码、汇编层面、硬件层面等底层实现,有兴趣可自行查阅资料了解。

猜你喜欢

转载自juejin.im/post/7085403237964070942