一般开发中用不到的volatile关键字

说起volatile关键字我脑子里第一个想到的就是面试题。第一次接触这个东西也是看面试题的时候,而且有好几次面试也都被问到了这个问题。不过每次都是背一背面试大纲也就这么过去了,后来回想起来总觉得不对,虽然面试的时候知道怎么去给面试官他想要的答案,但是有时候思考起这个东西感觉还是云里雾里。目前手上有《深入理解Java虚拟机》和《并发编程的艺术》两本书,所以就地取材对照着这两本书来个深入理解volatile关键字。

先马后炮一波,为什么要使用volatile关键字?答曰: 并发情况下使用共享变量,volatile关键字能保证共享变量的可见性和有序性,但是不能保证原子性。那么为什么要如此执着于这三个特性?因为在并发情况下,想要正常的使用共享变量,那就必须要满足这三个特性。

可见性

书上《深入理解Java虚拟机》说,当一个变量定义为volatile之后,它将具备可见性。这里的可见性指一个线程修改了这个被volatile修饰的变量,新值对其他线程来说立即可见。对于普通变量来说,就需要向主内存写入完成后其他线程去读。虽然两种变量的操作相差无几,但是它们的差别在哪里?书上讲的有点隐晦,总的来讲就是volatile关键字修饰的变量,线程对它进行更新后,它会被刷回主内存,而普通变量则需要线程主动的去向主内存写入。至于被volatile关键字修饰的变量为什么会有这样的骚操作,让我们来瞄一眼《并发编程》P9,书上说的已经玄乎到汇编的程度了。来来来敲黑板了,有volatile修饰的变量在进行写操作后,会多出一个lock前缀指令的汇编代码。lock指令会触发两件事

  1. 将当前处理器缓存行的数据写回系统内存。
  2. 这个写回内存操作会使在其他CPU里缓存了该内存地址的数据无效(对应工作内存中变量的副本)。

以上的第一条对应着被修改的数据会被刷回主内存。第二条中由于线程中缓存都失效,为了获取变量的值只能重新去主内存去读。这样就保证了共享变量的可见性,这里再说一遍可见性是指一个线程对值的修改对其他线程来说立即可见,而这一切都归功于这个lock前缀的指令。至于为什么对于普通变量来说不是立即可见?这个要去看Java内存模型。来看图

这边先介绍一下故事的背景。Java内存模型规定所有的变量都储存在主内存中。每条线程都有一个工作内存(相当于线程与主内存之间的高速缓存),线程使用变量时都会从主内存中拷贝一份副本放在工作内存中,线程对变量的操作都是在工作内存中进行,线程之间的交互都要通过主内存来完成。看到这里应该有点眉目了吧,线程之间是独立的,每个线程都有个独立的工作内存,工作内存之间是无法互相访问的。至于为什么线程不直接操作主内存?其中的缘由应该也是和为什么要使用缓存的理由是一致的,为了减少交互的开销提高运行速度。

有序性

书上又说,volatile关键字的第二个语义是禁止指令重排。这边先解释一下什么是指令重排。指令重排是程序执行时编译器和处理器对指令序列进行重排序而达到优化程序的一种手段。在讲重排序之前,要先讨论一下数据依赖性。何为数据依赖性?来看看《并发编程》P28

a = 1;
b = a; // 写后读

a = 1;
a = 2; // 写后写

a = b;
b = 1; // 读后写
复制代码

以上的三种情况,重排序它们的执行顺序,程序的结果就会被改变。

a = 1;
b = 1;
复制代码

对于这种对没有依赖的指令,编译器可能就会对它们进行重排序了。

而volatile关键字又是如何保证有序性(通过禁止重排序)的呢?答曰内存屏障,重排序时后面的指令不能重排序到内存屏障之前的位置。在单线程情况下,在as-if-serial语义(编译器不会对存在数据依赖关系的操作做重排序)的保护下,指令(无数据依赖的指令)的任意排序都不会影响程序的执行结果。在并发的情况下,当线程执行到有数据依赖的操作时,就插入内存屏障以保证程序有序的执行。最后通过lock指令把修改同步到内存,这样就达到重排序无法越过内存屏障的效果。

原子性

讲完其他两个然后来聊聊原子性。为什么volatile变量的运算在并发的情况下不能保证原子性?因为Java中的运算本身就不是原子操作。这里联想一下数据库事务特性之一的原子性,对于数据库的操作要么全部成功要么全部不成功,对于这里的原子性也是一样的。在Java运算的操作中,一个简单的操作可能会被编译成多个指令,万一指令执行中断,就可能出现计算错误的结果。这边就举一个最简单的i++ 操作吧。在javap反编译后,i++被编译成以下5个指令(通过javap -verbose XXX.class命令反编译.class文件)。对于每个指令的具体含义详见《虚拟机》附录B。

1. getstatic //0xb2 获取指定类的静态域,并将其值压入栈顶
2. dup //0x59 复制栈顶数值并复制压入栈顶
3. iconst_1 // 0x04 讲int型1推送至栈顶
4. iadd //0x60 将栈顶两int类型的值弹出栈,相加并压入栈顶
5. putstatic //0xb3 为指定的类的静态域赋值
复制代码

当共享变量i= 0,线程A、B同时对i变量进行++ 操作,若线程A执行到第4步的时候,线程B这边已经完成了自增并且写入了主内存,主内存中i的值从0变成了1。之后线程A也完成了自增然后往主内存写值,主内存中i的值还是1。这就出现了并发情况下共享变量计算不正确的情况。这时为了保证原子性使共享变量能在并发情况下正常使用,可以通过加锁的形式(synchronized关键字、JUC中的锁)或者使用JUC中的原子类来实现。

这边对volatile关键字的三个特性也都解释完了,虽然这个volatile我写代码到现在都没用过,但是也通过这个关键字了解到了内存模型还有一些字节码指令。这篇我也写的非常的虚,上面的内容几乎都是从书上搬来的再加上自己的一点思考,希望能帮读这篇文章的同学理一下思路。

学习的最终目的并不是为了面试,面试只是一个激励学习的动机。把握面试题,享受学习新知识的乐趣。

参考:

《深入理解Java虚拟机》
《并发编程艺术》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值