volatile在多线程编程中是一个十分重要的关键字,volatile被称为轻量级的synchronized,它保证了数据的可见性,同时其执行成本较synchronized更低。
多线程环境中,每个线程都有自己的工作空间,某个线程对数据修改后,该数据不一定能立刻在其他线程中更新(不可见)。
例如如下代码:
public class VolatitleTest {
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
new Thread(test).start();
Thread.sleep(1000);
Test.a = 1;
}
}
class Test implements Runnable{
static int a = 0;
@Override
public void run() {
while(a == 0) {}
}
}
在 1s 后,main 线程中修改 a 的值为 1,test 中的循环理应结束,然而事实上程序在 1s 后仍然在运行。这便是由于 a 的不可见性造成的:cpu 在执行while 循环时十分繁忙,并不能及时从主内存中获取到最新的 a 的值,才导致while 循环继续进行。
若在 Test 中的变量 a 前使用 volatile 修饰, 程序会在 1s 后正常结束。
static volatile int a = 0;
原因:
在多核处理的环境下,在对volatile修饰的变量进行写操作时,会发生两件事情:
如此一来,在 main 线程中修改 a 的值后,主内存中的 a 会立即更新。test 中的 a 将会失效,在下一次访问时会从主内存中获取新值。这样的机制保证了某个线程修改数据后对于其他线程是可见的。
指令重排: 你写的代码执行的顺序不一定是你写的顺序,为了提高代码执行的效率,JMM 可能会修改你程序执行顺序。
单线程中,为了保证程序执行结果不变,具有数据依赖性的语句,并不会被重排,数据依赖分为三种:
a = 1;
b = a;
a = 1;
a = 2;
a = b;
b = 1;
以上三种情况,重排后会改变程序执行的结果,因此不会被重排。
指令重排的具体规则可参考 happens-before
规则,本文不做阐述。
volatile 禁止指令重排的一个经典应用场景是 dcl 单例模式。
class Test{
private static Test instance;
private Test() {}
public Test getInstance() {
//instance被创建后,没必要再加锁独占资源,直接返回即可
if(instance != null){
return instane;
}
synchronized(Test.class){
if(instance == null) {
instance = new Test();
}
return instance;
}
}
}
new Test() 的过程并不是一步完成的,其分为三步:
其中开辟内存与初始化进行较慢,没有必要等待其执行完成后再执行下一条语句,cpu资源未被占满时,是可以同时执行之后的语句的,但由于1、2步的执行效率较慢,第3步可能先执行完毕。
在单线程中,getInstance() 函数会等待1、2步执行完毕后再返回 instance。但是在多线程环境下,可能会出现一些意想不到的错误。
例如,A 线程调用 getInstance(),抢占到锁,发现 instance 为 null,开始执行 new Test(),但由于上述原因,第三步可能先执行完,instance 指向开辟的内存,此时,B 线程也调用 getInstance,由于双重校验锁的缘故,并不会等待 A 线程释放锁,而是直接拿到 instance,然而,这时 instance 仍是未初始化完成的,此时若在 B 线程中操作 instance,就会造成意想不到的错误。
解决方法: 只需给 instace 加上 volaitile修饰即可。
private volatile static Test instance;
volatile 可以防止 new 时发生指令重排,使步骤 3 等待1、2执行完成后再执行,保证安全性。
对于某些复合的语句 volatile 并不能保证其原子性。
听着有些晦涩,直接看代码:
public class VolatitleTest {
public static void main(String[] args) {
Test test = new Test();
for(int i = 0; i < 1000; ++i) {
new Thread(test).start();
}
}
}
class Test implements Runnable{
public volatile static int a = 0;
@Override
public void run() {
System.out.println(Thread.currentThread() + " " + (a++));
}
}
如果上述程序是线程安全的,打印的最大 a 的值应为 999。
本地执行程序某次打印结果为:
其中有两组重复的数字,按理说,volatile 保证了数据修改后其他线程可见,为何会造成此线程不安全的问题呢。别急,我们来看看 a++ 的具体操作。
假设现在 a = 1,有线程 A
和线程 B
同时执行 a++,线程 A
计算完 a + 1 后,线程 B
三步都执行完毕,此时线程 B
的缓存中 a 的值改变,立刻将主内存中 a 的值更新为 2,并使线程 A
中的 a (1) 失效,但是,线程 A
此时 a + 1 已经执行完毕,线程 A
执行第 3 步时,直接将原本的计算结果 2 赋值给 a。于是,执行两次 a++ 后,a 的值实际只增加了 1。只有在线程 A
执行第 2 步之前更新 a 的值才不会出错。所以,该程序并非线程安全的,加上类锁使线程独占资源才能规避该问题。
参考资料:《Java并发编程的艺术》