下文笔者将详细介绍volatile这一篇文章,将使你真真的了解到volatile关键字的用法,如下所示:
volatile关键字 的功能:
我们都知道volatile关键字有两个功能:
1.保证变量的内存可见性
2.禁止指令重排序
可见性,例:
/**
* 变量的内存可见性例子
*
* @author java265.com
*/
public class VolatileExample {
/**
* main 方法作为一个主线程
*/
public static void main(String[] args) {
MyThread myThread = new MyThread();
// 开启线程
myThread.start();
// 主线程执行
for (; ; ) {
if (myThread.isFlag()) {
System.out.println("主线程访问到 flag 变量");
}
}
}
}
/**
* 子线程类
*/
class MyThread extends Thread {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改变量值
flag = true;
System.out.println("flag = " + flag);
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
以上的代码运行,控制台永远都不会打印出 “主线程访问到 flag 变量”
那到底为什么呢?明明将flag设置为true,但是主线程无法读取到最新的flag值,所以无法输出“主线程访问到 flag 变量”这句话,这就是变量可见性的一种示例,此时我们应该思考为什么会出现这种现象呢?这应该从jvm运行程序的内存模型
Java 内存模型
JMM(Java Memory Model):Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。也就是说,JMM 是 JVM 中定义的一种并发编程的底层模型机制。
JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
JMM 的规定:
- 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
- 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
- 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
JMM 的抽象示意图:
JMM 这样的设置会导致线程对共享变量的修改没有即时更新到主内存,或线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的
那么如何解决这个问题呢?
加锁 和 使用 volatile 关键字
使用 synchronizer 进行加锁
/**
* java265.com 示例程序
* main 方法作为一个主线程
*/
public static void main(String[] args) {
MyThread myThread = new MyThread();
// 开启线程
myThread.start();
// 主线程执行
for (; ; ) {
synchronized (myThread) {
if (myThread.isFlag()) {
System.out.println("主线程访问到 flag 变量");
}
}
}
}
当一个线程进入 synchronizer 代码块后
线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,代码运行完毕后,又将修改后的副本值刷新到主内存中,最后线程释放锁
使用 volatile 关键字
/**
* 子线程类
*/
class MyThread extends Thread {
private volatile boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改变量值
flag = true;
System.out.println("flag = " + flag);
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
当一个变量被 volatile 修饰后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,当线程操作变量副本并写回主内存后,会通过 CPU 总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。
volatile关键字也保证不了共享变量的可见性,只是采用修改变量后都写回到主内存中,然后通过其他机制告知变量副本已失效,让其他线程使用时重新获取
volatile功能2:禁止指令重排序
为了提高性能,在遵守 as-if-serial
语义(即不管怎么重排序,单线程下程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守。)的情况下,编译器和处理器常常会对指令做重排序。
一般重排序可以分为如下三种类型:
- 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
从 Java 源代码到最终执行的指令序列,会分别经历下面三种重排序
例:
int a = 0;
// 线程 A
a = 1; // 1
flag = true; // 2
// 线程 B
if (flag) { // 3
int i = a; // 4
}
从上面的代码好像没有问题,最后 i 的值是 1
但是为了提高性能,编译器和处理器常常会在不改变数据依赖的情况下对指令做重排序
假设线程 A 在执行时被重排序成先执行代码 2,再执行代码 1;而线程 B 在线程 A 执行完代码 2 后,读取了 flag 变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,那么 i 最后的值是 0,导致执行结果不正确。为了保证代码运行的顺序性质,此时就需使用volatile关键字
上例中, 使用 volatile 不仅保证了变量的内存可见性,还禁止了指令的重排序,即保证了 volatile 修饰的变量编译后的顺序与程序的执行顺序一样。那么使用 volatile 修饰 flag 变量后,在线程 A 中,保证了代码 1 的执行顺序一定在代码 2 之前。
volatile 在单例模式中的应用:
我们都知道单例的实现模式有很多种,下文笔者讲述volatile在单例模式中的应用,例:
多线程下,此单例模式,永远都只返回一个单例的Singleton
public class Singleton {
// volatile 保证可见性和禁止指令重排序
private static volatile Singleton singleton;
public static Singleton getInstance() {
// 第一次检查
if (singleton == null) {
// 同步代码块
synchronized(this.getClass()) {
// 第二次检查
if (singleton == null) {
// 对象的实例化是一个非原子性操作
singleton = new Singleton();
}
}
}
return singleton;
}
}
从以上的代码中,我们可以看出new Singleton()
是一个非原子性操作
对象实例化分为三步操作:(1)分配内存空间,(2)初始化实例,(3)返回内存地址给引用。所以,在使用构造器创建对象时,编译器可能会进行指令重排序。假设线程 A 在执行创建对象时,(2)和(3)进行了重排序,如果线程 B 在线程 A 执行(3)时拿到了引用地址,并在第一个检查中判断 singleton != null 了,但此时线程 B 拿到的不是一个完整的对象,在使用对象进行操作时就会出现问题。
所以,这里使用 volatile 修饰 singleton 变量,就是为了禁止在实例化对象时进行指令重排序
从以上的分析,我们可以得出volatile关键字的相关说明:
- volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值;或者作为状态变量,如 flag = ture,实现轻量级同步。
- volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
- volatile 只能作用于属性,我们用 volatile 修饰属性,这样编译器就不会对这个属性做指令重排序。
- volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。
- volatile 提供了 happens-before 保证,对 volatile 变量 V 的写入 happens-before 所有其他线程后续对 V 的读操作。
- volatile 可以使纯赋值操作是原子的,如
boolean flag = true; falg = false
。 - volatile 可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。