先了解有关volatile 常考面试题:
1)Java 中能创建 volatile 数组吗?
答案:能,只不过只是一个指向数组的引用,而不是整个数组,如果改变了引用指向的数组,将会受到volatile的保护,但是如果多个线程同时改变数组的元素,volatile关键字就不能起到保护的作用。
2)volatile 能使得一个非原子操作变成原子操作吗?
答案:能,一个典型的例子是在类中有一个 long 类型的成员变量。如果你知道该成员变量会被多个线程访问,如计数器、价格等,你最好是将其设置为 volatile。为什么?因为 Java 中读取 long 类型变量不是原子的,需要分成两步,如果一个线程正在修改该 long 变量的值,另一个线程可能只能看到该值的一半(前 32 位)。但是对一个 volatile 型的 long 或 double 变量的读写是原子。但也存在i++这种操作,从本质上来讲 ,volatile只能保证内存可见性,而不是保证原子性;
3)volatile 修饰符的有过什么实践?
答案:例如在分布式框架中的应用。简单的 说,就是当你写一个 volatile 变量之前,Java 内存模型会插入一个写屏障(write barrier),读一个 volatile 变量之前,会插入一个读屏障(read barrier)。意
思就是说,在你写一个 volatile 域时,能保证任何线程都能看到你写的值,同时,在写之前,也能保证任何数值的更新对所有线程是可见的,因为内存屏障会将其他所有写的值更新到缓存。
4)volatile 类型变量提供什么保证?
答案:volatile 变量提供顺序和可见性保证,例如,JVM 或者 JIT为了获得更好的性能会对语句重排序,但是 volatile 类型变量即使在没有同步块的情况下赋值也不会与其他语句重排序。 volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。某些情况下,volatile 还能提供原子性,如读 64 位数据类型,像 long 和 double 都不是原子的,但 volatile 类型的 double 和 long 就是原子的。
volatile 介绍
volatile 是一个类型修饰符。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略;volatile的第一条语义是保证线程间变量的可见性,简单地说就是当线程A对变量X进行了修改后,在线程A后面执行的其他线程能看到变量X的变动,更详细地说是要符合以下两个规则:
- 线程对变量进行修改之后,要立刻回写到主内存。
- 线程对变量读取的时候,要从主内存中读,而不是缓存。
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。
而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
Java内存模型还定义了一系列工作内存和主内存之间交互的操作及操作之间的顺序的规则,对于共享普通变量来说,约定了变量在工作内存中发生变化了之后,必须要回写到工作内存(迟早要回写但并非马上回写),但对于volatile变量则要求工作内存中发生变化之后,必须马上回写到工作内存,而线程读取volatile变量的时候,必须马上到工作内存中去取最新值而不是读取本地工作内存的副本,此规则保证了前面所说的“当线程A对变量X进行了修改后,在线程A后面执行的其他线程能看到变量X的变动”。
volatile 的特性
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性)
- 禁止进行指令重排序。(实现有序性)
- volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。
- 关于volatile 原子性可以理解为把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步,就跟下面的SoWhat跟SynSoWhat功能类似。
class SoWhat{
volatile int i = 0; // volatile修饰的变量
public int getI(){
return i;// 单个volatile变量的读
}
public void setI(int j){
this.i = j; // 单个volatile 变量的写
}
public void inc(){
i++;//复合多个volatile 变量
}
}
class SynSoWhat{
int i = 0;
public synchronized int getI(){
return i;
}
public synchronized void setI(int j){
this.i = j;
}
public void inc(){ // 普通方法调用
int tmp = getI(); // 调用已同步方法
tmp = tmp + 1;//普通写方法
setI(tmp);// 调用已同步方法
}
}
1、写理解
volatile写的内存语义如下:
当写一个volatile变量时,JMM会把该线程对应的本地中的共享变量值刷新到主内存。
public class VolaSemanteme {
int a = 0;
volatile boolean flag = false; // 这是重点哦
public void init() {
a = 1;
flag = true; //.......
}
public void use() {
if (flag) {
int i = a * a;
}
}
线程A调用init方法,线程B调用use方法。
2、读理解
volatile读的内存语义如下:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
public class VolaSemanteme {
int a = 0;
volatile boolean flag = false; // 这是重点哦
public void init() {
a = 1;
flag = true; //.......
}
public void use() {
if (flag) {
int i = a * a;
} //.......
}
}
流程图大致是这样的:
3、volatile 指令重排
volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。总结来说就是JMM内部会有指令重排,并且会有as-if-serial跟happen-before的理念来保证指令的正确性。内存屏障就是基于4个汇编级别的关键字来禁止指令重排的,其中volatile的规则如下:
第一个为读操作时,第二个任何操作不可重排序到第一个前面。
第二个为写操作时,第一个任何操作不可重排序到第二个后面。
第一个为写操作时,第二个的读写操作也不运行重排序。
示例代码:
/**
* 一个简单的展示Happen-Before的例子.
* 这里有两个共享变量:a和flag,初始值分别为0和false.在ThreadA中先给a=1,然后flag=true.
* 如果按照有序的话,那么在ThreadB中如果if(flag)成功的话,则应该a=1,而a=a*1之后a仍然为1,下方的if(a==0)应该永远不会为真,永远不会打印.
* 但实际情况是:在试验100次的情况下会出现0次或几次的打印结果,而试验1000次结果更明显,有十几次打印.
*/
public class SimpleHappenBefore {
/** 这是一个验证结果的变量 */
private static int a=0;
/** 这是一个标志位 */
private static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
//由于多线程情况下未必会试出重排序的结论,所以多试一些次
for(int i=0;i<1000;i++){
ThreadA threadA=new ThreadA();
ThreadB threadB=new ThreadB();
threadA.start();
threadB.start();
//这里等待线程结束后,重置共享变量,以使验证结果的工作变得简单些.
threadA.join();
threadB.join();
a=0;
flag=false;
}
}
static class ThreadA extends Thread{
public void run(){
a=1;
flag=true;
}
}
static class ThreadB extends Thread{
public void run(){
if(flag){
a=a*1;
}
if(a==0){
System.out.println("ha,a==0");
}
}
}
}
4、volatile写底层实现
JMM对volatile的内存屏障插入策略
在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。
5、volatile 读底层
JMM对volatile的内存屏障插入策略
在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障
6、volatile的实现原理
有volatile变量修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令。在CPU级别的功能如下:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会告知在其他CPU你们拿到的变量是无效的,下一次使用时候要重新共享内存拿。
使用 volatile 必须具备的条件
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
- 只有在状态真正独立于程序内其他内容时才能使用 volatile。
一次性安全发布(one-time safe publication)
缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
模独立观察(independent observation)
安全使用 volatile 的另一种简单模式是定期 发布 观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
volatile bean 模式
在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。
开销较低的读-写锁策略
volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
双重检查(double-checked)
单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。