volatile关键字的作用_你真的会用Volatile了吗?

你真的会用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

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

所以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

那既然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

造成这种情况的原因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

(2)线程A,线程B同时执行iconst_1将int型(1)推送至栈顶

43cec458a11fbcaafbf4b74b82313ca9.png

(3)线程A依次执行完了后续操作iadd和putfield,此时主内存中race的值已被增大1。线程A执行完毕后,线程B操作栈顶的race值就变成了过期的数据。

52e38fcbafdf1f183e8fce242d944022.png

(4)这时线程B执行iadd、putfield后就会把较小的值同步会主内存了。

825f603b6911ebde53312ea4539d2d4e.png

所以,在这种场景中,我们仍然要通过加锁来保证原子性,此时就不建议使用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

volatile 总结

现在我们知道如何使用volatile保证共享变量的可见性了,相信大家一定不虚此行,在日后的工作中能更加熟练的运用 volatile 关键字了。

那你觉得 volatile 关键字是如何保证线程的可见性和有序性的呢?这我们就需要从Java内存模型和硬件层面来了解可见性的本质。大家想了解吗?我们会在下一篇文章为大家揭秘,敬请期待!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值