面试中经常被问到的volatile,曾经很讨厌,现在很喜欢 哈哈

《深入理解JVM》那本书相信很多人在准备面试的时候都翻过,也看到过对volatile关键字的解释,但我理解了很多遍,终于在这轮面试接近尾声的时候才觉得理解的比较透彻了,在此做个小总结

volatile的两个语义

在java里,volatile主要包含了线程间可见性禁止指令重排序优化,这两个特性很特别,既不像普通变量理解简单,又不像synchronized一样简单粗暴,它看起来和线程安全有关系,又不能保证线程安全,刚开始理解起来确实有些难度。

线程间可见性

volatile在java里的第一个特性叫线程间的可见性,要理解这个问题,首先需要了解java内存模型

java内存模型简述

这里的java内存模型指屏蔽掉各种硬件和操作系统的内存访问差异
简单来说,java的内存分为工作内存主内存两种,每个线程拥有自己的工作内存,共享的变量会通过主内存去进行同步。

  • 举个例子,一个共享的变量value在工作内存中初始化,并同步到主内存中,另外一个线程就可以通过主内存去访问到这个变量。
  • 每次读取操作前会从主内存中获取该变量的值,写操作之后将值同步到主内存的方式来进行共享

java内存中的几种原子操作

  • 用于主内存的:lock(),unlock()
  • 用户工作内存的:use(),assign()
  • 用于同步的:
    • 从主内存到工作内存:read(),load()
    • 从工作内存到主内存:store(),write()

这几种方法是内存模型中的原子操作,java内存模型保证了read()loadstore()write()的顺序执行,但注意并不是连续执行

volatile如何保证线程间变量的可见性

普通变量通过主内存去共享变量的时候,并不能保证A线程修改完该值之后立马同步回主内存,也不能保证读取该变量之前一步进行变量值的读取。
volatile变量保证:

  • 使用变量前必须先从主内存刷新最新的值 -> use()前必须执行read(),load()中间不能有其他操作
  • 修改变量的值后立刻同步回主内存 -> assign()后连续执行store(),write()

通过上述规则,volatile变量保证了其线程间的可见性。但是,这样并没有就等于说volatile变量就保证了线程安全性。举个例子:

1
2
3
4
5
6
7
8
9
volatile int value = 0;
//in thread-1
for(int i=0; i<10000; i++){
value ++;
}
//in thread-2
for(int i=0; i<10000; i++){
value ++;
}

对于上面的代码,如果是线程安全的,最后的value值应该为20000,但是如果测试一下的话会发现,每次都要比20000少。因为volatile变量每次只保证取到了最新的值,例如说100,A,B线程同时取到的都是100,进行自加操作后,都成了101然后同步回主内存也为101,自然就少了一次自增操作。因此volatile变量并不能保证线程的安全性

因此volatile变量适用于:

  • 运算结果不依赖变量当前的值,或者能够确保只有单一的线程可以修改变量的值
    • volatile boolean flag = true就可以保证线程的安全性
  • 变量不需要与其他变量共同参与不变约束

禁止指令重排序优化

第二个语义一直是理解的难点,结合了happens-before原则,这次有了更清晰的了解。
指令重排序优化的意思是什么呢?

  • 当我们在一个线程里观察该线程内的操作的时候,它表现出来的执行顺序看起来和程序代码的顺序是相同的
  • 但是当我们在另外一个线程去观察本线程内的操作的时候,一切都是无序的操作,因为编译器可能会对我们写出来的代码进行重排序优化

同样,举个双校验模式的单例来说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton{
private volatile static Singleton uniqueInstance;
private Singleton(){}
public static Singleton getInstance() {
if(uniqueInstance == null) {
synchronized(Singleton.class) {
if(uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

在这里,new Sinlgeton()看起来只有一句,但是被编译成指令是3句,分别是

  • uniqueInstance 分配内存空间
  • 调用 uniqueInstance 的构造函数,初始化成员变量
  • uniqueInstance 赋值

根据前面的介绍,步骤2 和3 可能会被重排序,这样的话可能某个线程获取到的单例就是未完全初始化的实例。如果有volatile关键字,就可以禁止进行指令重排序优化了,保证了单例的正确运行。

java内存模型中的volatile应用

根据上面的介绍,我们对volatile有了一定的理解,那这个关键字在内存模型中起到一个什么作用呢?

首先,java内存模型主要是围绕三个特性进行设计的:

  • 原子性
  • 可见性
  • 有序性

分别来说, 原子性即指前面介绍的8种操作(read()load()等),例如synchronized底层就是lock(),unlock()实现
保证可见性即通过synchronized,volatile以及final修饰的变量(不可变即线程安全了)
最后一个有序性也就是前面说过的,当我们在一个线程里观察该线程内的操作的时候,它表现出来的执行顺序看起来和程序代码的顺序是相同的,当我们在另外一个线程去观察本线程内的操作的时候,一切都是无序的操作,因为编译器可能会对我们写出来的代码进行重排序优化。 这里涉及到happens-before原则,感兴趣的同学可以去翻一下JVM那本书再了解一下

总结

本来以为三两句能说完,原来还是涉及的东西比较多,要深入理解的话,至少要理解这些内容,不知不觉就扯了这么多。 面试的时候,通常都是单例模式惹的祸,所以这块儿是个隐藏的坑,如果要说单例,千万要对volatile有个深入的了解。