type
status
date
slug
summary
tags
category
icon
password
Property
Jul 19, 2023 05:34 AM
相比Sychronized(重量级锁),volatile提供了另一种解决可见性和有序性问题的方案。注意,Volatile无法根本解决原子性问题!
notion image

带着BAT大厂的面试问题去理解volatile

  • volatile关键字的作用是什么?
  • volatile能保证原子性吗?
  • 之前32位机器上共享的long和double变量的为什么要用volatile? 现在64位机器上是否也要设置呢?
  • i++为什么不能保证原子性?
  • volatile是如何实现可见性的? 内存屏障。
  • volatile是如何实现有序性的? happens-before等
  • 说下volatile的应用场景?

一、volatile的作用详解

1、防重排序

JMM-Java虚拟机模型在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,而一旦使用Volatile修饰,JMM 会提供内存屏障来阻止编译后的字节码在翻译成机器码的时候的指令重排序。
DCL,双重检查加锁(DCL)的方式来实现并发环境下的单例模式。
实例化一个对象需要三个步骤:
  • 分配内存空间
  • 初始化对象
  • 将内存空间的地址赋值给对应的引用
由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:
  • 分配内存空间
  • 将内存空间的地址赋值给对应的引用
  • 初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果,这就造成了构造方法的溢出。而Volatile关键字可以让我们禁止底层编译后的代码转译为CPU指令的时候可能出现的JMM对指令重排序。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。另一方面,我们知道JMM为了保证程序的有序性,在JMM中存在着一个happer-Before的概念原则,HappenBefore规则中有一条是 volatile 变量规则,对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。这里以Pdai的Demo为例:
根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系:
  • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
  • 根据 volatile 规则:2 happens-before 3。
  • 根据 happens-before 的传递性规则:1 happens-before 4。
notion image
因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。
JMM 提供了内存屏障阻止这种重排序。Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
JMM 会针对编译器制定 volatile 重排序规则表。
notion image
" NO " 表示禁止重排序。
为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
notion image
notion image

2、保证可见性

volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现的。
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题,我们看下下面的例子,就可以知道其作用:
volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。可见性的实现是通过内存屏障实现的,内存屏障在阻止指令重排序之后,会提供lock 前缀的指令,来让操作该对象内存地址的指令先去刷一遍主存。
大厂常问问题:i++为什么不能保证原子性?
应该能看出,volatile是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++其实是一个复合操作,包括三步骤
  • 读取i的值。
  • 对i加1。
  • 将i的值写回内存。
volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。
以一段代码来看Volatile关键字:
通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:
lock 前缀的指令在多核处理器下会引发两件事情:
  • 将当前处理器缓存行的数据写回到系统内存。
  • 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
这样一来,当CPU与内部缓存数据做交换的时候,他会嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
 
💡
有关Volatile关键字的问题,欢迎您在底部评论区留言,一起交流~
 
 
JUC技术篇(五):Synchronized锁JUC技术篇(七):Final关键字
fntp
fntp
多一点兴趣,少一点功利
公告
type
status
date
slug
summary
tags
category
icon
password
Property
Sep 5, 2023 06:04 AM
📝 博客只为了记录我的学习生涯
😎 我的学习目标是成为一名极客
🤖 我热爱开源当然我也拥抱开源
💌 我期待能收到你的Email留言
📧 我的邮箱:stickpoint@163.com
欢迎交流~