Volatile关键字浅析
1. volatile的定义
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致性地更新,线程应该确保通过排他锁单独获取这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile关键字,Java线程内存模型确保所有线程看到这个变量值的一致性。
2.volatile的实现原则
1)Lock前缀指令会引起处理器缓存写回内存。Lock前缀指令导致在执行指令期间,声言处理器的Lock#信号。在多核处理器环境中,Lock#信号确保在声言该信号期间,处理器可以独占任何共享内存。
2)一个处理器的缓存写会内存会导致其他处理器的缓存失效。(根据MESI协议)
3.volatile的自身特性(自身角度分析特性)
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。如下两个代码示例:
volatile关键字代码:
网站建设哪家好,找成都创新互联公司!专注于网页设计、网站建设、微信开发、微信小程序定制开发、集团企业网站建设等服务项目。为回馈新老客户创新互联还提供了临泉免费建站欢迎大家使用!
public class VolatileFeaturesExample {
volatile long v1 = 0L;
public void set (long v2) {
this.v1 = v2;
}
public long get () {
return v1;
}
public void getAndIncrement() {
v1++;
}
public static void main(String[] args) {
VolatileFeaturesExample v = new VolatileFeaturesExample();
/*new Thread(new ThreadSet(v)).start();
new Thread(new ThreadGet(v)).start();*/
final CountDownLatch countDownLatch = new CountDownLatch(5000);
for (int i = 0;i < 5000;i ++) {
new Thread(new Runnable() {
@Override
public void run() {
v.getAndIncrement();
countDownLatch.countDown();
}
}).start();
}
try {
countDownLatch.await();
System.out.println(v.get());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
static class ThreadGet implements Runnable {
private VolatileFeaturesExample v;
public ThreadGet (VolatileFeaturesExample v) {
this.v = v;
}
public void run() {
long local_v1 = 0L;
while (local_v1 <10) {
if (local_v1 != v.get()) {
System.out.println("ThreadGet--------------"+v.get());
local_v1 = v.get();
}
}
}
}
static class ThreadSet implements Runnable {
private VolatileFeaturesExample v;
public ThreadSet (VolatileFeaturesExample v) {
this.v = v;
}
public void run() {
long local_v1 = 0L;
while (local_v1 < 10) {
System.out.println("ThreadSet----------"+(++local_v1));
v.set(local_v1);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
把volatile改为锁synchronized。(这里只贴了方法代码,其它和上面一样)
long v1 = 0L;
public synchronized void set (long v2) {
this.v1 = v2;
}
public synchronized long get () {
return v1;
}
代码分析:
get和set方法执行结果
(1)加了volatile关键字的执行结果
ThreadSet----------1
ThreadGet--------------1
ThreadSet----------2
ThreadGet--------------2
ThreadSet----------3
ThreadGet--------------3
ThreadSet----------4
ThreadGet--------------4
ThreadSet----------5
ThreadGet--------------5
ThreadSet----------6
ThreadGet--------------6
ThreadSet----------7
ThreadGet--------------7
ThreadSet----------8
ThreadGet--------------8
ThreadSet----------9
ThreadGet--------------9
ThreadSet----------10
ThreadGet--------------10
(2)去掉volatile执行结果
ThreadSet----------1
ThreadGet--------------1
ThreadSet----------2
ThreadSet----------3
ThreadSet----------4
ThreadSet----------5
ThreadSet----------6
ThreadSet----------7
ThreadSet----------8
ThreadSet----------9
ThreadSet----------10
(3)去掉volatile关键字,换成锁synchronized的执行结果
ThreadSet----------1
ThreadGet--------------1
ThreadSet----------2
ThreadGet--------------2
ThreadSet----------3
ThreadGet--------------3
ThreadSet----------4
ThreadGet--------------4
ThreadSet----------5
ThreadGet--------------5
ThreadSet----------6
ThreadGet--------------6
ThreadSet----------7
ThreadGet--------------7
ThreadSet----------8
ThreadGet--------------8
ThreadSet----------9
ThreadGet--------------9
ThreadSet----------10
ThreadGet--------------10
getAndIncrement执行结果
(4)使用带有volatile关键字的v1,调用getAndIncrement累加到5000
第一次:5000
第二次:5000
第三次:4999
第四次:5000
第五次:4999
如上面示例代码所示,一个volatile变量的读/写操作,与一个普通变量的读/写使用锁同步,它们之间的执行结果不同。不使用volatile关键字,发现一个线程改线,另一个线程可能都不可见。
锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对于一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
锁的语义决定了临界区代码执行具有原子性。这意味,即使64位的long型和double变量,只要它是volatile变量,对该该变量的读/写就具有原子性。根据代码getAndIncrement方法结果得知,对于volatile++这种复合操作不就有原子性,这些操作本身不具有原子性。
特性
(1)可见性。对于一个volatile变量的读,总是能看到(任意线程)对这个volatile变量的最后写入。
(2)原子性。对任意单个volatile变量的读/写具有原子性,但类似volatile++这种操作不具有原子性。
4.volatile特性的影响性(从不是volatile变量的角度分析,volatile给它们带来的内存可见性影响)
1)volatile 写/读建立的happens-before关系
其实volatile保证了可见性,其实就是完成了线程之间的通信。
我们来分析下如下代码的happens-before关系
int num = 0;
volatile boolean flag = false;
public void write (int i) {
num = i; // 1
flag = true;// 2
}
public int read () {
if (flag) { // 3
int i = num; // 4
return i;
}
return num;
}
(1)根据程序次序规则,1happens-before2;3happens-before4;
(2)根据volatile规则,2happens-before3;
(3)根据happens-before的传递规则,1happens-before4;
我们发现一个问题volatile影响了普通的字段,可以理解为write的普通num写对read的普通读num可见了,根据1happens-before来判断。
代码测试,测试volatile对普通变量的影响:
影响特性
(1)任何变量的写在volatile变量写之前,那么这个变量在volatile变量读之后是可见的.(具体解释原理第6点中详解)
5.volatile内存语义实现
1)volatile重排序规则表
(1)当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile之后。
(2)当第一个是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile之前。
(3)当第一个操作是volatile写,第二个操作volatile时,不能进行重排序。
2)限制重排序的规则(内存屏障)
(1)在每个volatile写操作的前面插入一个StoreStore屏障。
(2)在每个volatile写操作的后面插入一个StoreLoad屏障。
(3)在每个volatile读操作的后面插入一个LoadLoad屏障。
(4)在每个volatile读操作的后面插入一个LoadStore屏障。
上面的内存屏障都非常保守,但它可以保证任意处理器平台,任意程序中都能得到正确的volatile语义。
3)代码示例分析
(1)volatile写插入的内存屏障
内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
实现写语义的内存屏障:StoreStore和StoreLoad。如下图执行指令执行顺序
StoreStore屏障可以保证在volatile写之前,前面所有的普通写操作已经对任何处理器可见了。这是因为StoreStore屏障将保证上面所有的普通写在volatile写之前刷新到主内存。
这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常无法准确判断一个volatile写的后面是否需要插入一个StoreLoad屏障。为了保证能正确实现volatile的内存语义,JMM实现了保守策略:在每个volatile写的后面或者每个volatile读前面插入一个StoreLoad屏障。从整体的执行效率角度考虑,JMM最终选择了在在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见模式是:一个线程写volatile变量,读个线程读取volatile读取同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。
(2)volatile读插入的内存屏障
LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
6、通过内存屏障(Memory Barrier)分析volatile实现
代码示例:
public class VolatileTest {
private boolean vonum = false;
private int num = 0;
// thread1
public void write (int i) {
num = i;
// 插入StoreStore屏障
vonum = true;
// 插入StoreLoad屏障
}
// thread2
public void read () {
if (vonum) {
// 插入LoadLoad屏障
// 插入LoadStore屏障
System.out.println(Thread.currentThread().getName()+"---"+num);
}
}
分析:
1)、LoadLoad:在volatile读后面有一个LoadLoad屏障,它保证了屏障前的Load和屏障后Load不会重排序;注意Load不保证Load是最新的数据(我的理解是因为CPU缓存优化的原因,参考缓存存储和无效队列),但是LoadLoad屏障保证了只要thread2的vonum为true(不考虑是什么时间,怎么发生的),那么屏障后面的num的值一定不会比vonum这个版本老。
2)、StoreStore:我们看到在volatile写的前面插入一个StoreStore屏障,为什么要插入这个屏障,我们先来看下StoreStore的作用,保证屏障前的Store不会和屏障后Store重排序,其实就是写入操作的执行顺序,但是具体什么时候写入也是不确定的。其实就是保证了thread1的num的写入一定先于vonum,为什么要这么做了,其实是为了保护volatile的读语义;假如我们可以重排序,vonum先执行,如果此时thread2执行,volatile的vonum已经有值,但是num还没有值,此时就会出现问题。但是如果没有重排序,num一定先于vonum写入,那么可以保证LoadLoad屏障的语义,vonum为true时,num肯定有值。
3)、LoadStore:在volatile读的后面也插入LoadStore屏障,结合上面第5点,书上说是禁止下面的普通写重排序(其实我想到的是为什么不用禁止volatile写了,我觉得和StoreLoad屏障有关系,详见下面的StoreLoad屏障),这是为什么了,我个人理解也是为了保证上面LoadLoad屏障的语义,因为如果下面的普通写和上面的volatile读和普通读重排序,那么我们读到的普通读是和volatile读那个版本的读还是普通写的读了,肯定是普通写的读,那么其实就破坏了LoadLoad屏障的语义了,普通读可能是比volatile读旧的版本,所以要禁止和普通写的重排序。
4)、StoreLoad:在volatile写的后面插入StoreLoad屏障,结合前面的1,2点Store和Load发生的时间都是不确定的,而我们知道volatile是保证可见性的。所以StoreLoad的作用是防止屏障前面的所有写和屏障后面的所有读重排序,屏障保证了前面的所有Store对所有处理器可见,屏障保证了后面的所有读都是前面Store的最新数据。其实StoreLoad屏障就是为了解决1.2点Stoe和Load的时机问题,但是它的代价要比其它几个屏障要高(底层是基于lock指令实现),相当于一个全屏障。
7、总结
要理解volatile关键字的实现原理,我觉得先得了解硬件的内存模型(顺便了解下协议),然后知道JMM,然后知道JMM为了实现可见性提供的内存屏障:LoadLoad、LoadStore、StoreStore、StoreLoad,它们每一个是什么意思,组合一起又是什么意思,了解它们的通信机制,知道volatile关键字的用途。
参考文章:
内存屏障(英文)
内存屏障(中文)
当前文章:Volatile关键字浅析
URL标题:http://hbruida.cn/article/gsiieh.html