前言
volatile关键字在平常的使用中似乎很少用到,但是其原理值得我们研究,因为它与Java的内存模型相关,并且能保证数据的可见性,但是保证不了原子性。简单来说,线程A对一个volatile变量的修改,对于其它线程来说是可见的,即线程每次获取volatile变量的值都是最新的。
一、volatile的两层含义
volatile的实现原理其实包含了两个点:
- 内存可见性。也就是保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
基于以上两点分别给出我自己的看法
1.内存可见性
Java的内存模型中,有主内存和本地内存的区别,其中主内存主要用于存储一些共享变量,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本,本地内存是一个抽象的概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。如下图是一个JMM模型
对于普通的变量:读操作会优先读取本地内存的数据,如果本地内存中的数据不存在,则会从主内存中拷贝一份到本地内存中;写操作只会修改本地内存的数据,然后再同步到主内存中。正是因为普通变量执行这种操作,所以可能造成死循环的问题。
1
2
3
4
5
6
7
8 >boolean stop = false;
>//线程1
>while(!stop){
doSomething();
>}
>//线程2
>stop = true;上面这段代码也许在大多数情况下能正常执行,但是也有可能造成死循环的情况发生。解释:当线程1执行的前,首先stop=false一开始是在主内存中有个共享变量stop,线程1开始执行,会将stop的值拷贝一份放在线程1的本地内存中,同样的,线程2也拷贝了一份放在自己的本地内存中,当线程2执行后修改了stop的值后,是先保存在本地内存中,但还没来得及刷新到主内存,此时线程1继续执行,但是由于主内存和本地内存中都是原来的值,所以线程1循环执行,造成了死循环。
那么volatile修饰的变量是如何保证内存可见性的呢?首先,volatile会强制将修改的值立即写入主内存。其次,当一个共享变量被一个线程修改后,会导致其他线程的本地内存中这个变量无效,所以导致其他线程如果要再使用这个变量需要去主内存中再度一遍。这样子,其他内存就能够读取到变量的最新值。
2.禁止进行指令重排序
什么是指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,它只能保证程序执行的结果时正确的,但是无法保证程序的操作顺序与代码顺序一致。这在单线程中不会构成问题,但是在多线程中就会出现问题。
volatile禁止进行指令重排序有两层意思:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
比如说
1
2
3
4
5 >int x = 2; //语句1
>int y = 0; //语句2
>volatile boolean flag = true; //语句3
>int x = 4; //语句4
>int y = -1; //语句5这里语句1和2不能放在语句3之后执行,语句4和5不能放在语句3之前执行,但是语句1和2、语句4和5的顺序没有固定,并且执行到语句3的时候,可以保证之前的语句1和2是执行了的,4和5是没有执行的,也就是禁止进行指令的重排序。那么这种指令重排序有什么作用呢?
1
2
3
4
5
6
7
8 >//线程1:
>context = loadContext(); //语句1
>inited = true; //语句2
>//线程2:
>while(!inited){
sleep()
>}
>doSomethingwithconfig(context);当没有使用volatile关键字的时候,线程1的语句1和2的顺序是不定的,也就是说,可能先是语句2,再是语句1。那这样就会导致一个问题,当语句2执行完毕后,若线程2开始执行,这里的context是没有初始化的,最后的结果肯定是有问题的。
当然这边是对java代码进行重排,也只是为了简单示意,真正的指令重排是在字节码指令的层面。
另外volatile是不能保证原子性操作的,原因很简单,来看一个具体的例子。
1 | public class Test { |
这边运行的结果是啥?10000?不对,结果应该是一个小于10000的数字。我们知道,像i++这种自增操作是不能保证原子性的,因为它涉及到三个步骤:读取变量i的原始值、加1操作、写入内存,当这三个操作被割裂开来,就会导致原子性的破坏。我们知道当volatile变量写后,可以让其他线程中的共享变量置为失效的状态。比如,线程1一开始读入了Inc=200,但是还没有进行增加也没有写回主存,此时被阻塞。线程2开始执行,从主存中读取Inc=200,增加1,将201并写回主存,此时线程1继续执行,此时线程1中仍为Inc=200+1=201。最终Inc的结果也就是201,而不是202。
其实可以用synchronized、Lock或者AtomicInteger来保证原子性。
二、深入探究内存可见性和禁止指令重排序
之前我们了解了Java的内存模型以及volatile的两层含义:内存可见性和禁止指令重排序。本节就来深入探究为何volatile能够实现内存可见性和禁止指令重排序。
在《深入理解JVM》中有这么一段话我摘过来:“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”。
实际上这个lock前缀指令就是一个内存屏障。维基百科中这么定义
内存屏障也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。 这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。
在JMM中内存屏障的插入策略是这样的
- 在每个volatile写操作的前面插入一个StoreStore屏障
抽象场景:Store1; StoreStore; Store2
Store1和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见
- 在每个volatile写操作的后面插入一个StoreLoad屏障
抽象场景:Store1; StoreLoad; Load2
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。
- 在每个volatile读操作的前面插入一个LoadLoad屏障
抽象场景:Load1; LoadLoad; Load2
Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- 在每个volatile读操作的后面插入一个LoadStore屏障
抽象场景:Load1; LoadStore; Store2
在Store2被写入前,保证Load1要读取的数据被读取完毕。
总结来说,内存屏障会提供三个功能,以此来保证内存可见性和禁止指令重排序
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。
还是拿上面的例子来做一个分析吧
1 | //线程1: |
这边如果我们设置inited是volatile类型
1 | context = loadContext(); //语句1 |
语句2是一个写操作,所以在volatile之前插入一个StoreStore,在volatile之后插入一个StoreLoad
1 | context = loadContext(); //语句1 |
因为有了屏障,所以语句1和语句2的执行顺序不会改变,也就禁止指令的重排序。另外,StoreLoad屏障让之前的inited对象对其他所有处理器都可见,也就保证了可见性。
这边想到了之前的happens-before,我的理解是,happens-before是最终的目的,而实现这种目的的手段之一就是内存屏障。
三、使用场景
1.状态标记量
之前的例子
1 | volatile boolean flag = false; |
2.double check
用于单例模式中。
1 | class Singleton{ |
这边简单解释为什么需要两次check
外层的check相当于第一次获得对象之后,后面无需再进入synchronized,如果没有外面的check的话,将每次都进入synchroized,我们知道加了synchroized同步后的操作肯定更加耗时的。
加synchroized是为了避免多线程下线程不安全的问题。假如有两个线程进入到内层,可能会产生两个实例。但是加了锁之后,第一个线程结束之后便不会重新再产生对象。
内层的check的原因也是因为多线程情况下,线程A已经进入到synchronized内,然后被阻塞,线程B执行并成功创建出对象,此时线程A继续执行,如果没有内层的check,会重复创建对象。
另外再解释一下为什么要用volatile的原因,因为我们知道创建一个对象的步骤不是原子性的,而是要有三步:给对象分配内存空间,初始化,引用指向该内存空间。这边用volatile的原因就是为了保证禁止指令重排序。在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。
