一.volatile的作用
Volatile关键字的作用主要有如下两个:1. 线程的可见性:当一个线程修改一个共享变量时,另外一个线程能立刻读到这个修改的值。2. 顺序一致性:禁止指令重排序。
二.并发编程三大特性与volatile原理
1.原子性:一个或者多个操作,要么全部执行并且在执行的过程中不被其他操作打断,要么就全部都不执行
2.可见性:多个线程操作一个共享变量时,其中一个线程对变量进行修改后,其他 线程可以立即看到修改的结果
3.有序性:程序的执行顺序按照代码的先后顺序来执行
1.可见性
一个线程对共享变量的修改,另一个线程可立刻感知到,这就是可见性问题。
lock指令
加了volatile之后我们使用 javap -v 的命令反编译之后可以看到这个变量的 flags 中多了一个标识ACC_VOLATILE。这个东西在调用到 C++ 代码是这样的
__asm__代表这是汇编指令
最终到 CPU 汇编指令之后是一个lock;
通过查阅LOCK指令的翻译
lock指令主要确保了两件事:
-
写volatile时处理器会将立即缓存写回到主内存。
-
一个处理器的缓存写回到内存会导致其他处理器的缓存失效。
-
read(从主存读取)
-
load(将主存读取到的值写入工作内存)
-
use(从工作内存读取数据来计算)
-
assign(将计算好的值重新赋值到工作内存中)
-
store(将工作内存数据写入主存)
-
write(将store 过去的变量赋值给主存中的变量)
如上图,只要 flag 变成了 1,然后线程不是要将 flag = 1写会工作内存吗?assign操作。此时,若 flag 变量上加了 volatile 关键字的话,那么此时会强制保证 assign 之后就立马执行 store + write,刷回到主内存里去,保证只要工作内存一旦变为 flag = 1,主内存立马变成 flag = 1
此外,如果这个变量加了 volatile 关键字的话,此时他就会让其他线程工作内存中的这个 flag 变量的缓存,使其强制过期掉,其他线程再从工作内存中读取 flag 变量的值时,发现它已经过期了,此时就会重新从主内存里加载这个flag=1。
总结下:volatile可见性的实现就是借助了汇编指令 LOCK,依托缓存一致性协议和总线嗅探技术,通过在写volatile的机器指令前加上lock前缀,使写volatile具有以下两个原则:
-
写volatile时处理器会将立即缓存写回到主内存。
-
一个处理器的缓存写回到内存会导致其他处理器的缓存失效。
最终,通过 volatile 关键字,可以实现的一个效果是,有一个线程修改了值,其他线程可以立刻感知到这个值。
2.有序性
对于代码,还有一个问题是指令重排序,编译器和指令器,有的时候为了提高代码执行效率,会将指令重新排序。
指令重排的两个原则
-
as-if-serial原则
不管怎么重排序,单线程下的执行结果不能被改变。
编译器、runtime和处理器都必须遵守as-if-serial语义。
-
happens-before原则
编译器、指令器可能会对代码重排序,但要遵守一定的规则,happens-before 原则。只要符合 happens-before 原则,那么就不能胡乱排序,如果不符合这些规则的话,那就可以自己排序
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作,volatile变量写,再是读,必须保证是先写,再读
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
上面这8条原则的意思很显而易见,就是程序中的代码如果满足这个条件,就一定会按照这个规则来保证指令的顺序。但是如果没满足上面的规则,那么就可能会出现指令重排,就这个意思。这8条原则是避免说出现乱七八糟扰乱秩序的指令重排,要求是这几个重要的场景下,比如是按照顺序来,但是8条规则之外,可以随意重排指令。
有序性,编译器和处理器为了提高运算性能都会对不存在数据依赖的操作进行指令重排优化,在Java内存模型中,通过as-if-serial和happens-before(先行先发生)来保证从重排的正确性,
但是在多线程环境中,指令重排则有可能出现一些意想不到的问题。
我们先来举一个例子,看一下如果只使用synchronized而不使用volatile会发生什么问题,就拿我们比较熟悉的单例模式来看。
我们通过双重校验锁的方式实现一个单例,这里不使用volatile关键字:
以上代码,我们通过使用synchronized对Singleton.class进行加锁,可以保证同一时间只有一个线程可以执行到同步代码块中的内容,也就是说singleton = new Singleton()这个操作只会执行一次,这就是实现了一个单例。
但是,当我们在代码中使用上述代码创建单例对象的时候有可能返回的对象为null。
我们假设Thread1 和 Thread2两个线程同时请求Singleton.getSingleton方法的时候:
Step1 ,Thread1执行到第8行,开始进行对象的初始化。
Step2 ,Thread2执行到第5行,判断singleton == null。
Step3 ,Thread2经过判断发现singleton != null,所以执行第12行,返回singleton。
Step4 ,singleton对象创建完成后,开始执行后续的操作,结果可能发现singleton对象为null。
之所以出现这种情况,是因为singleton = new Singleton()不是一个原子操作。
我们这里来分析一下,singleton = new Singleton();这行代码到底做了什么事情,大致过程如下:
1、虚拟机遇到new指令,到常量池定位到这个类的符号引用。
2、检查符号引用代表的类是否被加载、解析、初始化过。
3、虚拟机为对象分配内存。
4、虚拟机将分配到的内存空间都初始化为零值。
5、虚拟机对对象进行必要的设置。
6、执行方法,成员变量进行初始化。
7、将对象的引用指向这个内存区域。
我们把这个过程简化一下,简化成3个步骤:
a、JVM为对象分配一块内存
b、在内存上为对象进行初始化
c、将内存的地址复制给singleton变量
因为将内存的地址赋值给singleton变量是最后一步,所以Thread1在这一步骤执行之前,Thread2在对singleton==null进行判断一直都是true的,那么他会一直阻塞,直到Thread1将这一步骤执行完。
但是,以上过程并不是一个原子操作,并且编译器可能会进行重排序,如果以上步骤被重排成:
a、JVM为对象分配一块内存M
c、将内存的地址复制给singleton变量
b、在内存M上为对象进行初始化
这样的话,Thread1会先执行内存分配,在执行变量赋值,最后执行对象的初始化,那么,也就是说,在Thread1还没有为对象进行初始化的时候,Thread2进来判断singleton==null就可能提前得到一个false,则会返回一个不完整的sigleton对象,因为他还未完成初始化操作。
这种情况一旦发生,我们拿到了一个不完整的singleton对象,当尝试使用这个对象的时候就极有可能发生NPE异常。
那么,怎么解决这个问题呢?因为指令重排导致了这个问题,那就避免指令重排就行了。
所以,volatile就派上用场了,因为volatile可以避免指令重排。只要将代码改成以下代码,就可以解决这个问题:
对singleton使用volatile约束,保证他的初始化过程不会被指令重排。
那么volatile是如何实现这一特殊规则的呢?答案就是内存屏障(Memory Barrier)。在Java内存模型中,主要有以下4种类型的内存屏障:
-
LoadLoad屏障:对于Load1,LoadLoad,Load2这样的语句,在Load2及后续读取操作前要保证Load1要读取的数据读取完毕;
-
LoadStore屏障:对于Load1,LoadStore,Store2这样的语句,在Store2及后续写入操作前要保证Load1要读取的数据读取完毕;
-
StoreStore屏障:对于Store1,StoreStore,Store2这样的语句,在Store2及后续写入操作前要保证Store1的写入操作对其他处理器可见;
-
StoreLoad屏障:对于Store1,StoreLoad,Load2这样的语句,在Load2及后续读取操作前,Store1的写入对所有处理器可见。如下图所示:
-
对于volatile变量读,JMM会在读操作后面插入一个LoadLoad屏障、一个LoadStore屏障;
-
对于volatile变量写,JMM会在写操作前加一个StoreStore屏障,在写操作后加一个StoreLoad操作。
-
3.原子性
volatile能实现原子性吗?先来看一个例子。
实际运行的结果,经常会小于2000.
其中:语句2,i++ 其实在Java中执行过程,可以分为3步:
-
i 被从局部变量表(内存)取出,
-
压入操作栈(寄存器),操作栈中自增
-
使用栈顶值更新局部变量表(寄存器更新写入内存)
执行上述3个步骤的时候是可以进行线程切换的,或者说是可以被另其他线程的 这3 步打断的,因此语句2不是一个原子性操作。
如下图所示,对于一个 i++ 操作,只要是多个线程并发运行来执行这行代码,其实都是不保证原子性的,如果保证原子性,第一个线程 i++,i=1,第二个线程i++,i=2.
-
volatile 不保证原子性
volatile是不保证原子性的,如图,线程1变为 flag=1并写会到主内存了,线程2中工作内存,感知到被改变,会使 flag = 0过期掉,重新读到flag=1,此时线程2已经计算完flag=1,assign到主内存同样是1,最后写回到主内存。
三.volatile的适用场景
1:状态标志
也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。
而如果使用synchronized块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。
这种类型的状态标记的一个公共特性是:通常只有一种状态转换;shutdownRequested标志从false转换为true,然后程序停止。这种模式可以扩展到来回转换的状态标志,但是只有在转换周期不被察觉的情况下才能扩展(从false到true,再转换到false)。此外,还需要某些原子状态转换机制,例如原子变量。
2:一次性安全发布(one-time safe publication)
在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。
这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。如上面介绍的单例模式。
3:独立观察(independent observation)
安全使用 volatile 的另一种简单模式是:定期 “发布” 观察结果供程序内部使用。【例如】假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
使用该模式的另一种应用程序就是收集程序的统计信息。
【例】如下代码展示了身份验证机制如何记忆最近一次登录的用户的名字。将反复使用lastUser引用来发布值,以供程序的其他部分使用。(主要利用了volatile的可见性)
4:开销较低的“读-写锁”策略
如果读操作远远超过写操作,您可以结合使用内部锁和volatile 变量来减少公共代码路径的开销。
如下显示的线程安全的计数器,使用synchronized确保增量操作是原子的,并使用volatile保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
使用锁进行所有变化的操作,使用 volatile 进行只读操作。