深入理解volatile

深入理解volatile

1 简介

volatile是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

2 volatile变量的特性

(1)保证可见性,不保证原子性

a.当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去;

b.这个操作会导致其他线程中的缓存无效。

(2)禁止指令重排

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:

a.重排序操作不会对存在数据依赖关系的操作进行重排序。

比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。

b.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果,下例中的1和2由于不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达4处,而线程A中a=2这个操作还未被执行,所以b=a+1的结果也有可能依然等于2。

深入理解volatile

使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile禁止指令重排序也有一些规则:

a.当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

b.在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。

3 volatile不适用的场景

(1)volatile不适合复合操作

例如,inc++不是一个原子性操作,可以由读取、加、赋值3步组成,所以结果并不能达到30000。

深入理解volatile

(2)解决方法

1.采用synchronized

深入理解volatile

2.采用Lock

深入理解volatile

3.采用java并发包中的原子操作类,原子操作类是通过CAS循环的方式来保证其原子性的

4 volatile原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:

I. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

II. 它会强制将对缓存的修改操作立即写入主存;

III. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

5 volatile使用场景

经常使用volatile的两种场景:

1、并发场景下double-check的单例模式

<code>/** * double-check单例模式 */public class Singleton {//此处必须用volatile修饰,否则可能会有并发问题privatevolatilestaticSingletonsingleton;
private Singleton(){}
public static Singleton getSingleton(){
if(null == singleton){
synchronized (Singleton.class){
if(null == singleton){
singleton=newSingleton(); } } }
return singleton; }}
/<code>

2、停止一个工作线程

<code>public class Worker {
//此处必须用volatile修饰,否则可能会有并发问题 private volatile boolean flag = true;
public void startWork(){
new Thread(new Runnable() { @Override public void run() {
while (flag){//代码2
doSomething(); } }
},"thread1").start(); }
public void stopWork(){
flag = false; }
/** * 具体业务方法 */ private void doSomething(){
}
public static void main(String[] args){
Worker test = new Worker();
test.startWork();
try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
test.stopWork(); }}/<code>

上述的两个场景中,我们可以看到在类的成员变量上都加了volatile关键字修饰,那么为什么要加这个关键字?带着这个疑问,我们继续看下文。

我们知道在JVM的内存模型中,每个线程都有自己的工作内存,而各个线程共享堆内存,那么在多线程环境下对共享变量的操作就存在可见性的问题。什么是可见性问题呢?简单来说就是一个线程(写线程)对共享变量进行了修改,在另一个线程(读线程)中读取共享变量时获取的还是修改前的值,造成了两个线程中看到的同一个变量的值不一致;也就是说写线程的修改操作对读线程是不可见的,造成了两个线程间存在可见性问题。

怎么解决可见性问题呢?通常做法有两种:一是用volatile,一是用锁(synchronized或ReentrantLock)。当然锁的开销比较大,所以比较高效的做法是使用volatile关键字。

那么volatile是如何解决可见性问题的呢?答案是内存屏障,编译器通过在指令序列中加入内存屏障来禁止特定类型的指令重排从而保证了可见性。

我们在回到文章开头提到的volatile的两个应用场景来分析一下,如果不用volatile修饰会有什么样的问题?

在场景1中,成员变量singleton如果不用volatile修饰,那么注释中代码1(代码片段:singleton=new Singleton();)可能会被编译器进行指令重排,从而造成类初始化不完全。我们来具体分析一下:singleton=new Singleton();这句代码对singleton的赋值不是原子操作,我们可以简单看成由三条指令组成(1)在堆上申请空间;(2)调用构造函数初始化;(3)将堆上的内存地址引用赋值给栈上的变量singleton。假设编译器对指令重排后的顺序为(1)->(3)->(2)那么就会存在其他线程获取锁后在第二重check时判断到singleton!=null,从而直接将还未初始化完全的singleton对象返回,造成了问题。而加了volatile修饰后JVM会禁止编译器对volatile修饰的变量进行指令重排,因此执行顺序为(1)->(2)->(3),等到singleton初始化完成了才会返回,所以不存在问题。

在场景2中,成员变量flag如果不加volatile修饰,那么注释中代码2(while(flag))就会一直循环执行,因为thread1读取到的还是旧的值:true;加了volatile修饰后,主线程对flag的修改立即对thread1可见,所以此时thread1中flag=false,循环退出。

通过本文的分析,相信你对volatile有了一个深入的理解,在实际项目中我们要灵活应用volatile,避免出现可见性问题,从而写出更加健壮的代码。

关于深入理解volatile,你学会了多少?欢迎在留言区评论


分享到:


相關文章: