本文脑图volatile
是java
中热门关键字,也是面试中的高频问点,今天就来深刻的从各类volatile
面试题中剖析它的底层原理实现,并经过简单的代码去证实。java
在深刻volatile
以前,咱们先从原理入手,而后层层深刻,逐步剖析它的底层原理,使用过volatile
关键字的程序员都知道,在多线程并发场景中volitile
可以保障共享变量的可见性。程序员
那么问题来了,什么是可见性呢?volatile是怎么保障共享变量的可见性的呢?面试
在说可见性以前,咱们先来了解在多线程的条件下,线程与线程之间是怎么通讯的,咱们先来看看一张图:缓存
在Java线程中每次的 读取和 写入不会直接操做主内存,由于 cpu
的速度远快于主内存的速度,如果直接操做主内存,大大限制了cpu的性能,对性能有很大的影响,因此每条线程都有各自的 工做内存。多线程
这里的工做内存相似于缓存,并不是实际存在的,由于缓存的读取和写入的速度远大于主内存,这样就大大提升了cpu
与数据交互的性能。并发
全部的共享变量都是直接存储于主内存中,工做内存保存线程在使用主内存共享变量的副本,当操做完工做内存的变量,会写入主内存,完成对共享变量的读取和写入。app
在单线程时代,不存在数据一致性的的问题,线程都是排队的顺序执行,前面的线程执行完才会到后面的线程执行。ide
随着计算机的发展,到了 多核多线程的时代, 缓存的出现虽然提高了 cpu
的执行效率,可是却出现了 缓存一致性的问题,为了解决数据的一致性问题,提出两种解决方案:性能
总线上加Lock#锁:该方法简单粗暴,在总线上加锁,其它cpu的线程只能排队等候,效率低下。优化
缓存一致性协议:该方案是JMM中提出的解决方案,经过对变量地址加锁,减少锁的粒度,执行变得更加高效。
为了提升程序的执行效率,设计者们提出了底层对编译器和执行器(处理器)的优化方案,分别是编译器和处理器的重排序
那么什么是编译器重排序和处理器啊重排序呢?
编译器重排序就是在不改变单线程的语义的前提下,能够从新排列语句的执行顺序。
处理器排序是在机器指令的层面,假如不存在数据依赖,处理器能够改变机器指令的执行顺序,为了提升程序的执行效率,在多线程中假如两行的代码存在数据依赖,将会被禁止重排序。
不论是编译器重排序和处理器的重排序,前提条件都不能改变单线程语义的前提下进行重排序,说白了就是最后的执行结果要准确无误。
学过大学的计算机基础课都知道,咱们的程序用高级语言写完后是不能被各大平台的机器所执行的,须要执行编译,而后将编译后的字节码文件处理成机器指令,才能被计算机执行。
从java源代码到最终的机器执行指令,分别会通过下面三种重排序:
在这里插入图片描述
前面说到了数据依赖的特性,什么是数据依赖呢?
数据依赖就是假设一句代码中对一个变量a++
自增,而后后一句代码b=a
将a的值赋值给b,便表示这两句代码存在数据依赖,两句代码执行顺序不能互换。
前面提到编译器和处理器的重排序,在编译器和处理器进行重排序的时候,就会遵照数据的依赖性,编译器和处理器就会禁止存在数据依赖的两个操做进行重排序,保证了数据的准确性。
在JDK5
开始,为了保证程序的有序性,便提出了happen-before
原则,假如两个操做符合该原则,那么这两个操做能够随意的进行重排序,并不会影响结果的正确性。
具体happen-before
原则有6条,具体原则以下所示:
同一个线程中前面的操做先于后续的操做(可是这个并非绝对的,假如在单线程的环境下,重排序后不会影响结果的准确性,是能够进行重排序,不按代码的顺序执行)。
Synchronized
规则中解锁操做先于后续的加锁操做。
volatile
规则中写操做先于后续的读取操做,保证数据的可见性。
一个线程的start()
方法先于任何该线程的全部后续操做。
线程的全部操做先于其余该线程在该线程上调用join返回成功的操做。
若是操做a先于操做b,操做b先于操做c,那么操做a先于操做c,传递性原理。
咱们来看重点第三条,也就是咱们今天所了解的重点volatile关键字,为了实现volatile内存语义,规定有volatile修饰的共享变量在机器指令层面会出出现Lock前缀的指令。
咱们来看看一个例子经典的例子,具体的代码以下:
public class TestVolatile extends Thread { private static boolean flag = false; public void run() { while (!flag) ; System.out.println("run方法退出了") } public static void main(String[] args) throws Exception { new TestVolatile().start(); Thread.sleep(5000); flag = true; } }
看上面的代码执行run方法能执行退出吗?是不能的,由于对于这两个线程来讲,首先new TestVolatile().start()
线程拿到flag
共享变量的值为false,并存储在于本身的工做内存中。
第一个线程到while循环中,就直接进入死循环,即便主线程读取flag的值,而后改变该值为true。
可是对于第一个线程来讲并不知道,flag的值已经被修改,在第一个线程的工做内存中flag仍然为false。具体的执行原理图以下:
这样对于共享变量flag,主线程修改后,对于线程1来讲是不可见的,而后咱们加上volatile变量修饰该变量,修改代码以下:
private static volatile boolean flag = false;
输出的结果中,就会输出run方法退出了,具体的原理假如一个共享变量被Volatile
修饰,该指令在多核处理器下会引起两件事情。
将当前处理器缓存行数据写回主内存中。
这个写入的操做会让其它处理器中已经缓存了该变量的内存地址失效,当其它处理器需求再次使用该变量时,必须从主内存中从新读取该值。
让咱们具体从idea的输出的汇编指令中能够看出,咱们看到红色线框里面的那行指令:putstatic flag
,将静态变量flag
入栈,注意观察add指令前面有一个lock
前缀指令。
注意:让idea输出程序的汇编指令,在启动程序的时候,能够加上
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
做为启动参数,就能够查看汇编指令。
简单的说被volatile修饰的共享变量,在lock指令后是一个原子操做,该原子操做不会被其它线程的调度机制打断,该原子操做一旦执行就会运行到结束,中间不会切换到任意一个线程。
当使用lock前缀的机器指令,它会向cpu发送一个LOCK#信号,这样能保证在多核多线程的状况下互斥的使用该共享变量的内存地址。直到执行完毕,该锁定才会消失。
volatile的底层就是经过内存屏障来实现的,lock前缀指令就至关于一个内存屏障。
那么什么又是内存屏障呢?
内存屏障是一组CPU
指令,为了提升程序的运行效率,编译器和处理器运行对指令进行重排序,JMM为了保证程序运行结果的准确性,规定存在数据依赖的机器指令禁止重排序。
经过插入特定类型的内存屏障(例如lock前缀指令)来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和CPU:无论什么指令都不能和这条Memory Barrier
指令重排序。
因此为了保证每一个cpu的数据一致性,每个cpu会经过嗅探总线上传播的数据来检查本身数据的有效性,当发现本身缓存的数据的内存地址被修改,就会让本身缓存该数据的缓存行失效,从新获取数据,保证了数据的可见性。
那么既然volatile能够保证可见性,它能够保证数据的原子性吗?
什么是原子性呢?原子性就是即不可再分了,不能分为多步操做。在Java中只有对基本类型变量的赋值和读取才是原子操做。
如i = 1
,可是像j = i
或者i++
都不是原子操做,由于他们都进行了屡次原子操做,好比先读取i的值,再将i的值赋值给j,两个原子操做加起来就不是原子操做了。
因此假如一个volatile
的integer
自增(i++)
,其实要分红3步:
读取主内存中volatile变量值到工做内存;
在工做内存中增长变量的值;
把工做内存的值写入主内存。
假若有两个线程都要执行a变量的自增操做,当线程1执行a++;语句时,先是读入a的值为0,此时a线程的执行时间被让出。
线程2得到执行,线程2会从新从主内存中,读入a的值仍是0,而后线程2执行+1操做,最后把a=1刷新到主内存中;
线程2执行完后,线程1又开始执行,但以前已经读取的a的值0,由于前面的读取原子操做已经结束了,因此它仍是在0的基础上执行+1操做,也就是仍是等于1,并刷新到主内存中。因此最终的结果是a变量的值为1