大家好,我是王有志。关注王有志,一起聊技术,聊游戏,聊在外漂泊的生活。

今天我们学习并发编程中另一个重要的关键字volatile,虽然面试中它的占比低于synchronized,但依旧是不可忽略的内容。

关于volatile,我收集到了8个常见考点,围绕应用,特点和实现原理。

  1. volatile有什么作用?
  2. 为什么多线程环境中会出现可见性问题?
  3. synchronized和volatile有哪些区别?
  4. 详细描述volatile的实现原理(涉及内存屏障)。
  5. volatile有哪些特性?它是如何保证这些特性的?
  6. volatile保证线程间变量的可见性,是否意味着volatile变量就是并发安全的?
  7. 为什么方法中的变量不需要使用volatile?
  8. 重排序是如何产生的?

本文从volatile应用开始,接着从源码角度分析volatile的实现,通过对原理的剖析尝试解答以上问题。

volatile是什么

synchronized一样,volatile是Java的提供的用于并发控制的关键字,不过它们之间也有比较明显的差异。

首先是使用方式:

  • synchronized能够修饰方法和代码块

  • volatile只能修饰成员变量

能力上volatile也更“弱”一些:

  • 保证被修饰变量的可见性

  • 禁止被修饰变量发生指令重排

我们稍微修改关于线程你必须知道的8个问题(上)中可见性问题的代码,使用volatile修饰变量flag

private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) {
}
System.out.println("线程:" + Thread.currentThread().getName() + ",flag状态:" + flag);
}, "block_thread").start();
TimeUnit.MICROSECONDS.sleep(500);
new Thread(() -> {
flag = false;
System.out.println("线程:" + Thread.currentThread().getName() + ",flag状态:" + flag);
}, "change_thread").start();
}

不难发现,block_thread解脱了,说明对flag的修改被其它线程“看见了”,这就是volatile保证可见性的表现。

接着修改《深入理解JMM和Happens-Before》中指令重排带来有序性问题的代码,同样使用volatile修饰变量instance

public class Singleton {

	static volatile Singleton instance;

	public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

多次实验后发现,不会再获取到未经初始化的instacne对象了,这就是volatile禁止指令重排的表现。

Tips:再次强调,Happens-Before描述的是行为结果间的关系

volatile的实现

以下内容基于JDK 11 HotSpot虚拟机,以X86架构的实现为主,会与ARM架构的实现对比。选择这些的原因很简单,它们是各自领域的“顶流”

volatile使用简单,功能易理解,但往往简单的背后隐藏着复杂的实现。和分析synchronized的过程一样,从字节码开始,再到JVM的实现,力求从底层串联volatile,内存屏障与硬件之间的关系。

volatile在不同架构下的实现差异较大,看个例子,X86架构的templateTable_x86getfield_or_static方法的实现:

void TemplateTable::getfield_or_static(int byte_no, bool is_static, RewriteControl rc) {
// 省略类型判断的代码
__ bind(Done);
// [jk] not needed currently
// volatile_barrier(Assembler::Membar_mask_bits(Assembler::LoadLoad | Assembler::LoadStore));
}

ARM架构的templateTable_armgetfield_or_static方法的实现:

void TemplateTable::getfield_or_static(int byte_no, bool is_static, RewriteControl rc) {
// 省略类型判断的代码
__ bind(Done);
if (gen_volatile_check) {
Label notVolatile;
__ tbz(Rflagsav, ConstantPoolCacheEntry::is_volatile_shift, notVolatile);
volatile_barrier(MacroAssembler::Membar_mask_bits(MacroAssembler::LoadLoad | MacroAssembler::LoadStore), Rtemp);
__ bind(notVolatile);
}
}

X86架构下不需要对volatile类型进行特殊处理,而ARM架构下,添加内存屏障保证了与X86架构一致的效果。这个例子是为了展示规范在不同CPU架构上的实现差异,另外提醒大家不要误将X86的实现当成标准,X86架构对重排序的约束更强,能“天然”实现JMM规范中的某些要求,所以JVM层面的实现看起来会非常简单。

上面一直在说模板解释器,不过后面的内容我要用字节码解释器bytecodeInterpreter了。为什么不用模板解释器?因为模板解释器离OrderAccess太“远”了,而OrderAccess中内存屏障的详细解释是理解volatile原理的关键。

不过,我们还是先花点时间了解下X86架构下内存屏障assembler_x86的实现:

enum Membar_mask_bits {
StoreStore = 1 << 3,
LoadStore = 1 << 2,
StoreLoad = 1 << 1,
LoadLoad = 1 << 0
}; void membar(Membar_mask_bits order_constraint) {
if (os::is_MP()) {
if (order_constraint & StoreLoad) {
int offset = -VM_Version::L1_line_size();
if (offset < -128) {
offset = -128;
}
lock();
addl(Address(rsp, offset), 0);
}
}
}

使用位掩码定义内存屏障的枚举,分析偏向锁的时候就见到过位掩码的使用,重点在membar方法中最后两行代码:

lock();
addl(Address(rsp, offset), 0);

插入了lock addl指令,它是X86架构下内存屏障实现的关键,orderAccess_linux_x86中的实现也是如此。

Tipsmembar方法是Memory Barrier(内存屏障)的缩写,另外也有称为Memory Fence(内存栅栏)的,或者直接称为fence,反正屏障,栅栏什么的乱七八糟的。

从字节码开始

使用双检锁单例模式生成的字节码:

public class com.wyz.keyword.keyword_volatile.Singleton

  static volatile com.wyz.keyword.keyword_volatile.Singleton instance;
flags:(0x0048) ACC_STATIC, ACC_VOLATILE public static com.wyz.keyword.keyword_volatile.Singleton getInstance();
Code:
stack=2, locals=2, args_size=0
24: putstatic #7 // Field instance:Lcom/wyz/keyword/keyword_volatile/Singleton;
37: getstatic #7 // Field instance:Lcom/wyz/keyword/keyword_volatile/Singleton;

我们看字节码中的关键部分:

  • 标记volatile变量的ACC_VOLATILE

  • 写入/读取静态变量时的指令getstaticputstatic

Java 11虚拟机规范第4章中是这样描述ACC_VOLATILE的:

ACC_STATIC 0x0008 Declared static.

ACC_VOLATILE 0x0040 Declared volatile; cannot be cached.

虚拟机规范要求volatile修饰的变量不能被缓存。我们知道,CPU高速缓存是带来可见性问题的“罪魁祸首”,不能被缓存就意味着杜绝了可见性问题,但并不意味着不使用缓存。

Java 11虚拟机规范第6章中也描述了getstatic指令的作用:

Get static field from class.

putstatic指令的作用:

Set static field in class.

可以大致猜到JVM的实现volatile的方式,JVM中定义getstatic/putstatic指令对应的方法,并在方法中判断变量是否被标记为ACC_VOLATILE,然后进行特殊逻辑处理。

Tips

  • 0x0048是ACC_STATICACC_VOLATILE结合的结果;

  • static变量,读取和写入是getfieldputfield两条指令。

字节码解释器的实现

这部分我们只看putstatic的源码,前面模板解释器的部分也大致分析了getstatic,剩下的就留给大家自行分析了。

putstatic的实现在bytecodeInterpreter中第2026行:

CASE(_putfield):
CASE(_putstatic):
{
if ((Bytecodes::Code)opcode == Bytecodes::_putstatic) {
// static的处理方式
} else {
// 非static的处理方式
}
// ACC_VOLATILE -> JVM_ACC_VOLATILE -> is_volatile()
if (cache->is_volatile()) {
// volatile变量的处理方式
if (tos_type == itos) {
obj->release_int_field_put(field_offset, STACK_INT(-1));
}else {
// 省略了超多的类型判断
}
OrderAccess::storeload();
} else {
// 非volatile变量的处理方式
}
}

逻辑很简单,判断变量的类型,然后调用OrderAccess::storeload(),保证volatile变量的特性实现。

JVM的内存屏障

JVM在不同操作系统,CPU架构的基础上,构建了一套符合JMM规范的内存屏障,屏蔽了不同架构间的差异,实现了内存屏障的语义一致性。这部分重点解释JVM实现的4种主要内存屏障和介绍X86架构的实现以及硬件差异导致的不同。

来看orderAccess中对4种内存屏障的解释:

Memory Access Ordering Model

LoadLoad: Load1(s); LoadLoad; Load2

Ensures that Load1 completes (obtains the value it loads from memory) before Load2 and any subsequent load operations. Loads before Load1 may not float below Load2 and any subsequent load operations.

StoreStore: Store1(s); StoreStore; Store2

Ensures that Store1 completes (the effect on memory of Store1 is made visible to other processors) before Store2 and any subsequent store operations.  Stores before Store1 may not float below Store2 and any subsequent store operations.

LoadStore: Load1(s); LoadStore; Store2

Ensures that Load1 completes before Store2 and any subsequent store operations.  Loads before Load1 may not float below Store2 and any subsequent store operations.

StoreLoad: Store1(s); StoreLoad; Load2

Ensures that Store1 completes before Load2 and any subsequent load operations.  Stores before Store1 may not float below Load2 and any subsequent load operations.

努力翻译下对4种主要的内存屏障的描述:

  • LoadLoad,指令:Load1; LoadLoad; Load2。确保Load1在Load2及之后的读操作前完成读操作,Load1前的Load指令不能重排序到Load2及之后的读操作后;

  • StoreStore,指令:Store1; StoreStore; Store2。确保Store1在Store2及之后的写操作前完成写操作,且Stroe1写操作的结果对Store2可见,Store1前的Store指令不能重排序到Store2及之后的写操作后;

  • LoadStore,指令:Load1; LoadStore; Store2。确保Load1在Store2及之后的写操作前完成读操作,Load1前的Load指令不能重排序到Store2及之后的写操作后;

  • StoreLoad:指令:Store1; StoreLoad; Load2。确保Store1在Load2及之后的Load指令前完成写操作,Store1前的Store指令不能重排序到Load2及之后的Load指令后。

虽然翻译过来有些拗口,但理解起来并不困难,建议小伙伴们认真阅读这部分注释(包括后面的内容)。

注释中可以看出,内存屏障的保证了程序的有序性,以在StoreStore中提到了一写可见性的保证。

X86架构的内存屏障实现

Linux平台X86架构的实现orderAccess_linux_x86中对内存屏障的定义:

inline void OrderAccess::loadload()   { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore() { compiler_barrier(); }
inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::acquire() { compiler_barrier(); }
inline void OrderAccess::release() { compiler_barrier(); }

实现非常简单,只有两个核心方法compiler_barrierfence

static inline void compiler_barrier() {
__asm__ volatile ("" : : : "memory");
} inline void OrderAccess::fence() {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
compiler_barrier();
}

上述代码是GCC的扩展内联汇编形式,简单解释下compiler_barrier方法中的内容:

  • asm,插入汇编指令;

  • volatile,禁止优化此处的汇编指令;

  • meomory,汇编指令修改了内存,要重新读取内存数据。

接着是支撑storeload屏障的fence方法,和templateTable_x86的实现一样,核心是lock addl指令。lock前缀指令可以理解为CPU指令级的锁,对总线和缓存加锁,主要有两个作用:

  • Lock前缀指令会引起处理器缓存回写到内存

  • 处理器缓存回写到内存会导致其他处理器的缓存无效

实际上X86架构提供了内存屏障指令lfence,sfence,mfence等,但为什么不使用内存屏障指令呢?原因在fence的注释中:

mfence is sometimes expensive

mfence指令在性能上的开销较大。

好了,到这里我们已经能够得到X86架构下实现volatile特性的原理:

  • JVM的角度看,内存屏障提供了可见性和有序性的保证

  • X86的角度看,voaltile指令禁止重排序,Lock指令引起缓存失效和回写

Tipsfence方法中AMD64和X86的处理略有差异,关于它们的渊源,可以参考pansz大佬的知乎

其他架构实现差异的原因

前面看到,X86架构和ARM架构的模板解释器中,getfield_or_static方法在使用内存屏障上产生了分歧,ARM通过内存屏障到达了“罗马”,而X86出生在“罗马”。

不难想到产生这种差异的原因,CPU架构对重排序的约束不同,导致JVM需要使用不同的处理方式达到统一的效果。关于CPU允许的重排序,我“搬运”了一张图:

该图来自介绍CPU缓存与内存屏障的经典文章《Memory Barriers:a Hardware View for Software Hackers》,虽然年代较为“久远”,但依旧值得阅读。原图中列标题是竖向,看起来并不方便,所以进行了简单的“视觉优化”。

从图中也可以看到,X86架构只允许“Stores Reordered After Loads”重排序,因此JVM中只对storeload进行了实现,至于其它屏障的特性则是由CPU自己保证的。

结语

volatile的内容太难写了,特性不难,源码也不难,但是讲内存屏障非常难。

说少了难以理解,说多了就“越界”,就成了写硬件的文章。因此在写硬件实现差异的策略是桌面端最常用的X86架构为主,并对比移动端最常用ARM架构的实现,尽量简短的解释volatile的实现。

实际上,内存屏障的部分还有acquirerelease两种单向屏障没有涉及到,大家可以自行了解。

那么,现在回到开始的题目中,相信你能够轻松的回答出前6道题目了吧?至于第8题,因为重排序的产生涉及到大量硬件内容,所以没有写,如果感兴趣的话,可以写个番外篇和大家分享我对重排序的理解,或者可以参考我在volatile题解中的回答。


好了,今天就到这里了,Bye~~

11.硬核的volatile考点分析的更多相关文章

  1. 全网最硬核 JVM TLAB 分析(单篇版不包含额外加菜)

    今天,又是干货满满的一天.这是全网最硬核 JVM 系列的开篇,首先从 TLAB 开始.由于文章很长,每个人阅读习惯不同,所以特此拆成单篇版和多篇版 全网最硬核 JVM TLAB 分析(单篇版不包含额外 ...

  2. Mybatis系列全解(六):Mybatis最硬核的API你知道几个?

    封面:洛小汐 作者:潘潘 2020 年的大疫情,把世界撕成几片. 时至今日,依旧人心惶惶. 很庆幸,身处这安稳国, 兼得一份安稳工. · 东家常讲的一个词:深秋心态 . 大势时,不跟风.起哄, 萧条时 ...

  3. 重磅硬核 | 一文聊透对象在 JVM 中的内存布局,以及内存对齐和压缩指针的原理及应用

    欢迎关注公众号:bin的技术小屋 大家好,我是bin,又到了每周我们见面的时刻了,我的公众号在1月10号那天发布了第一篇文章<从内核角度看IO模型的演变>,在这篇文章中我们通过图解的方式以 ...

  4. 全网最硬核 JVM 内存解析 - 1.从 Native Memory Tracking 说起

    个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判.如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 i ...

  5. Android版数据结构与算法(五):LinkedHashMap核心源码彻底分析

    版权声明:本文出自汪磊的博客,未经作者允许禁止转载. 上一篇基于哈希表实现HashMap核心源码彻底分析 分析了HashMap的源码,主要分析了扩容机制,如果感兴趣的可以去看看,扩容机制那几行最难懂的 ...

  6. 袋鼠云研发手记 | 数栈·开源:Github上400+Star的硬核分布式同步工具FlinkX

    作为一家创新驱动的科技公司,袋鼠云每年研发投入达数千万,公司80%员工都是技术人员,袋鼠云产品家族包括企业级一站式数据中台PaaS数栈.交互式数据可视化大屏开发平台Easy[V]等产品也在迅速迭代.在 ...

  7. Colder框架硬核更新(Sharding+IOC)

    目录 引言 控制反转 读写分离分库分表 理论基础 设计目标 现状调研 设计思路 实现之过五关斩六将 动态对象 动态模型缓存 数据源移植 查询表达式树深度移植 数据合并算法 事务支持 实际使用 展望未来 ...

  8. 程序员需要了解的硬核知识之CPU

    大家都是程序员,大家都是和计算机打交道的程序员,大家都是和计算机中软件硬件打交道的程序员,大家都是和CPU打交道的程序员,所以,不管你是玩儿硬件的还是做软件的,你的世界都少不了计算机最核心的 - CP ...

  9. 阿里P7整理“硬核”面试文档:Java基础+数据库+算法+框架技术等

    现在的程序员越来越多,大部分的程序员都想着自己能够进入大厂工作,但每个人的能力都是有差距的,所以并不是人人都能跨进BATJ.即使如此,但身在职场的我们一刻也不能懈怠,既然对BATJ好奇,那么就要朝这个 ...

  10. 硬核讲解 Jetpack 之 LifeCycle 源码篇

    前一篇 硬核讲解 Jetpack 之 LifeCycle 使用篇 主要介绍了 LifeCycle 存在的意义,基本和进阶的使用方法.今天话不多说,直接开始撸源码. 本文基于我手里的 android_9 ...

随机推荐

  1. win32 - 在cmd中禁用进程权限

    C:\Users\path>whoami /priv 在cmd中输入whoami /priv后将获得当前令牌(标准用户)的权限. C:\Users\path>tasklist /v /fo ...

  2. Taurus.MVC WebMVC 入门开发教程2:一个简单的页面呈现

    前言: 在上一篇中,我们了解了如何下载.配置和运行 Taurus.MVC WebMVC 框架. 现在,让我们开始编写一个简单的页面并进行呈现. 步骤1:创建控制器 首先,我们需要创建一个控制器来处理页 ...

  3. PostgreSQL中查看版本的几种方式

    PostgreSQL中查看版本的几种方式 1.SQL方式 1 2 3 4 5 6 7 8 9 10 postgres=# show server_version;  server_version -- ...

  4. Singularity容器

    """参考文档 https://apptainer.org/user-docs/master/build_a_container.html ""&qu ...

  5. 【LeetCode链表#11】环形链表II(双指针)

    环形链表II 力扣题目链接(opens new window) 题意: 给定一个链表,返回链表开始入环的第一个节点. 如果链表无环,则返回 null. 为了表示给定链表中的环,使用整数 pos 来表示 ...

  6. ABP开发需要用到的命令

    0.命令行在哪里执行? 在Visual Studio的"解决方案资源管理器"的解决方案或者项目上点鼠标右键,选择"在终端中打开". 1.安装abp的命令行 官网 ...

  7. 数据结构(三):舞伴配对问题(C++,队列)

    好家伙, 题目如下: 1.舞伴配对问题:假设在周末舞会上,男士们和女士们进入舞厅时,各自排成一队.跳舞开始时,依次从男队和女队的队头上各出一人配成舞伴. 2.若两队初始人数不相同,则较长的那一队中未配 ...

  8. OPPO 自研大规模知识图谱及其在数智工程中的应用

    导读:OPPO 知识图谱是 OPPO 数智工程系统小布助手团队主导.多团队协作建设的自研大规模通用知识图谱,目前已达到数亿实体和数十亿三元组的规模,主要落地在小布助手知识问答.电商搜索等场景. 本文主 ...

  9. C++ STL函数对象 仿函数

    1 //STL函数对象 仿函数 2 #include<iostream> 3 #include<string> 4 5 using namespace std; 6 7 8 / ...

  10. ACS 构词法 + 44个后缀 记忆方法

    抖音号:九词君-不用语法学好英语