volatile原理和使用
它在多处理器开发中保证了共享变量的”可见性”.可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值.如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度.本文将深入分析在硬件层面上Intel处理器是如何实现volatile的
volatile的用法
volatile通常被比喻成”轻量级的synchronized”,也是Java并发编程中比较重要的一个关键字.和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量.无法修饰方法及代码块等
volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了
1 | public class Singleton { |
如以上代码,是一个比较典型的使用双重锁校验的形式实现单例的,其中使用volatile关键字修饰可能被多个线程同时访问到的singleton
volatile的原理
CPU的术语
术语 | 英文单词 | 术语描述 |
---|---|---|
内存屏障 | memory barriers | 是一组处理器指令,用于实现对内存操作的顺序限制 |
缓冲行 | cache line | 缓存中可以分配的最小存储单位.处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存 |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中
但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里
所以如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存.而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中.这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的
即Lock前缀指令在多处理器下会引发了两件事情:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
volatile与可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
我们在Java内存模型中分析过:Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存.不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行.所以就可能出现线程1改了某个变量的值,但是线程2不可见的情况
前面的关于volatile的原理中介绍过了,Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新.因此可以使用volatile来保证多线程操作时变量的可见性
volatile可见性与内存屏障
volatile对于可见性的实现,内存屏障也起着至关重要的作用.因为内存屏障相当于一个数据同步点,他要保证在这个同步点之后的读写操作必须在这个点之前的读写操作都执行完之后才可以执行.并且在遇到内存屏障的时候,缓存数据会和主存进行同步,或者把缓存数据写入主存、或者从主存把数据读取到缓存
前文介绍过缓存缓存一致性协议,同时也提到过内存一致性模型的实现可以通过缓存一致性协议来实现.同时留了一个问题:已经有了缓存一致性协议,为什么还需要volatile?
这个问题的答案可以从多个方面来回答:
- 并不是所有的硬件架构都提供了相同的一致性保证,Java作为一门跨平台语言,JVM需要提供一个统一的语义
- 操作系统中的缓存和JVM中线程的本地内存并不是一回事,通常我们可以认为:MESI可以解决缓存层面的可见性问题.使用volatile关键字,可以解决JVM层面的可见性问题
- 缓存可见性问题的延伸:由于传统的MESI协议的执行成本比较大.所以CPU通过Store Buffer和Invalidate Queue组件来解决,但是由于这两个组件的引入,也导致缓存和主存之间的通信并不是实时的.也就是说,缓存一致性模型只能保证缓存变更可以保证其他缓存也跟着改变,但是不能保证立刻、马上执行
- 其实在计算机内存模型中,也是使用内存屏障来解决缓存的可见性问题的(再次强调:缓存可见性和并发编程中的可见性可以互相类比,但是他们并不是一回事儿).写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存.读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列).进而避免由于Store Buffer和Invalidate Queue的非实时性带来的问题
所以内存屏障也是保证可见性的重要手段,操作系统通过内存屏障保证缓存间的可见性,JVM通过给volatile变量加入内存屏障保证线程之间的可见性
内存屏障也是保证可见性的重要手段,操作系统通过内存屏障保证缓存间的可见性,JVM通过给volatile变量加入内存屏障保证线程之间的可见性
总结一下Java中的内存屏障:用于控制特定条件下的重排序和内存可见性问题.Java编译器也会根据内存屏障的规则禁止重排序
volatile与有序性
有序性即程序执行的顺序按照代码的先后顺序执行
我们在Java内存模型中分析过:除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save有可能被优化成load->save->add .这就是可能存在有序性问题
而volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止指令重排优化等
普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致
volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行.这就保证了有序性.被volatile修饰的变量的操作,会严格按照代码顺序执行,load->add->save 的执行顺序就是:load、add、save
volatile禁止指令重排的原理
volatile是通过内存屏障来来禁止指令重排的
内存屏障(Memory Barrier)是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作.下表描述了和volatile有关的指令重排禁止行为:
是否能重排序 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|
第一个操作 | 普通读/写 | volatile读 | volatile写 |
普通读/写 | NO | ||
volatile读 | NO | NO | NO |
volatile写 | NO | NO |
从上表我们可以看出:
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序.这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序.这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序
具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
- 在每个volatile读操作的后面插入一个LoadStore屏障
- 对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
内存屏障 | 第二个操作 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|---|
第一个操作 | 普通读 | 普通写 | volatile读(同步块入口) | volatile写(同步块出口) |
普通读 | LoadStore | |||
普通写 | StoreStore | |||
volatile读(同步块入口) | LoadLoad | LoadStore | LoadLoad | LoadStore |
volatile写(同步块出口) | StoreLoad | StoreStore |
所以volatile通过在volatile变量的操作前后插入内存屏障的方式,来禁止指令重排,进而保证多线程情况下对共享变量的有序性
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图,如下图所示:
上图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了.这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存
volatile写后面的StoreLoad屏障.此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序.因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return).为了保证能正确实现volatile的内存语义
JMM在采取了保守策略:
在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障.从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障.因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量.当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升.从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率
volatile与原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行
我们在并发编程的多线程问题中分析过:线程是CPU调度的基本单位.CPU有时间片的概念,会根据不同的调度算法进行线程调度.当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权.所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题
我们介绍synchronized的时候,提到过为了保证原子性,需要通过字节码指令monitorenter和monitorexit,但是volatile和这两个指令之间是没有任何关系的.所以volatile是不能保证原子性的
在以下两个场景中可以使用volatile来代替synchronized:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值
- 变量不需要与其他状态变量共同参与不变约束
除以上场景外,都需要使用其他方式来保证原子性,如synchronized或者concurrent包
我们来看一下volatile和原子性的例子:
1 | public class Test { |
以上代码比较简单,就是创建10个线程,然后分别执行1000次i++ 操作.正常情况下,程序的输出结果应该是10000,但是多次执行的结果都小于10000.这其实就是volatile无法满足原子性的原因
为什么会出现这种情况呢,那就是因为虽然volatile可以保证inc在多个线程之间的可见性.但是无法inc++的原子性
既生synchronized,何生volatile
synchronized和volatile两个关键字是Java并发编程中经常用到的两个关键字,而且通过前面的回顾,我们知道synchronized可以保证并发编程中不会出现原子性、可见性和有序性问题,而volatile只能保证可见性和有序性,那么既生synchronized、何生volatile?
synchronized的问题
我们都知道synchronized其实是一种加锁机制,那么既然是锁,天然就具备以下几个缺点:
- 有性能损耗
虽然在JDK 1.6中对synchronized做了很多优化,如如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等(深入理解多线程(五)—— Java虚拟机的锁优化技术),但是他毕竟还是一种锁
以上这几种优化,都是尽量想办法避免对Monitor(深入理解多线程(四)—— Moniter的实现原理)进行加锁,但是并不是所有情况都可以优化的,况且就算是经过优化,优化的过程也是有一定的耗时的
所以无论是使用同步方法还是同步代码块,在同步操作之前还是要进行加锁,同步操作之后需要进行解锁,这个加锁、解锁的过程是要有性能损耗的
关于二者的性能对比,由于虚拟机对锁实行的许多消除和优化,使得我们很难量化这两者之间的性能差距,但是我们可以确定的一个基本原则是:volatile变量的读操作的性能与普通变量几乎无差别,但是写操作由于需要插入内存屏障所以会慢一些,即便如此,volatile在大多数场景下也比锁的开销要低 - 产生阻塞
synchronized的实现原理,无论是同步方法还是同步代码块,无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的
基于Monitor对象,当多个线程同时访问一段同步代码时,首先会进入Entry Set,当有一个线程获取到对象的锁之后,才能进行The Owner区域,其他线程还会继续在Entry Set等待.并且当某个线程调用了wait方法后,会释放锁并进入Wait Set等待
所以synchronize实现的锁本质上是一种阻塞锁,也就是说多个线程要排队访问同一个共享对象
而volatile是Java虚拟机提供的一种轻量级同步机制,他是基于内存屏障实现的.说到底,他并不是锁,所以他不会有synchronized带来的阻塞和性能损耗的问题
volatile附加功能
volatile其实还有一个很好的附加功能,那就是禁止指令重排
总结与思考
我们介绍过了volatile关键字和synchronized关键字.现在我们知道,synchronized可以保证原子性、有序性和可见性.而volatile却只能保证有序性和可见性
那么我们再来看一下双重校验锁实现的单例,已经使用了synchronized,为什么还需要volatile?
1 | public class Singleton { |