前言
volatile相关的知识点,在面试过程中,属于基础问题,是必须要掌握的知识点,如果回答不上来会严重扣分的哦。
volatile关键字基本介绍
volatile可以看成是synchronized的一种轻量级的实现,但volatile并不能完全代替synchronized,volatile有synchronized可见性的特性,但没有synchronized原子性的特性。
可见性即用volatile关键字修饰的成员变量表明该变量不存在工作线程的副本,线程每次直接都从主内存中读取,每次读取的都是最新的值,这也就保证了变量对其他线程的可见性。
另外,使用volatile还能确保变量不能被重排序,保证了有序性。
当一个变量定义为volatile之后,它将具备两种特性:
- 保证此变量对所有线程的可见性
- 禁止指令重排序优化
volatile与synchronized的区别:
- 1、volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
- 2、volatile保证数据的可见性,但是不保证原子性; 而synchronized是一种排他(互斥)的机制,既保证可见性,又保证原子性。
- 3、volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
- 4、volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
保证此变量对所有线程的可见性:
当一条线程修改了这个变量的值,新值对于其他线程可以说是可以立即得知的。Java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存,线程的工作内存保存了该线程使用到的变量在主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读取主内存中的变量。
知识拓展:内存可见性:
- 概念:JVM内存模型:主内存 和 线程独立的 工作内存。Java内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存(比如CPU的寄存器),线程只能访问自己的工作内存,不可以访问其它线程的工作内存。工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中。
- 如何保证多个线程操作主内存的数据完整性是一个难题,Java内存模型也规定了工作内存与主内存之间交互的协议,定义了8种原子操作:
- lock:将主内存中的变量锁定,为一个线程所独占。
- unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量。
- read:将主内存中的变量值读到工作内存当中。
- load:将read读取的值保存到工作内存中的变量副本中。
- use:将值传递给线程的代码执行引擎。
- assign:将执行引擎处理返回的值重新赋值给变量副本。
- store:将变量副本的值存储到主内存中。
- write:将store存储的值写入到主内存的共享变量当中。
通过上面Java内存模型的概述,我们会注意到这么一个问题,每个线程在获取锁之后会在自己的工作内存来操作共享变量,操作完成之后将工作内存中的副本回写到主内存,并且在其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其是不可见的。
即其他线程的本地内存中的变量已经是过时的,并不是更新后的值。volatile保证可见性的原理是在每次访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。所以volatile关键字的作用之一就是保证变量修改的实时可见性。
即,volatile的特殊规则就是:
- read、load、use动作必须连续出现。
- assign、store、write动作必须连续出现。
所以,使用volatile变量能够保证:
- 每次读取前必须先从主内存刷新最新的值。
- 每次写入后必须立即同步回主内存当中。
也就是说,volatile关键字修饰的变量看到的是自己的最新值。线程1中对变量v的最新修改,对线程2是可见的。
禁止指令重排序优化:
volatile boolean isOK = false; //假设以下代码在线程A执行 A.init(); isOK=true; //假设以下代码在线程B执行 while(!isOK){ sleep(); } B.init();
A线程在初始化的时候,B线程处于睡眠状态,等待A线程完成初始化的时候才能够进行自己的初始化。这里的先后关系依赖于isOK这个变量。
如果没有volatile修饰isOK这个变量,那么isOK的赋值就可能出现在A.init()之前(指令重排序,Java虚拟机的一种优化措施),此时A没有初始化,而B的初始化就破坏了它们之前形成的那种依赖关系,可能就会出错。
知识拓展:指令重排序:
- 概念:指令重排序是JVM为了优化指令,提高程序运行效率,在不影响 单线程程序 执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就会给程序带来问题。
不同的指令间可能存在数据依赖。比如下面的语句:
int l = 3; // (1) int w = 4; // (2) int s = l * w; // (3)
面积的计算依赖于l与w两个变量的赋值指令。而l与w无依赖关系。
重排序会遵守两个规则:
- as-if-serial规则:as-if-serial规则是指不管如何重排序(编译器与处理器为了提高并行度),(单线程)程序的结果不能被改变。这是编译器、Runtime、处理器必须遵守的语义。
- happens-before规则:
- 程序顺序规则:一个线程中的每个操作,happens-before于线程中的任意后续操作。
- 监视器锁规则:一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果(A)happens-before(B),且(B)happens-before(C),那么(A)happens-before(C)。
- 线程start()规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens-before 线程B中的操作。
- 线程join()规则:主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作,happens-before join()的返回。
- 中断规则:一个线程调用另一个线程的interrupt,happens-before于被中断的线程发现中断。
- 终结规则:一个对象的构造函数的结束,happens-before于这个对象finalizer的开始。
- 概念:前一个操作的结果可以被后续的操作获取。讲直白点就是前面一个操作把变量a赋值为1,那后面一个操作肯定能知道a已经变成了1。
- happens-before(先行发生)规则如下:
虽然,(1)-happensbefore ->(2),(2)-happens before->(3),但是计算顺序(1)(2)(3)与(2)(1)(3)对于l、w、area变量的结果并无区别。编译器、Runtime在优化时可以根据情况重排序(1)与(2),而丝毫不影响程序的结果。
- volatile使用场景:
- 1、对变量的写操作不依赖当前变量的值。
- 2、该变量没有包含在其他变量的不变式中。
- 如果正确使用volatile的话,必须依赖下以下种条件:
也可以这样理解,就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。
第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(i++)看上去类似一个单独操作,实际上它是一个由(读取-修改-写入)操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。
实现正确的操作需要使 i 的值在操作期间保持不变,而 volatile 变量无法实现这点。
- 在以下两种情况下都必须使用volatile:
- 1、状态的改变。
- 2、读多写少的情况。
具体如下:
// 场景一:状态改变 /** * 双重检查(DCL) */ public class Sun { private static volatile Sun sunInstance; private Sun() { } public static Sun getSunInstance() { if (sunInstance == null) { synchronized (Sun.class) { if (sunInstance == null){ sunInstance = new Sun(); } } } return sunInstance; } } // 场景二:读多写少 public class VolatileTest { private volatile int value; //读操作,没有synchronized,提高性能 public int getValue() { return value; } //写操作,必须synchronized。因为x++不是原子操作 public synchronized int increment() { return value++; } }
问题来了,volatile是如何防止指令重排序优化的呢?
答:
volatile关键字通过 “内存屏障” 的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。大多数的处理器都支持内存屏障的指令。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
知识拓展:内存屏障:
内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下几种类型:
- LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。