这篇文章主要以介绍volatile关键字的语义以及其原理为主,顺便对一些相关说法进行谈论探究,文章配图较少,以文字为主,所以可能略显枯燥。
本文会从 语义解释 与 底层实现 两个不同的角度来解释volatile的实现原理。
(下文简称 volatile写操作=volatile写,volatile读操作=volatile读)
首先,我们先说明下 volatile 在 java 中的语义:
- 1.增强被修饰部分的可见性
- 2.禁止指令重排序
在此先说结论:
volatile读 的汇编指令中多出了一个 lock前缀 修饰的指令,这条指令完成了volatile几乎所有的功能。
下面来详细说明JAVA如何实现volatile功能的。
volatile的语义解释:
其可见性部分是通过直接访问内存,并且清空其它线程工作内存缓存值达到的。工作内存是一个与主内存相对应的词,如果联系到JMM内存布局,那么可以把主内存理解为存储对象实例的堆内存,把工作内存理解为线程内的缓存,其存储有一份对象实例中某些运算需要数据的副本。
禁止指令重排序部分,其通过实现java中happens-before的volatile规则做到的,为了实现这个规则,其在volatile写之前插入了一个 JMM的内存屏障(注意,这只是一个JVM的内存屏障概念,其并不等同于硬件级别的内存屏障概念)达成的,这个内存屏障既是读写屏障,也是写写屏障,因此使系统本身无法干预其指令顺序。
volatile的底层实现:
实际上 volatile 的底层实现并没有那么复杂,其主要功能大都体现在 volatile写 的处理上面,volatile读 与普通读差异不大(在《深入理解JAVA虚拟机》的volatile部分有提到)。可以这么说,不论是volatile的可见性还是禁止指令重排序部分,都是由 volatile写 实现的。可能很多人都想不到,volatile 看似复杂的功能与语义,仅仅只是由一条汇编指令实现的(参考《深入理解JAVA虚拟机》):
lock addl $0x0,(%esp)
(!!!以下为硬件层面解释,与JVM虚拟机无关。)
可能大家想不到,volatile所有功能 都源于这个 lock前缀。
lock前缀 的汇编语义其实就是锁,先说一下其会做哪几件事(在intel用户手册上,很多处理器并不会锁住总线,所以在 intel汇编规则 下,1.1的情况时不会出现的,在AT&T汇编规则下,1.2的情况时不会出现的):
-
1.1.如果一个值没有在缓存出现,只在主内存中存在。那么lock执行期间,会锁住总线,使其它内核缓存无法通过总线与主内存进行数据交换(AT&T汇编下)
-
1.2.如果一个值在缓存当中已经存在,那么其会使用缓存锁锁住缓存,然后触发缓存一致性(intel使用snoopy协议实现)来确保缓存中的值相同(intel汇编下)
-
2.其将自己的缓存值写回主内存,并且使其它的缓存值失效
在此我们看下《深入理解JAVA虚拟机》中那段汇编代码:
mov $0x3375cdb0,%esi
mov %eax,0x150(%esi)
shr $0x9,%esi
movb $0x0,0x1104800(%esi)
lock addl $0x0,(%esp)
lock前缀会通过锁的方式使其修饰的这条指令,lock addl $0x0,(%esp)
这整条呈现原子性,因此 lock前缀的操作 与 addl的操作 并不会产生线程安全问题。addl $0x0,(%esp)
这句指令本身为空操作(扩展中会解释),但其并非无意义,其确定了 lock前缀 的操作数。
addl $0x0,(%esp)
中,操作数(因为其为addl这种指令,其格式位AT&T格式,因此目的数在前,操作数在后)为 %esp,esp为32位堆栈顶寄存器,其存储有本次计算的结果,并且lock前缀的操作数也是 %esp。
那么还是以此代码为例,因为代码片段有限,因此我只能加以推论和猜测。在lock前缀的上一条指令中,movb指令的操作数是esi,esi为源变址寄存器,我猜测其应该指向缓存堆栈,那么计算结果应该放置在了堆栈顶,也就是说,此时esp存储的应该就是计算结果。esi为源变址寄存器,我猜测其应该指向缓存堆栈,那么计算结果应该防止在了堆栈顶,也就是说,此时esp存储的应该就是计算结果。
lock addl $0x0,(%esp)
这条指令在汇编中应该时这么运行的:
- 1.lock前缀给总线加锁
- 2.开始对esp寄存器的值进行加0操作(addl指令内容)
- 3.把esp寄存器的值强制写入主内存中,并且使其它内核缓存(java中即指各线程中的工作内存的值)中对应的值失效
那么接下来我们看下,这个 lock前缀 的指令是如何完成volatile语义的。
首先是其内存屏蔽作用(既禁止指令重排序),很显然,这里面并 没有使用真正的内存屏蔽指令 (扩展有介绍),因为lock前缀修饰指令存在于 volatile写 当中,而因为其给总线加锁,因此此时其它指令无法通过总线对其内存值进行写操作,又因为其会无效化其它内核的缓存值,因此其它线程无法通过缓存进行读取,总线被锁,也无法通过总线进行读取。也就是说,volatile写 之后的所有 volatile读 与 volatile写 都需要等待本条 volatile写 操作完成才可以继续执行,这就起到了内存屏障的作用,起到了禁止指令重排序的作用。
然后是内存可见性的实现,因为其锁住期间,强制向主内存写入值,并且其无效化其它内核的缓存值(既JMM中线程的工作内存),因此其结果是直接同步于主内存的,其它线程需要使用此值时,必然要去主内存当中同步此值。
!!!需要注意的是,volatile本身并不会避免在使用时从主内存复制一份副本到缓存(工作内存)中来,也就是说,就算你使用volatile修饰变量,在读操作时,你的缓存当中还是有一份副本的,不过这个副本很可能被其它volatile写操作无效化,导致你需要从主内存直接获取最新值。
总之,其是靠 强制向主内存写入 与 无效化其它缓存值 来实现可见性的,而并没有跳过缓存。
扩展:
-
1.
addl $0x0,(%esp)
的语义addl中的l后缀:其意味着后面会跟随32位操作数。 esp:e为32位寄存器,s为顶指针,p为堆栈,esp为堆栈顶指针。
整句意思为:esp = esp + 0
,即堆栈顶指针移动0 很明显这是一个空操作,只是因为 lock前缀 后面不可跟
nop空指令,因此选择使用堆栈顶指针空操作这种 效率较高的 “空操作” 使语义完整。(参考《深入理解JAVA虚拟机》) -
2.关于内存屏障
如果说volatile没有使用真正的内存屏蔽技术,那么真正的内存屏蔽技术又是什么呢? 根据 intel 的汇编指令(还有一种AT&T指令,我并没有在AT&T版本汇编中找到 内存屏蔽指令。。。)中, 其真正的内存屏蔽指令如下:
Sfence:为StoreStore屏障
Lfence:为LoadLoad屏障
Mfence:为全能屏障这些内存屏障我没有具体研究过,因此不再多说。
真正的内存屏障指令的效率要高于lock前缀,因为其不需要锁总线,而为什么JAVA的volatile使用lock前缀实现 语义,我的猜测因该使因为内存屏障指令功能单一,并不能强制绑定上一条指令进行原子化处理,也无法做到无效化
其它内核缓存值,自然其就无法实现扩展被修饰变量可见性的目的。而且AT&T汇编指令貌似没有 内存屏蔽 指令,
因此使用通用的lock前缀来完成volatile的功能。 -
3.lock前缀如何实现锁住内存总线的?
cpu有一个引脚专门控制lock操作,当执行到lock操作时,会给此引脚加上低电平来触发锁总线操作,当lock修饰 的指令执行完,引脚恢复高电平,此引脚时cpu控制总线的一种方式。
(在此声明,文章关于汇编部分查阅了一些网上的文章,因为太杂太多,说法不一等,所以在此没有引用原文,也没有声明引用,有兴趣大家可以自行查阅资料)