深度解析volatile—底层实现

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介: 我们都知道,Java关键字volatile的作用1、内存可见性2、禁止指令重排序可见性是指,在多线程环境,共享变量的操作对于每个线程来说,都是内存可见的,也就是每个线程获取的volatile变量都是最新值;并且每个线程对volatile变量的修改,都直接刷新到主存。

我们都知道,Java关键字volatile的作用

1、内存可见性
2、禁止指令重排序

可见性是指,在多线程环境,共享变量的操作对于每个线程来说,都是内存可见的,也就是每个线程获取的volatile变量都是最新值;并且每个线程对volatile变量的修改,都直接刷新到主存。
下面重点介绍指令重排序。

为什么要指令重排序?

为了提高程序执行的性能,编译器和执行器(处理器)通常会对指令做一些优化(重排序)

1、编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
2、处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

学过《编译原理》同学应该知道,现代高级编程语言的编译器,实现都很复杂。

编译器基本构造包括:语法分析、词法分析、语义分析、中间代码生成、指令优化、目标代码产生。

第一阶段:编译器优化,就是发生在编译阶段,就Java而言,就是java源码编译生成class字节码的时候,对编译生成的中间代码进行的一次指令优化。Java的编译器是javac.exe。

第二阶段:执行器(处理器)优化,和不同的处理器硬件厂商的实现有关,也和Java的执行器(java.exe,也称Java解释器)有关。执行器优化,是对于机器指令在目标平台的机器上运行,做的一层优化。

我们知道,现代高级编程语言,经过编译后,产生目标代码,如.java的源文件编译后生成.class字节码文件,.cpp源文件经过C++编译器编译后生成.o对象文件。

这些编译后生成的文件,不能直接在机器上运行,而是需要转化成特定平台的机器指令。机器能够运行的指令,是需要这个平台、这个机器能正确识别的。

相同的一份源码,最终转化成不同平台上的机器指令,是不同的。

这也更容易理解:汇编指令,并不是跨平台的。Windows下通常使用Intel汇编,而Linux下多用AT&T汇编,它们在语法上存在差异,运行效果也依赖于各自平台的实现。

在Java中,为了提高运行效率,javac编译器,和java解释器,在2个阶段分别对指令进行了优化,也就是重排序。

Java重排序的前提:在不影响 单线程运行结果的前提下进行重排序。也就是在单线程环境运行,重排序后的结果和重排序之前按代码顺序运行的结果相同。

指令重排序对单线程没有什么影响,它不会影响程序的运行结果,但是会影响多线程的正确性。

Java因为指令重排序,优化我们的代码,让程序运行更快,也随之带来了多线程下,指令执行顺序的不可控。既然指令重排序会影响到多线程执行的正确性,那么我们就需要某些情景下禁止重排序。Java提供给我们禁止重排序能力的操作——就是volatile。

那么JVM的volatile是如何禁止重排序的呢?

在具体探究之前,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:

1、同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。
2、监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则)
3、对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则)
4、线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则)
5、线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。
6、如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。

在JVM中,将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则、volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。

我们着重看第三点volatile规则:对volatile变量的写操作 happen-before 后续的读操作。为了实现volatile内存语义,JMM会重排序,其规则如下:

是否重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写
volatile读 NO NO NO
volatile写 NO NO



为了探究volatile底层的实现原理,进行了如下探究。

通过javap 命令,将字节码文件反编译。观察反编译的结果,对于volatile修饰的变量,发现反编译得到的代码并没有什么帮助,和不加volatile修饰的变量没有任何区别。也就是说,字节码层面volatile变量并没有什么不同。

下面通过查看Java的汇编指令,查看Java代码最真实的运行细节。

如何查看Java的汇编指令,可以阅读:https://www.jianshu.com/p/93821b08e774

img_adbc0c3ca67f06c43f75185596715b9d.jpe

通过使用-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly

IDEA打印出了源代码的汇编指令。我们看到红色线框里面的那行指令:putstatic a ,将静态变量a入栈,注意观察add指令前面有一个lock前缀指令。

加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。我们发现,volatile变量在字节码级别没有任何区别,在汇编级别使用了lock指令前缀。

lock是一个指令前缀,Intel的手册上对其的解释是:

Causes the processor's LOCK# signal to be asserted during execution of the accompanying instruction (turns the instruction into an atomic instruction). In a multiprocessor environment, the LOCK# signal insures that the processor has exclusive use of any shared memory while the signal is asserted.

简单理解也就是说,lock后就是一个原子操作。原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。

当使用 LOCK 指令前缀时,它会使 CPU 宣告一个 LOCK# 信号,这样就能确保在多处理器系统或多线程竞争的环境下互斥地使用这个内存地址。当指令执行完毕,这个锁定动作也就会消失。

是不是感觉有点像Java的synchronized锁。但volatile底层使用多核处理器实现的lock指令,更底层,消耗代价更小。

因此有人将Java的synchronized看作重量级的锁,而volatile看作轻量级的锁 并不是全无道理。

lock前缀指令其实就相当于一个内存屏障。内存屏障是一组CPU处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。

编译器和执行器 可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。正如去西藏途中各个站点的先后顺序在你心中都一清二楚。

内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪个CPU执行的。这正是volatile实现内存可见性的基础。

内存屏障细说来有写屏障、读屏障、读写屏障,而且内存屏障的实现依赖于编译器和机器两部分。

编译器在编译过程中可能会对指令重排序,这样开发者通过显式地标注告知编译器,避免编译器最终生成的代码行为违背预期,对于 Java 而言,不光生成的 bytecode 需要保存 volatile 的语义,连运行时的 JIT 代码的行为也要遵守相应的约束;即插入内存屏障后,告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行,从而实现了禁止重排序。

关于内存屏障的一些具体细节,大佬Martin写了一篇文章《going into memory barriers》介绍,外网可以看看。

小结:

1、Java重排序的前提:在不影响 单线程运行结果的前提下进行重排序。也就是在单线程环境运行,重排序后的结果和重排序之前按代码顺序运行的结果相同。
2、指令重排序对单线程没有什么影响,它不会影响程序的运行结果,反而会优化执行性能,但会影响多线程的正确性。
3、Java因为指令重排序,优化我们的代码,让程序运行更快,也随之带来了多线程下,指令执行顺序的不可控。
4、volatile的底层是通过lock前缀指令、内存屏障来实现的。


存档文章

目录
相关文章
|
5月前
|
缓存 Java 编译器
必知的技术知识:Java并发编程:volatile关键字解析
必知的技术知识:Java并发编程:volatile关键字解析
26 0
|
6月前
|
缓存 Java 编译器
JMM内存模型 volatile关键字解析
JMM内存模型 volatile关键字解析
46 0
|
存储 缓存 Java
volatile 关键字 (详细解析)
volatile 关键字 的工作原理: 1、子线程t 和 main方法从主内存读取到数据放入其对应的工作内存,此时 flag的值为false 2、子线程t 将flag的值更改为true 3、在某一时刻 子线程t将flag的值写回主内存后,失效其他线程对此变量副本 4、main方法 再次对flag进行操作的时候线程会从主内存读取最新的值,放入到工作内存中
157 0
|
存储 缓存 Java
volatile关键字解析
volatile关键字解析
178 0
volatile关键字解析
|
存储 缓存 安全
基础篇:深入JMM内存模型解析volatile、synchronized的内存语义
总线锁定:当某个CPU处理数据时,通过锁定系统总线或者是内存总线,让其他CPU不具备访问内存的访问权限,从而保证了缓存的一致性
97 0
|
存储 缓存 Java
Java并发编程之Volatile关键字解析
在java的并发编程中我们经常会使用到Volatile关键字。而关于Volatile关键字的使用以及Volatile关键字的特性和实现原理也是在笔面试中经常会遇到的问题了。
108 0
Java并发编程之Volatile关键字解析
|
缓存 Java 编译器
Java并发编程学习笔记:volatile关键字解析
Java并发编程学习笔记:volatile关键字解析
|
缓存 Java 编译器
Java并发编程:volatile关键字解析
Java并发编程:volatile关键字解析
136 0
Java并发编程:volatile关键字解析
|
存储 缓存 算法
并发编程(一)| Volatile 与 Synchronized 深度解析
今天这篇是我的好朋友 evil say的投稿,这小伙现在大四,客观来说,大四有这个实力,我觉得很不错。他目前正在找实习,如果看了本文觉得他可以,有公司有坑位、愿意抛出橄榄枝的话。请联系他:hack7458@outlook.com
|
安全 前端开发 Java
volatile关键字解析~高级java必问
昨天我介绍了原子性、可见性、有序性的概念,那么今天就来见识下这些概念的主角-volatile关键字。
94 0
volatile关键字解析~高级java必问

推荐镜像

更多