关于 Java 关键字 volatile 的总结

createh54个月前 (12-29)技术教程53


1 什么是 volatile

volatile 是 Java 的一个关键字,它提供了一种轻量级的同步机制。相比于重量级锁 synchronized,volatile 更为轻量级,因为它不会引起线程上下文的切换和调度。

2 volatile 的两个作用

  1. 可以禁止指令的重排序优化
  2. 提供多线程访问共享变量的内存可见性

3 禁止指令重排

3.1 什么是指令重排

指令重排序是 JVM 为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度,例如将多条指令并行执行或者是调整指令的执行顺序。但是在多线程的情况下,指令重排序可能会带来问题,例如程序执行的顺序可能会被调整。在加上 volatile 关键字之后可以有效解决这个问题。

下面我们举个例子:

double r = 2.1; //(1) 
double pi = 3.14;//(2) 
double area = pi*r*r;//(3)

在代码语句的顺序为 1->2->3,但实际上顺序无论是 1->2->3 还是 2->1->3 对结果并无影响,所以在编译时和运行时可以根据需要对1、2语句进行重排序。

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:
1 不会对存在数据依赖关系的操作进行重排序
2 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

3.2 指令重排带来的问题

我们来看看这个基于双重检验的单例模式:

public class Singleton3 {
 private static Singleton3 instance = null;

 private Singleton3() {}

 public static Singleton3 getInstance() {
 if (instance == null) {
 synchronized(Singleton3.class) {
 if (instance == null)
 instance = new Singleton3();// 非原子操作
 }
 }

 return instance;
 }
}

事实上,这个单例模式的实现方式是有问题的,问题在哪呢?问题在于instance = new Singleton3(); 并不是一个原子操作。

我们可以将其抽象成以下几条指令:

memory =allocate(); //1:分配对象的内存空间 
ctorInstance(memory); //2:初始化对象 
instance =memory; //3:设置instance指向刚分配的内存地址

可以看到,操作2依赖于操作1,但操作3并不依赖于操作2。所以 JVM 是可以针对它们进行指令的优化重排序的,经过重排序后如下:

memory =allocate(); //1:分配对象的内存空间 
instance =memory; //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory); //2:初始化对象

指令重排之后,instance 指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给 instance 引用,恰好另一个线程进入方法判断 instance 引用不为 null,然后就将其返回使用,导致出错。

3.3 禁止指令重排的原理

volatile 关键字提供内存屏障的方式来防止指令被重排,编译器在生成字节码文件时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

内存屏障会确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

对于上面的基于双重检验的单例模式,我们只需对其稍作修改即可令其正确运行。我们已经知道,问题来自于指令重排,那么我们禁止指令重排即可,用 volatile 关键字修饰 instance 变量,使得 instance 在读、写操作前后都会插入内存屏障,避免重排序。完整代码如下:

public class Singleton3 {
 private static volatile Singleton3 instance = null;

 private Singleton3() {}

 public static Singleton3 getInstance() {
 if (instance == null) {
 synchronized(Singleton3.class) {
 if (instance == null)
 instance = new Singleton3();
 }
 }
 return instance;
 }
}

4 保证内存可见性

4.1 什么是保证内存可见性

Java 支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有这个变量的拷贝(虽然对象以及成员变量分配的内存是在共享内存中的,但是每个执行的线程还是可以拥有一份拷贝,这样做的目的是加速程序的执行,这是现代多核处理器的一个显著特性),所以程序在执行过程中,一个线程看到的变量并不一定是最新的。volatile 告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。

4.2 实现的具体细节

如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

具体的说,内存可见性也是通过内存屏障实现的,它会执行下面两个操作:

  1. 强制将对缓存的修改操作立即写入主存
  2. 如果是写操作,它会导致其他 CPU 中对应的缓存行无效

5 总结

  1. volatile 提供了一种轻量级的同步机制,在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞
  2. volatile 只能确保可见性,而加锁机制既可以确保可见性又可以确保原子性
  3. volatile 屏蔽掉了 JVM 中必要的代码优化,所以在效率上比较低
  4. 相比 synchronized,虽然 volatile 更简单并且开销更低,但它的同步性较差,而且其使用也更容易出错

相关文章

Java 关键字之 native 详解 java native access

本篇博客我们将介绍Java中的一个关键字——native。native 关键字在 JDK 源码中很多类中都有,在 Object.java类中,其 getClass() 方法、hashCode()方法、...

《Java语言程序设计》期末考试模拟试题——判断题和问答题

一、是非选择题1、构造方法(Constructor)是否可被重写(override)?2、启动一个线程是用run()方法吗?3、是否可以继承String类?4、Java语言代码中能否应用goto语句?...

长知识了!Java 关键字 transient 还能这么用

前言最近在看 HashMap 源代码的时候,发现链表 table 数组采用了transient 关键字,笔者当时感觉对 transient 关键字即陌生但又有似曾相识,所以花了一些时间简要的总结了下使...