volatile
Java语言为了解决并发编程中存在的原子性、可见性和有序性问题,提供了一系列和并发处理相关的关键字,比如synchronized
、volatile
、final
、concurren包
等。在前一篇文章中,我们也介绍了synchronized
的用法及原理。本文,来分析一下另外一个关键字——volatile
。
本文就围绕volatile
展开,主要介绍volatile
的用法、volatile
的原理,以及volatile
是如何提供可见性和有序性保障的等。
volatile
这个关键字,不仅仅在Java语言中有,在很多语言中都有的,而且其用法和语义也都是不尽相同的。尤其在C语言、C++以及Java中,都有volatile
关键字。都可以用来声明变量或者对象。下面简单来介绍一下Java语言中的volatile
关键字。
volatile的用法
volatile
通常被比喻成"轻量级的synchronized
",也是Java并发编程中比较重要的一个关键字。和synchronized
不同,volatile
是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。
volatile
的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile
修饰就可以了。
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
如以上代码,是一个比较典型的使用双重锁校验的形式实现单例的,其中使用volatile
关键字修饰可能被多个线程同时访问到的singleton。
volatile的原理
为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。
但是,对于volatile
变量,当对volatile
变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
所以,如果一个变量被volatile
所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile
在并发编程中,其值在多个缓存中是可见的。
volatile与可见性
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。
前面的关于volatile
的原理中介绍过了,Java中的volatile
关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用volatile
来保证多线程操作时变量的可见性。
volatile与有序性
有序性即程序执行的顺序按照代码的先后顺序执行。
我们在再有人问你Java内存模型是什么,就把这篇文章发给他中分析过:除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save
有可能被优化成load->save->add
。这就是可能存在有序性问题。
而volatile
除了可以保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止指令重排优化等。
普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。
volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile
修饰的变量的操作,会严格按照代码顺序执行,load->add->save
的执行顺序就是:load、add、save。
volatile与原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
我们在Java的并发编程中的多线程问题到底是怎么回事儿?中分析过:线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。
在上一篇文章中,我们介绍synchronized
的时候,提到过,为了保证原子性,需要通过字节码指令monitorenter
和monitorexit
,但是volatile
和这两个指令之间是没有任何关系的。
所以,volatile
是不能保证原子性的。
在以下两个场景中可以使用volatile
来代替synchronized
:
1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。
2、变量不需要与其他状态变量共同参与不变约束。
除以上场景外,都需要使用其他方式来保证原子性,如synchronized
或者concurrent包
。
我们来看一下volatile和原子性的例子:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>2) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
以上代码比较简单,就是创建10个线程,然后分别执行1000次i++
操作。正常情况下,程序的输出结果应该是10000,但是,多次执行的结果都小于10000。这其实就是volatile
无法满足原子性的原因。(Thread.activeCount()方法获取当前程序活着的线程数,程序结束前,至少包含2个线程一个是main线程,一个是GC线程。)
为什么会出现这种情况呢,那就是因为虽然volatile可以保证inc
在多个线程之间的可见性。但是无法inc++
的原子性。
volatile可见性验证
按顺序打印ABC,注意,static
也可以在多线程中保证变量的可见性,但两者都不保证原子性,所以都是线程相对不安全的,不过volatile可以保证有序性,防止指令重排。如果整数类型加减需要线程安全,可以使用AtomicInteger
代替,同时具有可见性,原子性。
public class TestPrintABCThread {
// 如果去除volatile,将无法完整打印ABC
volatile int state = 1;
public void printA() {
//通过循环,hang住线程
while (state != 1) {
}
System.out.print(Thread.currentThread().getName());
state++;
}
public void printB() throws InterruptedException {
while (state != 2) {
}
System.out.print(Thread.currentThread().getName());
state++;
}
public void printC() throws InterruptedException {
while (state != 3) {
}
System.out.print(Thread.currentThread().getName());
state = 1;
}
public static void main(String[] args) {
TestPrintABCThread thread = new TestPrintABCThread();
//线程A
new Thread(() -> {
thread.printA();
}, "A").start();
//线程B
new Thread(() -> {
try {
thread.printB();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "B").start();
//线程C
new Thread(() -> {
try {
thread.printC();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "C").start();
}
}
总结与思考
我们介绍过了volatile
关键字和synchronized
关键字。现在我们知道,synchronized
可以保证原子性、有序性和可见性。而volatile
却只能保证有序性和可见性。
那么,我们再来看一下双重校验锁实现的单例,已经使用了synchronized
,为什么还需要volatile
?
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
前言: 在了解完volatile关键字之后,再仔细思考了单例模式的双重检测,发现以前挺多东西还没懂的。
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;
}
}
显而易见我们都知道volatile关键字的作用其实就是让该变量的变化对于每一个线程可见,其底层实现原理是由于java内存模型(jmm)中的封装了8个交互操作。
read:把一个主内存中的值传递到工作内存,以便load动作使用 load:把read操作从主内存获取的内存变量赋值到工作内存的变量副本 use:将工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的字节码指令的时候将会执行这个操作。 assign:从执行引擎接受到的值赋给工作内存的变量,当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 store:他把工作内存中的一个变量的值传送到主内存中,以便随后的write操作使用 write:把store操作从工作内存中得到的变量的值放入主内存的变量中 lock:作用于主内存的变量,把一个变量标示一条线程独占的状态。 Unlock:作用于主内存,把一个处于锁定状态的变量释放出来。释放后的变量才可以被其他线程锁定。 每次执行use操作的时候都先执行read和load操作,让volatile修饰的变量每次获取的都是新的值;
每次执行assign的时候,随后都会执行store和write操作,让volatile修饰的变量每次都刷新到主内存中。
还有一个点就是其禁止指令重排序。
uniqueInstance = new Singleton();
主要在于uniqueInstance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情。
给 uniqueInstance 分配内存
调用 Singleton 的构造函数来初始化成员变量,形成实例
将singleton对象指向分配的内存空间(执行完这步 singleton才是非 null了)
在JVM的即时编译器中存在指令重排序的优化。 也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错(因为没有初始化) 再稍微解释一下,就是说,由于有一个『instance已经不为null但是仍没有完成初始化』的中间状态,而这个时候,如果有其他线程刚好运行到第一层if (instance ==null)这里,这里读取到的instance已经不为null了,所以就直接把这个中间状态的instance拿去用了,就会产生问题。这里的关键在于线程T1对instance的写操作没有完成,线程T2就执行了读操作。
volatile如何解决 volatile关键字的一个作用是禁止指令重排,把uniqueInstance声明为volatile之后,对它的写操作就会有一个内存屏障,这样,在它的赋值完成之前,就不用会调用读操作。
何为内存屏障 观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,此lock非jmm交互操作的lock。
lock指令的作用是使本cpu的cache写入内存,该写入动作会引起其他cpu的cache无效化(缓存一致性)。通过这样一个操作让对于volatile变量的修改对于其他cpu可变。
lock指令把之前的cache都同步到内存中,等同于让lock指令后面的指令依赖于lock指令前面的指令,根据处理器在进行重排序时是会考虑指令之间的数据依赖性,所以lock指令之前的指令不会跑到lock指令之后,之后的也不会跑到之前。
so volatitle解决了两个问题:instance的线程可见性、以及在初始化instance的时候遇到的指令重排序问题。
double check的意义 为什么要判断两次instance==null呢???
第一次检测:
由于单例模式只需要创建一次实例,如果后面再次调用getInstance方法时,则直接返回之前创建的实例,因此大部分时间不需要执行同步方法里面的代码,大大提高了性能。如果不加第一次校验的话,每次都要去竞争锁。
第二次检测:
如果没有第二次校验,假设线程t1执行了第一次校验后,判断为null,这时t2也获取了CPU执行权,也执行了第一次校验,判断也为null。接下来t2获得锁,创建实例。这时t1又获得CPU执行权,由于之前已经进行了第一次校验,结果为null(不会再次判断),获得锁后,直接创建实例。结果就会导致创建多个实例。所以需要在同步代码里面进行第二次校验,如果实例为空,则进行创建。