你真的会用Volatile了吗?
作者:大方
前言
在Java面试时,很多面试官都喜欢考察面试者对Java并发的了解程度。你面试的时候被并发编程的问题难住了吗?是不是也挂在并发问题上?
在面试中,volatile关键字是出镜率相当高的一个。面试官以volatile关键字作为一个切入点,往往可以一问到底,把并发编程的三特性、Java内存模型、线程安全问题都牵扯出来,再往下深入地话还可以继续考察JVM底层实现原理以及操作系统的相关知识。
面试官经常会以下面问题开始
面试官:平时工作中有没有用到 volatile 关键字?
你:用到了,为了在多线程并发时保证共享变量的可见性。
面试官:那说说volatile关键字的作用,了解并发编程的三大特性吗?
你:并发三大特性是原子性、可见性和有序性。volatile实现了可见性和有序性。
.......
面试官可以一直无限问下去,直到问到你的盲点。
所以我们很有必要认真学习一下这个volatile关键字,这样我们在以后遇到类似的问题就可以从容面对,甚至还能吊打面试官。我们本篇文章就围绕 volatile 展开,主要介绍 volatile 的用法,以及 volatile 是如何提供可见性和有序性的。
下面我们来回忆一下并发的三大特性:原子性、可见性和有序性。
并发三大特性
1.原子性:原子性就是说一个操作不能被打断,要么执行完要么不执行。
2.可见性:可见性是指一个变量的修改对所有线程可见。即当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。 3.有序性:为了提高程序的执行性能,编辑器和处理器都有可能会对程序中的指令进行重排序。
对于volatile关键字,它只满足可见性和有序性,不满足原子性。当一个变量被定义为volatile之后具备了可见性和有序性。
volatile特性
可见性
简单地说就是volatile变量修改后,所有线程都能立即实时地看到它的最新值。
有序性
有序性是指系统在进行代码优化时,不能把在volatile变量操作后面的语句放到其前面执行,也不能将volatile变量操作前面的语句放在其后执行。
所以,volatile关键字可以解决多线程可见性问题。那我们如何使用volatile关键字呢?看下面的代码。
初识volatile
下面的两个例子演示了变量使用volatile和未使用volatile时,变量更新对多线程执行的影响。
//使用了volatile
public class VolatileDemo {
public static volatile boolean stop = false;//任务是否停止,volatile变量
public static void main(String[] args) throws Exception {
Thread thread1 = new Thread(() -> {
while (!stop) { //stop=false,不满足停止条件,继续执行
//do someting
}
System.out.println("stop=true,满足停止条件。" +
"停止时间:" + System.currentTimeMillis());
});
thread1.start();
Thread.sleep(100);//保证主线程修改stop=true,在子线程启动后执行。
stop = true; //true
System.out.println("主线程设置停止标识 stop=true。" +
"设置时间:" + System.currentTimeMillis());
}
}
在VolatileDemo中,停止标识stop使用volatile关键字修饰,初始值为false。创建子线程thread1并启动,在子线程thread1任务中,当不满足停止条件时,线程会一直运行;当满足停止条件,终止任务。稍后,我们在主线程中设置停止标识为true。执行代码,结果如下图,我们可以看到在主线程设置stop=true后,子线程同时感知到stop的变化终止了任务。
![4013e0515de8f735c46a3ec8e88c6488.png](https://i-blog.csdnimg.cn/blog_migrate/ddd32ea7c30a33c72594ba17d539c3e7.jpeg)
NonVolatileDemo中,停止标识stop未使用volatile关键字修饰,初始值为false。其他代码和VolatileDemo完全一致。
//未使用了volatile
public class NonVolatileDemo {
public static boolean stop = false;//任务是否停止,普通变量
public static void main(String[] args) throws Exception {
Thread thread1 = new Thread(() -> {
while (!stop) { //stop=false,不满足停止条件,继续执行
//do someting
}
System.out.println("stop=true,满足停止条件。" +
"停止时间:" + System.currentTimeMillis());
});
thread1.start();
Thread.sleep(100);//保证主线程修改stop=true,在子线程启动后执行。
stop = true; //true
System.out.println("主线程设置停止标识 stop=true。" +
"设置时间:" + System.currentTimeMillis());
}
}
执行代码,结果如下图,我们可以看到在主线程设置stop=true后,子线程未及时感知到stop的变化,还在继续执行任务。
![0350bb9226ff25152ab604f6146e1011.png](https://i-blog.csdnimg.cn/blog_migrate/f6908c26b2ee5ec0c3b9d8ecbb954487.jpeg)
所以NonVolatileDemo代码就存在可见性问题;而在VolatileDemo中通过使用volatile关键字,很简单的保证了多线程下共享变量的可见性。
我们知道对于synchonized关键词来说,它是能同时保证三大特性的,那为什么在需要保证可见性的时候不使用synchonized呢?
下面,我们使用synchonized实现VolatileDemo功能,来进行直观感受下。
public class SychronizedDemo {
public static boolean stop = false;//任务是否停止
//同步静态方法,设置stop
public static synchronized void setStop(boolean flag) {
SychronizedDemo.stop = flag;
}
//同步静态方法,获取stop
public static synchronized boolean isStop() {
return stop;
}
public static void main(String[] args) throws Exception {
Thread thread1 = new Thread(() -> {
while (!isStop()) { //stop=false,不满足停止条件,继续执行
//do someting
}
System.out.println("stop=true,满足停止条件。" +
"停止时间:" + System.currentTimeMillis());
});
thread1.start();
Thread.sleep(100);//保证主线程修改stop=true,在子线程启动后执行。
setStop(true); //true
System.out.println("主线程设置停止标识 stop=true。" +
"设置时间:" + System.currentTimeMillis());
}
}
在SychronizedDemo中,停止标识stop为普通静态变量,初始值为false。stop的设置方法setStop或获取方法isStop都为同步方法,可以保证锁对象SychronizedDemo类静态变量stop的可见性。执行代码,结果如下图,我们可以看到在主线 程设置stop=true后,子线程同时感知到stop的变化终止了任务。
![f5efe6490cb2d811ffb536ab578a7d80.png](https://i-blog.csdnimg.cn/blog_migrate/44de7e3119607868d1542558b7559b99.jpeg)
那既然volatile关键字可以保证可见性,使用volatile关键字有局限性吗?
事实上,在使用volatile关键字时我们要遵循一定的规则。
volatile的使用建议
使用 volatile 变量的主要原因是单个字段同步操作的简易性。如果只使用了volatile就能实现线程安全,那就放心的使用它,如果同时还需要添加其他的同步措施,那就不要使用。
正确使用的场景举例:变量本身标识是一种状态,或者引用变量的某些属性状态,在代码中需要确保这些状态的可见性,这时就可使用volatile。volatile 变量仅仅是一个状态标识,用于指示发生了一个重要的一次性事件,例如完成初始化标识或请求终止标识。
volatile boolean stop=false;//volatile 变量,用于停止请求的状态标识
public void shutdown() {//停止请求
stop = true;
}
public void doWork() {
while (!stop) {//判断是否需要停止
// do Something
}
}
这样只要任何一个线程调用了shutdown(),其他线程在执行doWork时都可以立即感知到stop变量的变化,这时就可以大胆的使用volatile。这种类型的状态标记的一个公共特性是:通常只有一种状态转换,如标志从false 转换为true。
volatile关键字是Java提供的最轻量级的同步机制,为字段的访问提供了一种免锁机制,使用它不会引起线程的切换及调度。这时使用volatile要比synchronized要简单有效的多,如果使用synchronized还会影响系统的吞吐量。
那既然volatile关键字可以保证可见性,它使用起来这么方便,那它是解决可见性的万能药吗?我能用volatile代替synchonized吗?
事实上,volatile关键字并不是万能的,因为volatile并不能保证原子性。
volatile的错误用法
我们看下面的例子:
public class VolatileTest {
public volatile int race = 0;
public void increase() {
race++;
}
public int getRace(){
return race;
}
public static void main(String[] args) {
//创建5个线程,同时对同一个volatileTest实例对象执行累加操作
VolatileTest volatileTest=new VolatileTest();
int threadCount = 5;
Thread[] threads = new Thread[threadCount];//5个线程
for (int i = 0; i < threadCount; i++) {
//每个线程都执行1000次++操作
threads[i] = new Thread(()->{
for (int j = 0; j < 10000; j++) {
volatileTest.increase();
}
System.out.println(Thread.currentThread().getName()+"执行1000次++后,race值为:"+volatileTest.getRace());
},"线程"+(i+1));
threads[i].start();
}
//等待所有累加线程都结束
for (int i = 0; i < threadCount; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//所有子线程结束后,race理论值应该是:5*10000=50000,但是执行结果会小于它
System.out.println("累加结果:"+volatileTest.getRace());
}
}
在VolatileTest中,race是使用volatile关键字修饰的int类型变量,初始值为0。创建5个线程,对同一个volatileTest实例对象执行累加操作,每个线程执行1000次++操作。这样所有子线程结束后,race理论值应该是:5*10000=50000,但是执行结果会小于它,如下图。
![0228971485e64d7da79ca4b4facbc659.png](https://i-blog.csdnimg.cn/blog_migrate/f7eb11c06446f01bbbba221ba833583a.jpeg)
造成这种情况的原因volatile不能保证++操作的原子性。
我们知道基本数据类型的单次读、写操作时具有原子性的。同样单个volatile 变量单次的读、写操作也具有原子性。但是对于类似于 ++,--,逻辑非! 这类复合操作,这些操作整体上是不具有原子性。
volatile int i=0;//定义volatile变量i
if(i==1)//单独对i读,此操作具有原子性
i=1;//单独对i写(赋值),此操作具有原子性
i++;//复合操作,此操作不具有原子性
因为++操作分三次操作完成的。我们执行反编译命令 javap -c VolatileTest.class,我们看到increase()函数中race++是由以下字节码指令构成。
public void increase();
Code:
0: aload_0
1: dup
2: getfield #2 // Field race:I
5: iconst_1
6: iadd
7: putfield #2 // Field race:I
10: return
字节码释义如下:
aload_0 将this引用推送至栈顶
dup 复制栈顶值this应用,并将其压入栈顶,即此时操作数栈上有连续相同的this引用;
getfield 弹出栈顶的对象引用,获取其字段race的值并压入栈顶。第一次操作
iconst_1 将int型(1)推送至栈顶
iadd 弹出栈顶两个元素相加(race+1),并将计算结果压入栈顶。第二次操作
putfield 从栈顶弹出两个变量(累加值,this引用),将值赋值到this实例字段race上。第三次操作,赋值
从字节码层面很容易分析出来并发失败的原因了,假如有两条线程同时执行race++,
(1)线程A,线程B同时执行getfield指令把race的值压入各自的操作栈顶时。volatile关键字可以保证来race的值在此时是正确(最新的值)的。
![7a847c19a624f4c7a27369049e29a0b6.png](https://i-blog.csdnimg.cn/blog_migrate/91cdfd1ae498626e6d3bf8aa4dd2e05b.jpeg)
(2)线程A,线程B同时执行iconst_1将int型(1)推送至栈顶
![43cec458a11fbcaafbf4b74b82313ca9.png](https://i-blog.csdnimg.cn/blog_migrate/26234dd6d93fde9da2e350e31f4ca030.jpeg)
(3)线程A依次执行完了后续操作iadd和putfield,此时主内存中race的值已被增大1。线程A执行完毕后,线程B操作栈顶的race值就变成了过期的数据。
![52e38fcbafdf1f183e8fce242d944022.png](https://i-blog.csdnimg.cn/blog_migrate/f0bf32f0369d5017bad09d564a2420b9.jpeg)
(4)这时线程B执行iadd、putfield后就会把较小的值同步会主内存了。
![825f603b6911ebde53312ea4539d2d4e.png](https://i-blog.csdnimg.cn/blog_migrate/5500dd23b562fc000855c6261e5a6276.jpeg)
所以,在这种场景中,我们仍然要通过加锁来保证原子性,此时就不建议使用volatile。以下为正确实现,使用synchronized保证 race++操作的原子性。
public class SynchronizedTest {
public int race = 0;
//使用synchronized保证++操作原子性
public synchronized void increase() {
race++;
}
public int getRace(){
return race;
}
public static void main(String[] args) {
//创建5个线程,同时对同一个volatileTest实例对象执行累加操作
SynchronizedTest synchronizedTest=new SynchronizedTest();
int threadCount = 10;
Thread[] threads = new Thread[threadCount];//5个线程
for (int i = 0; i < threadCount; i++) {
//每个线程都执行1000次++操作
threads[i] = new Thread(()->{
for (int j = 0; j < 10000; j++) {
synchronizedTest.increase();
}
System.out.println(synchronizedTest.getRace());
});
threads[i].start();
}
//等待所有累加线程都结束
for (int i = 0; i < threadCount; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//所有子线程结束后,race是:5*10000=50000。
System.out.println("累加结果:"+synchronizedTest.getRace());
}
}
执行代码,如下图。
![d692d494e85ee3100f17b3c141f7ba11.png](https://i-blog.csdnimg.cn/blog_migrate/5e38014d27d62ef0a280a6809d896e2a.jpeg)
volatile 总结
现在我们知道如何使用volatile保证共享变量的可见性了,相信大家一定不虚此行,在日后的工作中能更加熟练的运用 volatile 关键字了。
那你觉得 volatile 关键字是如何保证线程的可见性和有序性的呢?这我们就需要从Java内存模型和硬件层面来了解可见性的本质。大家想了解吗?我们会在下一篇文章为大家揭秘,敬请期待!