volatile关键字

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易完全被正确、完整地理解,以至于很多程序员都习惯不去实习它,使用它。学校中对于volatile关键字也是一笔带过,并未深刻讲解,而工作中,很多程序员都习惯使用synchorized来处理同步问题,并且听信很多古老信条“不能理解好volatile便不要去用它”,但是这么做是舍本逐末,本末倒置的处理方式。深入了解volatile关键字以及底层虚拟机实现细节,对于理解Java并发特性有着深刻的意义。

volatile是一个类型修饰符(type specifier),就像大家更熟悉的const一样,它是被设计用来修饰被不同线程访问和修改的变量volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。

                                                                                                                      —— 摘自百度百科

众所周知,并发编程是编程中一个难点,可谓之小高山。在Java的生态圈中,并发编程的难度被极为完善的类库所降低,但同样的,这些类库也就造就了一大批知其然而不知其所以然的程序猿,何况类库并不能完美解决任何一个业务需求(JDK提供的强大类库有利亦有弊)。只有深知Java虚拟机底层对于Java并发编程的支持与缺陷,才能在合适的场景选择合适的类库,甚至自己实现类库以解决更加复杂的业务场景。想要真正理解并发编程,笔者认为,不妨静下心来从底层原理啃下去,因为当你从底层原理来看待上层应用的并发问题的时候,思路将更加清晰。

为什么会有并发问题?

稍微对计算机有所了解的猿们都明白,当前计算机的存储设备的运算能力与处理器的运算能力是有几个数量级的差距。木桶定理:决定它到底能装多少水不是由最高的那根木板所决定,而是由最低的那块木板所决定。根据木桶定理,决定计算机的运算速率与性能的,无疑是计算机当前的存储设备(想要了解计算机各个构件之间的运算速度,请自行百度)。为了让计算机可以跑得更快,计算机先驱者在处理器与储存设备之间引入了一层运算速度非常接近处理器的高速缓存:将运算需要使用到的数据复制到缓存中,让运算快速进行,当运算结束后再从缓存同步回内存之中,这样子处理器就无须等待缓慢的内存读写。但是,在多处理器系统中,每个处理器都对应着一个高速缓存,而这些高速缓存又共享同一个主内存,所以就会引入一个新的问题——缓存一致性。除了增加高速缓存之外,处理器内部也会对输入代码进行乱序执行优化,与处理器的乱序执行优化类似,Java虚拟机的即时编译也有类似的指令重排序优化。

所以并发问题的来源,无非就是多个高速缓存在共享同一个主内存时,如果多个高速缓存共享数据(比如变量)的情况下,就有可能出现在某一个时刻中高速缓存记录的同一个值得状态的不一致,为了解决缓存不一致的问题,先驱者们引入了缓存一致性协议(总线锁机制)。架构图如下:

Java内存模型

Java虚拟机规范中试图定义一种Java内存模型来屏蔽各种硬件操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。(这种设计思想在Java编程中可谓是无处不在,通过抽象来屏蔽底层实现细节,只对上层提供对外透明的接口)。

从Java虚拟机的角度来看内存分配问题,每一个Java线程都分配了各自的工作内存,并且各个工作内存之间并不透明,而同时它们共享着同一块主内存,所以并发的问题在于各个线程的工作内存与主内存交互过程中产生的数据状态不一致的问题.

贯穿Java并发编程的所有问题,莫不是围绕着以下三个原则来展开,并由点及面的产生了非常多的不同场景下使用的类库(因为是围绕着Java技术生态来讲解,所以就采用Java生态的技术体系来分析三个原则,以下原文来自《深入理解Java虚拟机》)并且,Java内存模型也是在围绕着并发过程中如何处理该三个原则来建立的:

原子性:原子具有不可分割性。由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,可以大致认为对基本数据类型的访问读写是具备原子性的(除开long和double的非原子性协定)。

可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量这种依赖主内存作为传递媒介的方式实现可见性的。无论是普通变量还是volatile变量都是如此,它们的区别在于,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。注:除了volatile之外,synchorized和final也可以实现可见性,synchorized关键字是采用同步快形式保证可见性,而final则是:被fianl修饰的字段在构造器中一旦初始化完成,并且构造器并没有把“this”的引用传递出去(this引用逃逸),那么其他线程就能看到final字段的值。

有序性:Java内存模型的有序性在前面讲解volatile时也详细的讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的(线程内表现为串行的语义),如果在另一个线程中观察另一个线程,所有操作都是无序的(指令重排序以及工作内存与主内存同步延迟现象)。volatile本身包含了禁止指令重排序的语义,而synchorized则是“一个变量在一个时刻只允许一条线程对其进行lock操作”。

Java内存模型定义了volatile变量的特殊性,天上具备了三个原则中的可见性以及有序性。为什么说volatile具备可见性呢?当一个变量被声明为volatile的时候,它将具备如下两个特性:

1、保证此变量对于所有线程的可见性,这里的可见性指的是当多个线程同时共享该变量时,一个线程对于该变量的修改,另外的线程可以立刻感知其新值。(在各个线程的工作内存中,volatile变量也不存在不一致的情况,但是由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因为可以认为不存在不一致性问题)。但是,Java内存模型规定了volatile变量的内存可见性,却并未规定它的运算过程的原子性,所以在多线程并发场景下,volatile的运算操作并非是线程安全。

2、禁止指令重排序优化。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。

比方说如下的代码:

Map configOptions;
char[] configText;

// 思考此变量为何一定要申明为volatile
volatile boolean initialized = false;


// 假设一下代码在线程A中执行
// 模拟读取配置文件,并将读完完载入内存后将initilized设置为true以通知其他线程配置可用。
configOptions = new HashMap<>();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initilized = true;


// 假设一下代码在线程B执行
// 等待initlized为true,代表线程A已经把配置信息初始化完成
while(!initilized) {
	sleep();
}
// 使用线程A读取的配置信息
doSomethingWithConfig();

如果线程A的执行代码在还没有读取完配置信息的时候,先执行了initlized的赋值操作,那么线程B就可以在线程A还未完成读取操作的时候,去使用配置信息,结果自然就会发生重大错误。指令重排序是机器指令优化操作,属于系统优化级别。

那么volatile是如何禁止系统指令的重排序的呢?

在将变量定位volatile的时候,如果对volatile变量进行操作,Java虚拟机对在其操作(赋值,运算等等)的时候,插入一道内存屏障指令。内存屏障指令的作用就是保证对变量的操作修改会即时同步到主内存中,而缓存一致性(总线锁机制)会通过一种嗅探指令来标识其他线程当前记录的该变量的值为失效状态,那么当他们需要访问该变量之时会主动发起指令从主内存中同步该变量的真实值。

简单的总结来看,从分析了为什么会有并发的问题,再到Java内存模型,以及它是如何解决并发的三个原则(特性),再回过头来思考并发,似乎也就是那么一回事。那么再来看volatile变量,底层对它做了什么,是如何保证可见性以及有序性,再重新回头来思考volatile的适用场景,以及如何在synchorized与volatile中抉择,也就可以明白了。

volatile变量的具备多线程可见性,以及禁止指令重排序的能力,但是本身的运算并非原子操作,所以volatile的适用场景可简单总结为两点:

A、运算结果不依赖变量的当前值,或者能够保证只有单一的线程修改变量的值。

B、变量不需要与其它的状态变量共同参与不变约束。

至于如何在synchorized以及volatile变量中抉择?volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为需要在本地代码中插入许多内存屏障指令来保证处理器不会发生乱序执行。所以建议优先考虑volatile变量,只有当volatile的能力并不足以解决业务场景的情况下再考虑synchorized以及juc并发编程包下的api。

但是如果所有的有序性都需要依赖voaltile和synchorized来完成,那么很多操作就会变得非常繁琐,而我们再编写并发代码的时候并未有所察觉,是因为Java语言中的一个happend-before原则。它作为一种规范指导我们如何判断数据是否存在竞争、线程是否安全。通过这些规则(规范)我们可以解决并发环境下两个操作之间是否存在冲突的所有问题,不再局限与volatile与synchorized的可见性或者原子同步,Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无需任何同步器协助就已经存在。。(以下先行发生原则摘抄自《深入理解Java虚拟机》)

  • 程序次序规则。一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后边的操作。
  • 管程锁定规则。一个unlock操作先行发生于后边对同一个锁的lock操作。同一个锁,以及时间上的先后顺序。
  • volatile变量规则。
  • 线程启动规则:thread对象的start方法先行发生于此线程的每一个动作。
  • 线程终止规则。线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过thread.join()方法结束、thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则。对线程interrupt()方法的调用先于发生于被中断线程的代码检测到中断时间的发生,可以通过threead.interrupt()方法检测到是否中断。
  • 对象终结规则。一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()的方法的开始。
  • 传递性。如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

以上学习主要来自于《深入理解Java虚拟机》,作者周志明周老师,写的非常非常棒,非常推荐。

猜你喜欢

转载自my.oschina.net/u/1589819/blog/1612089