volatile 在面试时候会经常问到,关于其实现原理 需要计算机底层的一些知识。
- 首先看下java线程模型 JMM
引用周志明老师的《深入理解虚拟机第二版》java必看的书籍!
volatile 有什么作用?
-
- 被他修饰的变量,在多个线程之间可见。
-
- 防止指令重排序 (双检索单例时候会出现该问题)
volatile 为什么能保证可见性?
CPU为了提高处理性能,并不直接和内存进行通信,而是将内存的数据读取到内部缓存(L1,L2)再进行操作,但操作完并不能确定何时写回到内存,如果对volatile变量进行写操作,当CPU执行到Lock前缀指令(汇编,底层会调用 到 cpu的 lock add1 $ 0x0 , (%esp) 这个指令 ) 时,会将这个变量所在缓存行的数据写回到内存,不过还是存在一个问题,就算内存的数据是最新的,其它CPU缓存的还是旧值,所以为了保证各个CPU的缓存一致性,每个CPU通过嗅探在总线上传播的数据来检查自己缓存的数据有效性,当发现自己缓存行对应的内存地址的数据被修改,就会将该缓存行设置成无效状态,当CPU读取该变量时,发现所在的缓存行被设置为无效,就会重新从内存中读取数据到缓存中。
所以保证可见性的答案是内存屏障 对应的指令是 lock add1 $ 0x0 , (%esp)
volatile 为什么能防止指令重排?
答案是也: 内存屏障 对应的指令同样是 lock add1 $ 0x0 , (%esp)
内存屏障解释: (Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下几种类型:
- LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
总结: 说白了 内存屏障有这两个作用
- (1)阻止屏障两侧的指令重排序;(例子 双检索单例)
- (2)强制把工作内存中的脏数据等写回主内存,让其他工作内存中的数据失效(通过嗅探在总线上传播的数据来检查自己缓存的数据有效性), 从而保证每次读取变量值的时候都是从主内存中读取到的。
ps: 关于volatile的作用以及原理 我真的是看过n篇文章,也翻阅过很多资料,说实话有些东西比较容易忘,所以这里做个记录
show you me code (让我们感受一下重排序的 "魅力" 哈哈)
public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
while (true) {
x = 0;
y = 0;
a = 0;
b = 0;
i++;
Thread one = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();
other.start();
Thread.sleep(1);//TODO 这里我使用sleep(1),不对线程进行join,让one和other乱序执行
//TODO 美团的博客说这里需要one.join()和other.join();我个人在这里持疑问态度
//TODO 假设one.join();那么肯定是先执行完one里边的run方法在执行other方法。既然one都执行完了,那么a肯定赋值了,也就是说你怎么
//TODO 重排序,a都是有值的,在执行到other时候,y 肯定 = 1; 也就是说y=1;
//TODO 我个人认为出现y=0的情况 是因为可见性导致的。也就是说a=1这个操作,在other线程读取变量a时候 a还没有刷新到主内存。所以导致y=0;
//TODO 不知道我理解的对不对,有时间找个大佬请教下。我在跑程序时候 使用的sleep(1)的方式 就是让这俩程序乱序执行(才可能出现因为重排序引发的y=0现象)
//TODO 第一步: x = b; (假设one抢到cpu时间片) (并且发生重排序 和 a = 1; 换位置了)
//TODO 第二步: b = 1; (假设other抢到cpu时间片)
//TODO 第三步: y = a; (假设other抢到cpu时间片) 注意此时a还没有赋值
//TODO 第四步: a = 1; (假设one抢到cpu时间片) (并且发生重排序 和 x = b; 换位置了)
// one.join();
// other.join();
String result = "第" + i + "次执行(x=" + x + " y=" + y + ")";
if (x == 0 && y == 0) {
System.out.println("第" + i + "次 出现了 : " + "(" + x + "" + "," + y + ")");
break;
} else {
System.out.println(result);
}
}
}
}
看完上边代码我们就知道,输出结果应该是 (x=0,y=1) 或者 (x=1,y=0) 或者(x =1,y =1),就看谁抢到cpu时间片了 但是理论上将一定不会出现 (x=0,y=0)的现象
。
but 看下图。还真就出现了
- 目前我猜测和重排序有关,欢迎讨论
另外我有一个疑问 (见下图中描述) ,欢迎大佬们赐教。
另外关于双检索单例时候为什么要加 volatile?不加volatile会出现什么问题??? 欢迎大佬们给出留言。
参考:
《深入理解虚拟机第2版》
美团技术团队,不错的帖子,可惜就是没评论区
占小狼的博客