Volatile:JVM 我警告你,我的人你别乱动

网站建设4年前发布
48 0 0

20230305204800c23ef23305161ad6544514e0203e0861a13b0d258,Volatile 算是一个面试中的高频问题了。我们都知道 Volatile 有两个作用:,指令重排序的问题,基本上都是通过 DCL 问题来考察。,面试中通常会是下面这种情景:,面试官:用过单例吗?,你:用过。,面试官:如何实现一个线程安全的懒汉式单例,你:DCL。,面试官:DCL 可以保证线程绝对安全吗?,你:加 Volatile。,面试官满意的点点头。通常情况下,面试中这个问题聊到这里也就结束了。,但这个问题,还有一些可挖掘的内容。我们顺着单例的代码继续往下挖:,如果不加 Volatile,会有什么问题呢?问题就出现在下面这行代码:,上面这行代码看起来也平平无奇呀,就是一个赋值操作,还能整什么幺蛾子呢?我们只写了一行代码,但 JVM 则需要做好几步操作。那 JVM 究竟干了啥呢?大概也许可能差不多就是把大象给放冰箱里了。,Java 代码中的一条赋值语句,到了 JVM 指令层面大概分三步:,下面通过字节码来一探究竟,为了简化问题,我们替换成下面的代码:,编译以后,通过 javap -v 命令,或者 IDEA 中的 JClassLib 插件可以看到如下图所示的内容:,2023030520475946dea0e51f56cb84e622246942c765d51d4d15742,通过上面的字节码信息,可以更加清楚的看到上面提到的那三个步骤:,到这里,问题就比较明了了。重排的问题会发生在第 2 和 3 步。因为先初始化还是先把对象的内存地址赋值给 o,并没有必然的前后制约关系。因此,这类的指令在某些情况下会被重排序。,单线程下,这种重排序完全没有问题。但是多线程的场景下,就有可能出问题:A 线程进入到 instance = new Singleton(); 后,由于指令重排,在 init 之前,将地址给了 o。此时 B 线程来了,发现 instance 不为 null,于是直接拿去用了,然而此时 instance 并没有初始化,只是个半成品。所以,当 B 拿到 instance 进行操作的时候就会出现问题了。,因此,instance 需要使用 volatile 来修饰,从而禁止进行指令重排。,到这里,你可能要说了,我用单例不加 volatile,这么长时间了也没遇到你说的重排序问题。你怎么证明「重排序」的存在呢?好问题,下面咱们通过一个小例子来验证一下重排序是否真的存在。,代码很简单,就是几个赋值操作,但却很巧妙。x、y、a、b 初始都为 0,两个线程分别给 a、x 和 b、y 赋值,线程 one 先让 a = 1,然后再让 x = b;two 线程先让 b = 1,然后再让 y = a。,假如不发生重排序,那么以上程序只会有下面六种可能:,202303052051102304f2d8924215642030445d59da0991f36320904,也就是说,在没有重排序的情况下,不可能出现 x、y 同时为 0 的情况。而如果 x、y 同时为 0 了,那么一定是出现了下面六种情况中的一种,既发生了重排。,20230305204801038a08113e0d3c2920965590c962d436afe80b731,运行程序,经过漫长的等待,得到了如下的输出:,202303052048016941f7177e6ba39f1734930af3b75753477b1e773,可以看到,在执行了五十多万次以后,我们终于捕捉到了一次重排序。发生这种情况的几率很低,所以你就算没有用 volatile 大概率不会有问题,但我们在今后还是要合理的使用 volatile。,聊完指令重排,接下来聊聊内存可见。这次我们直接上代码:,代码很简单,主线程内开启一个子线程,子线程中一个 while 循环,当 flag 为 false 时,结束循环。flag 初始值为 true,一秒钟后,被主线程设置为 false。,按照上面这个逻辑,子线程应该会在程序启动一秒后停止。然而,当你运行程序后会发现,这个程序就像吃了炫迈一样,根本停不下来。,这说明主线程对 flag 的修改,子线程并没有感知到。我们修改一下程序:,为 flag 加上 volatile 修饰符,再次运行,你会发现程序运行后,很快(大概一秒钟)就停止了。这是为啥?是炫迈的药劲儿过了吗?,哈哈,当然不是。为了更好的性能,线程都有自己的缓存(CPU 中的高速缓存),我们称之为工作内存或者本地内存。还有一块公共内存,我们叫它主从吧。它们的结构大致如下图所示:,2023030520480187a7a2f568737b7f76e149f9884986751e6b0b838,主存中定义了一个 flag 变量,每个线程读取它的时候,为了更好的性能会在线程本地缓存一份它的副本。读取的时候也是优先读取本地副本的值。当 flag 被 volatile 修饰后,每次被修改,都会让其他线程中的副本失效,从而必须去主存中读取最新的值。所以,在使用了 volatile 后,子线程能够立即感知到 flag 的变化,从而停止。,上图简化了线程(CPU)的缓存结构,其完整结构如下图所示:,20230305204802176ce1880f901cc5884614fb34084f1f9bf8bd196,现代 CPU 共有三级缓存,分别为:L1、L2 和 L3。CPU 中的每个核心都有自己的 L1 和 L2,而一颗 CPU 中的多个核心会共享 L3。,Volatile 的意思是,易变的,动荡不定的,反复无常的。volatile 的作用就是告诉 JVM,被我修饰的变量它非常善变,你要给我盯好了,一旦有风吹草动要立马通知大家;另外,你不要自作聪明的调整它的位置(为了性能重排序),它可是说翻脸就翻脸的主儿。,最后,留一个小问题:内存可见性的那个程序中,就算 flag 没有被 volatile 修饰,线程顶多不是第一时间读到 flag 的修改,但也不应该一直读不到呀,这是为啥?这太反直觉了!

© 版权声明

相关文章