欢迎大家搜索“小猴子的技术笔记”关注我的公众号,有问题可以及时和我交流。
为了保证内存的可见性,Java编译器会在生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序。JMM被内存屏障指令分为了4类(Load表示读,store表示写):
LoadLoad Barriers:在两个读指令之间插入一个“LoadLoad”的内存屏障,确保Load1的数据装载,先于Load2的数据装载。
StoreStore Barriers:在两个写指令之间插入一个“StoreStore”的内存屏障。确保Store1的数据先刷新到主内存,并且对其数据可见。Store1的写数据先于Store2的写数据。
LoadStore Barriers:在读和写指令之间加一个“LoadStore”屏障,确保Load1的数据装载先于Store2的写数据。
StoreLoad Barriers:在写和读之间加一个“StoreLoad”屏障,确保Store1的数据写入并且刷新到内存先于Load2。“StoreLoad”会使该屏障之前所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。执行“StoreLoad”屏障的开销比较昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(不了解写缓冲区概念的小伙伴,可以查看我上篇文章《为什么会有重排序?它对线程有什么影响?》)。
如果你简历上写的有多线程的知识的话,那么面试官很大几率会问你volatile这个关键字的问题。也许你会说出,volatile是解决了内存可见性问题和禁止重排序的作用。那么你知道它底层是怎么解决的吗?
为了实现volatile的内存语义,编译器在生成字节码的时候,JMM采取保守策略会向指令序列中插入内存屏障来禁止特定类型的处理器重排序。
1.在每个volatile写操作前面插入一个StoreStore屏障。
2.在每个volatile写操作后面插入一个StoreLoad屏障。
3.在每个volatile读操作后面插入一个LoadLoad屏障。
4.在每个volatile读操作后面插入一个LoadStore屏障。
下图将对保守策略的内存屏障做一个关系的解读:
注意:上述的volatile写和volatile的读的内存屏障插入策略非常保守。其实在实际执行时,只要不改变volatile写-读的内存语义,编译器就可以根据具体情况省略不必要的屏障。比如下面的这个例子:
1 | ini复制代码public class VolatileBarriersExample { |
注意,最后的StoreLoad屏障不能省略,因为第二个volatile写之后,方法立即返回。此时编译器无法准确判断后面是否会有volatile读或写。为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。其实,volatile禁止指令重排序就是使用了内存屏障作为保证来实现的。
了解volatile的底层内存屏障的实现之后,我们来看一下对一个volatile变量的读写时,该共享变量所在的本地内存和主内存的变化(也就是内存可见性的问题):
1 | csharp复制代码public class VolatileExample { |
假设线程A首先执行了writer()方法,随后线程B执行reader()方法,那么A线程执行之后的共享变量的状态示意图如下:
结论:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
结论:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
由此可以得出结论:volatile的内存可见性是如果一个线程修改了共享变量,那么该共享变量会立刻刷新到主存中。同时,会通知另外一个持有该共享变量的线程,告诉它这个共享变量已经修改了,不要再使用你工作内存中的变量值了,快去主内存中重新获取吧。
总的来说:volatile使用了内存屏障来禁止指令的重排序,使用刷新主内存,通知其他线程工作内存中的共享变量失效,使其他线程强制去主内存获取最新的值来保证,被volatile修饰的变量的内存可见性。
本文转载自: 掘金