在上一篇文章中,我们围绕volatile关键字做了很多阐述,主要介绍了volatile的用法、原理以及特性。在上一篇文章中,我提到过:volatile只能保证可见性和有序性,无法保证原子性。关于这部分内容,有读者阅读之后表示还是不是很理解,所以我再单独写一篇文章深入分析一下。阅读本文之前,请先阅读上一篇文章: 再有人问你volatile是什么,就把这篇文章发给他
volatile与有序性
在上一篇文章中我们提到过:volatile一个强大的功能,那就是他可以禁止指令重排优化。通过禁止指令重排优化,就可以保证代码程序会严格按照代码的先后顺序执行。那么volatile又是如何禁止指令重排的呢?
先给出结论:volatile是通过 内存屏障 来来禁止指令重排的。
内存屏障(Memory Barrier)是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。下表描述了和volatile有关的指令重排禁止行为:
从上表我们可以看出:
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个 StoreStore
屏障。
对于这样的语句Store1; StoreLoad ; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
在每个volatile写操作的后面插入一个 StoreLoad
屏障。
对于这样的语句Store1; StoreLoad ; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
在每个volatile读操作的后面插入一个 LoadLoad
屏障。
对于这样的语句Load1; LoadLoad ; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
在每个volatile读操作的后面插入一个 LoadStore
屏障。
对于这样的语句Load1; LoadStore ; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
所以,volatile通过在volatile变量的操作前后插入内存屏障的方式,来禁止指令重排,进而保证多线程情况下对共享变量的有序性。
volatile与可见性
在上一篇文章中我们提到过:Java中的 volatile
关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。
其实,volatile对于可见性的实现,内存屏障也起着至关重要的作用。因为内存屏障相当于一个数据同步点,他要保证在这个同步点之后的读写操作必须在这个点之前的读写操作都执行完之后才可以执行。并且在遇到内存屏障的时候,缓存数据会和主存进行同步,或者把缓存数据写入主存、或者从主存把数据读取到缓存。
这里稍微拓展一下,我们在 内存模型是怎么解决缓存一致性问题的 一文中介绍过缓存一致性协议,同时也提到过内存一致性模型的实现可以通过缓存一致性协议来实现。同时,留了一个问题:已经有了缓存一致性协议,为什么还需要volatile?
这个问题的答案可以从多个方面来回答:
1、并不是所有的硬件架构都提供了相同的一致性保证,Java作为一门跨平台语言,JVM需要提供一个统一的语义。
2、操作系统中的缓存和JVM中线程的本地内存并不是一回事,通常我们可以认为:MESI可以解决缓存层面的可见性问题。使用volatile关键字,可以解决JVM层面的可见性问题。
3、缓存可见性问题的延伸:由于传统的MESI协议的执行成本比较大。所以CPU通过Store Buffer和Invalidate Queue组件来解决,但是由于这两个组件的引入,也导致缓存和主存之间的通信并不是实时的。也就是说, 缓存一致性模型只能保证缓存变更可以保证其他缓存也跟着改变,但是不能保证立刻、马上执行。
其实,在计算机内存模型中,也是使用内存屏障来解决缓存的可见性问题的(再次强调: 缓存可见性和并发编程中的可见性可以互相类比,但是他们并不是一回事儿 )。
写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列)。进而避免由于Store Buffer和Invalidate Queue的非实时性带来的问题。
所以,内存屏障也是保证可见性的重要手段,操作系统通过内存屏障保证缓存间的可见性,JVM通过给volatile变量加入内存屏障保证线程之间的可见性。
再来总结一下Java中的内存屏障:用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
volatile与原子性
在 以前的文章 中,我们介绍 synchronized
的时候,提到过,为了保证原子性,需要通过字节码指令 monitorenter
和 monitorexit
,但是 volatile
和这两个指令之间是没有任何关系的。 volatile
是不能保证原子性的。
网上有很多文章,拿 i++
的例子说明 volatile
不能保证原子性,然后进行各种分析,有的说由于引入内存屏障导致无法保证原子性,有的说一段 i++
代码,在编译后字节码为:
10: getfield #2 // Field i:I
14: iconst_1
15: iadd
16: putfield #2 // Field i:I
在不考虑内存屏障的情况下,一个 i++
指令也包含了四个步骤。
这些分析,只是说明了 i++
本身并不是一个原子操作,即使使用 volatile
修饰 i
,也无法保证他是一个原子操作。并不能解释为什么 volatile
为啥不能保证原子性。
要我说,由于CPU按照时间片来进行线程调度的,只要是包含多个步骤的操作的执行,天然就是无法保证原子性的。因为这种线程执行,又不像数据库一样可以回滚。如果一个线程要执行的步骤有5步,执行完3步就失去了CPU了,失去后就可能再也不会被调度,这怎么可能保证原子性呢。
为什么 synchronized
可以保证原子性 ,因为被 synchronized
修饰的代码片段,在进入之前加了锁,只要他没执行完,其他线程是无法获得锁执行这段代码片段的,就可以保证他内部的代码可以全部被执行。进而保证原子性。
但是 synchronized
对原子性保证也不绝对,如果真要较真的话,一旦代码运行异常,也没办法回滚。所以呢,在并发编程中,原子性的定义不应该和事务中的原子性一样。他应该定义为:一段代码,或者一个变量的操作,在没有执行完之前,不能被其他线程执行。
那么,为什么 volatile
不能保证原子性呢?因为他不是锁,他没做任何可以保证原子性的处理。当然就不能保证原子性了。
总结
本文在上一篇文章的基础上,再次介绍了volatile和原子性、有序性以及可见性之间的关系。有序性和可见性是通过内存屏障实现的。而volatile是无法保证原子性的。
至此,并发编程系列文章已经出了很多篇了,不知道读者对于这部分内容还有没有什么疑问和更多学习建议。欢迎留言。点击阅读原文,查看该系列所有文章。
直面Java第123期:Java枚举是如何保证线程安全的。
成神之路第012期:Java集合类—List。
内存模型是怎么解决缓存一致性的?
搞定计算机网络面试,看这篇就够了
TIOBE 8 月编程语言榜出炉,Java稳居第一
什么是IO中的阻塞、非阻塞、同步、异步?
那么请长按二维码,关注 Hollis
转发朋友圈,是对我最大的支持。