关键字volatile的作用和特性
volatile的第一个特性–保证可见性
解决内存可见性问题方式的一种是加锁,但是使用锁太笨重,因为它会带来线程上下文的切换开销。Java提供了一种弱形式的同步,也就是volatile关键字。该关键字确保对一个变量的更新对其他线程马上可见。
当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。
当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。 下面举个例子
先写一段不用volatile修饰共享变量的代码:
public class VolatileDemo01 {
//main方法作为主线程
public static void main(String[] args) {
//a.创建一个子线程
MyThread t = new MyThread();
//b.启动子线程
t.start();
//c.主线程执行
while(true) {
if(t.isFlag()) {
System.out.println("主线程进入循环执行!");
}
}
}
}
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 boolean setFlag(boolean flag){
return this.flag = flag;
}
}
这段代码在执行的时候会发现运行结果卡在了
flag = true
这是因为子线程启动后对flag进行了修改,但是由于这个修改对于主进程来说是不可见的,所以主进程中的if语句条件一直为false,接下来修改代码,将共享变量用volatile关键字修饰,然后再次执行:
private volatile boolean flag = false;
程序正确进入循环,表明此时的修改对主线程可见。
下面看看不可见的原因分析
了解不可见问题出现的原因时需要了解一下JMM(java memory model)
Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量加载到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。
如果不使用volatile关键字对共享变量进行修饰时,在子线程在工作内存中修改变量后更新到主内存中,这时由于主线程已经将修改前的变量从主内存加载到工作内存中而导致执行时无法更新变量值。
如果使用volatile关键字对共享变量进行修饰后,主线程在执行时发现主内存中的变量发生修改时将会使之前加载到工作内存中的变量失效,然后重新从主内存中加载变量到工作内存中去。
volatile的第二个特性–保证有序性
Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。
什么是数据依赖性?
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
在单线程下重排序可以保证最终执行结果与程序顺序执行的结果一致,但是在多线程下就会出现问题。比如下面例子:
public class VolatileDemo04 {
public static int a = 0, b = 0;
public static int i = 0, j = 0;
public static void main(String[] args) throws Exception{
int count = 0;
while (true) {
count++;
a = 0;
b = 0;
i = 0;
j = 0;
//定义两个线程
//线程a
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
i = b;
}
});
//线程b
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
j = a;
}
});
t1.start();
t2.start();
t1.join(); //让主线程等待t1线程执行完毕
t2.join(); //让主线程等待t2线程执行完毕
// 得到线程执行完毕后变量的结果
System.out.println("第"+count +"次结果:i="+i+",j="+j);
if (i==0 && j==0) {
break;
}
}
}
}
在正常情况下,程序是按照编写顺序执行的。但是CPU为了减少运算压力,将会对指令进行重排序。比如下面这段代码:
int a = 1;
int b = 2;
a += 1;
这段代码正常执行顺序应该是将1赋给a,再将2赋给B,最后a再加1,CPU中会有如下过程:
load a
set a=1
load b
set b=2
load a
set a=2
但是CPU在进行运算时将会重排序,所以实际上过程应该是这样的:
load a
set a=1
set a=2
load b
set b=2
对比两个过程就会发现实际上过程会比预想的过程少的一步,这样CPU会减少一些运算压力。
所以再来分析上面的java程序,声明四个变量a, b, i, j,它们的初始值都为0。然后创建两个线程,一个将1赋给a,将b赋给i;另一个将1赋给b,将a赋给j。
预计一下结果应该有三种情况:
1、当线程一执行完后再执行线程二,此时i=0, j=1
2、当线程一的a=1执行后,线程二也启动执行b=1,此时再继续执行就会得到i=1, j=1
3、当线程二先执行,执行完后再执行线程一,此时 I=1 j=0.
由于CPU会进行指令重排序,有可能会得到第四种情况:i=0, j=0。
将上述代码进行执行,得到以下运算结果:
第1106522次结果:i=0,j=1
第1106523次结果:i=0,j=1
第1106524次结果:i=0,j=1
第1106525次结果:i=0,j=1
第1106526次结果:i=0,j=0
进程已结束,退出代码0
可以看到,在第1106526次时,出现了i=0, j=0的情况,接着修改代码,将所有变量使用volatile关键字进行修饰:
public volatile static int a = 0, b = 0;
public volatile static int i = 0, j = 0;
再次运行就会发现会一直执行下去:
第257862568次结果:i=0,j=1
第257865166次结果:i=0,j=1
第257874338次结果:i=0,j=1
第257875433次结果:i=0,j=1
第257881247次结果:i=0,j=1
第257886494次结果:i=0,j=1
第257892310次结果:i=0,j=1
第257892519次结果:i=0,j=1
.....
使用volatile可以禁止指令重排序,从而修正重排序可能带来的并发安全性问题
volatile的第三个特性–不能保证原子性操作
在使用volatile关键字时,对变量的操作是不能保证原子性。