理解volatile关键字
前言
在多线程并发编程中volatile扮演者重要的角色,它是轻量级的synchronized,在多处理器中保证了共享变量的可见性,执行成本更低,因为它不会引起线程的上下文切换和调用,简单来说就是多线程对共享变量的修改能让其他线程立即知晓而不需要花费线程切换的相关成本,这一切都由一个叫做内存模型的东西来控制,接下来涉及到的内容可能较为羞涩难懂,或者理解不到位,加油!
重要概念
接下来介绍较为常见的一些概念。
缓存一致性
计算机在执行程序时,每条指令都是在CPU中执行的,指令执行的过程中难免会涉及到数据的读写,而数据是存放在主内存中(RAM),主内存的特点是速度慢、容量大,这就造成了每次与主内存交互时都会导致速度变慢,降低执行效率,所以在CPU中加入了高速缓存,该高速缓存实际上是少量的、速度非常快的内存,目的就是为了加快对数据的访问及操作。也就是说,执行程序时会将主内存的数据拷贝到高速缓存中,CPU直接拿高速缓存的数据进行计算并存储计算后的结果,最后在将结果更新到主内存中,只是不知道何时会写到主内存中。单核(单个CPU)中该形式并无问题,可到了多核(多CPU)时就会出现异常,也就是在多线程情况下,每条线程可能运行于不同的CPU中,因此每个线程在运行时有属于自己的高速缓存,这就有可能造成数据的不一致性,以下通过例子来更好的说明问题:
i = i + 1;
在多线程情况下,两个线程分别将主内存中i变量的值拷贝到各自的CPU高速缓存中,线程1执行程序后,将结果写回到主内存中,而此时线程2所在的CPU高速缓存的i变量仍然是初始值,执行程序后依然写回主内存中,可见这是造成数据不一致的根本原因,即拥有多个高速缓存!既然如此,何不多个CPU共享同一个高速缓存呢,很明显这会降低效率,所以为了能让多个高速缓存的内容保持一致,就定义了缓存一致性协议 - MESI协议,也就是说所有的高速缓存都必须遵守该规定。简单描述下该协议的思想,与主内存进行传输是通过一条共享的总线,简单来说,每个高速缓存都连着一条共享的总线,也就是说高速缓存可以窥探总线上发生的数据交换,跟踪其他高速缓存在做什么,所以当其中一个高速缓存所属的CPU去读写主内存时,其他CPU便会知道,它们以此来使自己的缓存保持同步,只要某个CPU一写内存,其他CPU马上就知道它们自己的高速缓存中对应的段已经失效了,下图所示是CPU与主内存的关系。

Java内存模型
为了保证多线程下共享变量的正确性,内存模型定义了对共享变量读写操作的规范,通过多条规则来保证指令执行的正确性,而Java内存模型正是基于此规范,屏蔽了各种硬件和操作系统的差异性,确保Java程序在不同的平台下始终提供一致的可见性保证。在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、形参(方法入参)和异常处理器参数不会在线程之间共享,它们不会有可见性问题,所以并不会受内存模型的影响。Java线程之间的通信由Java内存模型控制,其决定一个线程对共享变量的写入何时对另外一个线程可见。
从抽象的角度来看,Java内存模型定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了共享变量的副本,本地内存是Java内存模型的一个抽象概念,并不真实存在。

从图上可知,Java内存模型控制主内存与每个线程的本地内存之间的交互来提供可见性保证。
重排序
在执行程序时,为了提高性能,编译器与处理器常常会对指令做重排序,分为以下几种:
-
编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
-
指令级并行的重排序:处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
-
内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
其中第一项属于编译器重排序,第二三项属于处理器重排序。重排序可能会导致多线程程序出现可见性问题,所以Java内存模型规定了禁止特定类型的编译器重排序,而对于处理器重排序,Java内存模型则是要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令来禁止特定类型的处理器重排序(所谓的内存屏障就是一组处理器指令),简单来说凡是重排序会造成问题的一律被禁止。
happens-before
Java内存模型使用happens-before的概念来阐述操作之间的可见性,Java内存模型规定如果一个操作执行的结果需要对另外一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程内,也可以是在不同线程之间。以下是Java内存模型规定的happens-before原则:
-
程序顺序法则:一个线程中的每个操作,happens-before于该线程中的任意后续操作,即前者的执行结果对后者可见。其实在两个互不依赖的操作下是允许重排序的,Java内存模型认为其合法,所以该条法则实际上并不严谨
-
监视器法则:对一个锁的解锁,happens-before于后续对这个锁的加锁,即某一个线程解锁后其他线程都能知道
-
volatile变量法则:对volatile变量的写入happens-before于后续对这个volatile变量的读,即对volatile变量是先写后读
-
线程启动法则:如果线程A执行ThreadB.start操作,那么A线程的ThreadB.start操作happens-before于线程B中的任意操作(感觉说的不够准确)
-
线程终结法则:如果线程A执行ThreadB.join操作并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join操作成功返回,即线程B中执行结果在A线程中能够看到
-
传递性:如果A happens-before B,且 B happens-before C,那么A happens-before C
happens-before的前后两个操作不会被重排序且后者对前者的执行结果是可见的,也就是说操作可以在不违反happens-before规则下进行重排序。当然了,仍然还是会出现可见性问题,比如说多线程下普通读写与volatile变量写,针对这种情况可以禁止重排序,而对于多线程下多个普通变量读写没办法使用禁止重排序来解决问题,毕竟这是常见的代码操作,要是禁止了那岂不是捡了芝麻丢了西瓜,这也是我们在多线程下常出现的问题,这种情况下就应该为变量加上volatile关键字。
as-if-serial
不管怎么重排序,单线程下程序的执行结果不能被改变,编译器和处理器都必须遵守as-if-serial。该语义较为清晰明了就不做展开介绍了。
其他概念
原子性:原子操作是不能被线程调度机制中断的操作,也就是说一旦操作开始,那么它一定可以在可能发生上下文切换(切换到其他线程)之前执行完毕,对基本数据类型的变量的读取和直接赋值操作(直接赋值数据)就是原子性操作,而间接赋值操作(变量对变量之间的赋值)并不是原子性操作,还有J.U.C.Atomic包下的原子类同样也属于原子性操作。
public class Test {
private int i;
public void f1() {
i = 0;
}
public void f2() {
i++;
}
}
// 将上面的代码反编译后:javap -c Test ---->
/**
* public void f1();
* Code:
* 0: aload_0 --> this参数,每个方法都会有这么一个,虚拟机在执行的过程中会隐式传入该参数,这也是我们为什么可以再非静态方法中使用this关键字
* 1: iconst_0 --> int类型常量0进栈
* 2: putfield #2 // Field i:I --> 给对象的字段赋值,即 i = 0
* 5: return
*
* public void f2();
* Code:
* 0: aload_0 --> this参数
* 1: dup --> 复制0序号中的引用并压入栈中
* 2: getfield #2 // Field i:I --> 获取对象的字段,将其值压入栈顶
* 5: iconst_1 --> int类型常量1进栈
* 6: iadd --> 将栈中的前两个值进行相加,即 getfield + iconst_1:0 + 1
* 7: putfield #2 // Field i:I --> 给对象的字段赋值,即 i = 1
* 10: return
*
*
* 仔细观察f1与f2反编译后的结果可知,对于 i = 0 只用了一条指令(putfield)才操作,而对于 i++ 用了(getfield、putfield)指令,中间还参杂了其他的指令,这就有可能在执行这些指令时进行了上下文切换导致该值被修改
* 从而造成错误性结果,所以我们可以知道对于赋值语句是原子操作,而递增语句是非原子操作
*/
从上面的结果可知直接赋值语句是原子性操作,而递增语句是非原子性操作。
可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
volatile原理
说到原理,还是要明白volatile在内存中到底起了什么作用,官方叫做内存语义。
volatile写:当写一个volatile变量时,Java内存模型会把该线程对应的本地内存中的共享变量值刷新到主内存
voaltile读:当读一个volatile变量时,Java内存模型会把该线程对应的本地内存置为无效,接下来将从主内存中读取共享变量
更为详细地说,使用volatile修饰的变量在汇编层面上加了Lock前缀的指令,该指令会引起高速缓存中的数据回写到主内存,在搭配上缓存一致性机制来导致其他CPU的高速缓存无效,当CPU对该变量进行操作时会重新从主内存中把数据读到高速缓存中。
volatile特性
-
可见性:对一个volatile变量的读,总是能看到任意线程对该变量的写入
-
原子性:对任意单个volatile变量的读/写具有原子性,但类似volatile++复合操作不具有原子性
-
重排序:特定情况下禁止重排序,即对多个volatile变量的读/写操作不能进行重排序
以下是Java内存模型为了实现volatile内存语义而定义的重排序规则表:
| 是否能重排序 | 第二个操作 | ||
| 第一个操作 | 普通读/写 | volatile读 | volatile写 |
| 普通读/写 | NO | ||
| volatile读 | NO | NO | NO |
| volatile写 | NO | NO | |
表格中NO自然是表示禁止重排序,而对于空白处的前提是要遵守Java内存模型定义的happens-before/as-if-serial规定后方可重排序,当然了,在重排序的情况仍然可能会出现可见性问题(最常见的就是普通变量之间的操作),这种时候可以使用volatile关键字。前文我们提到禁止重排序实际上就是在指令序列中插入内存屏障,对于任意处理器平台来说会根据自身处理器的内存模型继续优化,也就是说虽然Java内存模型针对volatile关键字定义了插入内存屏障的策略,但到了不同的处理器可能会由于其处理器的特性而被省略,比如Java内存模型帮你加上了3个内存屏障,实际上到了处理器层面就只需要2个。
结束语
以上大部分的知识点属于理论型,参考了相关书籍与文章,笔者只是对其概念与介绍做了一个整理与总结,若有错误请见谅。关于volatile关键字的理解说不上很难,只是涉及到的点很多很杂,个人认为最重要的还是懂得其原理,其他概念性的理论有个印象即可。
参考资料
《Java编程思想》
《Java并发编程的艺术》
浙公网安备 33010602011771号