volatile 关键字原理解析
今天,来谈谈 Java 并发编程中的一个基础知识点:volatile 关键字
本篇文章主要从可见性,原子性和有序性进行讲解
- Java的内层模型
- happen-before
- 可见性
- 有序性
一. 主存与工作内存
说 volatile 之前,先来聊聊 Java 的内存模型。
在 Java 内存模型中,规定了所有的变量都是存储在主内存当中,而每个线程都有属于自己的工作内存。线程的工作内存保存了被该内存使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
对于单线程的程序,这样的规定没有任何影响;但是对于多线程的程序,便可能导致,某个线程已经改变了主内存中的变量,而另一个线程还在使用其工作内存中的变量,因此造成了数据的不一致。
Java内层模型二. 可见性
volatile 可以保证数据的可见性,前面说到对于多线程的程序可能会造成数据不一致,但是当一个变量加上 volatile 之后,便可以保证,其他线程读取到的该变量都是最新值。
这是因为每当对该变量进行写操作时,都会使得其他线程工作变量中的该变量的拷贝失效,而迫使线程们都重新去主内存读取
我们来看看实例:
public class TestThread extends Thread {
private volatile boolean isRunning = true;
public void setRunning(boolean running) {
isRunning = running;
}
@Override
public void run() {
int i = 1;
while (isRunning) {
i++;
}
System.out.println(i);
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
TestThread thread = new TestThread ();
thread.start();
Thread.sleep(3000);
thread.setRunning(false);
}
}
当 isRunning 变量没有添加 volatile 变量时,该程序会发生死循环,因为setRunning(false)
并没有影响到 thread 所在线程的工作内存(这时该线程看到的值仍然是 true)
当我们为变量添加上 volatile 之后,setRunning(false)
执行完毕,thread 所在线程的工作内存的变量拷贝便就此作废,必须去主内存获取最新的值,死循环也因此不会再发生了
值得注意的是,当我们在循环中添加了打印语句,或者 sleep 方法等,这时无论有没有 volatile,都会停止循环,如:
while (isRunning) {
i++;
System.out.println(i);
}
这是因为,JVM 会尽力保证内存的可见性,原本的代码中,程序一直处于死循环,这时 JVM 没有办法强制要求 CPU 分出时间去保证可见性;但是当加上打印语句之后,CPU 便会分出时间去处理这件事情,并保证了可见性;但是,与之不同的是,volatile 是强制保证可见性的。
三. 原子性
volatile 没有办法保证操作的原子性的
直接上代码:
public class AtomicTest {
private static volatile int race = 0;
private static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
increase();
}
});
threads[i].start();
}
//等待所有累加线程都结束
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
这段代码摘自《深入理解 Java 虚拟机》12 章,当 race 没有 volatile 关键字的加持时,最终的打印结果经常会小于 10000,而有了 volatile,这段程序变不再出现这种情况。
假设两个线程 1 和 2,它们俩先后读取了 race 的值(初始值为 0),由于它们都还没有进行写操作,因此两个线程这时看到的值都是 0,因此便使得之后两次自增操作的结果是 1,而不是 2
image刚刚说到 volatile 变量在进行写操作的时候,会让其他线程对应的工作内存中的拷贝失效,使得需要直接去主存中读取变量,而上例中线程 1 在进行写操作之前,线程 2 便已经执行了读操作,因此没办法影响线程 2 的读取,因此也不会更新为最新的数据了
四. 有序性
volatile 可以在一定程度上禁止指令重排序
重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
flag 变量添加上 volatile 关键字以后,语句 1,2 不会排在 3 的后面执行,当然 4,5 也不会在 3 的前面执行
但是 1 和 2, 3 和 4 之间的顺序没办法干预,这也是我们说“一定程度改变”的原因
上个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited){
sleep()
}
doSomethingwithconfig(context); //出错,context 可能还没有初始化
面对这样的例子的时候,如果 inited 是非 volatile 变量,那么因为重排序的关系,有可能出错;但是加上 volatile 后便不用担心了
happens-before
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
对happens-before关系的具体定义如下。
① 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
②两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
上面的①是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!上面的②是JMM对编译器和处理器重排序的约束原则。正如前面所言,其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。因此,happens-before关系本质上和as-if-serial语义是一回事。
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
happens-before规则如下:
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
五. 使用场景
1. 状态变量:
比如上面给出的可见性的代码例子
while (isRunning) {
i++;
}
对于这种用于标记状态的变量,volatile 是非常好用的
2. 双重检验:
最经典的就是单例模式的双重检验实现,如果忘了的刚好复习一下:
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
这里的 volatile,是为了保证 singleton = new Singleton();
操作的有序性,因为 singleton = new Singleton();
并不是原子操作,做了 3 件事
- 给 singleton 分配内存
- 调用 Singleton 的构造函数来初始化成员变量
- 将 singleton 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)
但是由于重排序的原因,1-2-3 的顺序可能变成 1-3-2,如果是后者,在 singleton 变成非 null 时(即第三步),如果第二个线程开始进入第一个判断 if (singleton == null)
,那么便会直接返回 true,然而事实上 singleton 还没有完成初始化