你是这样的volatile,出乎意料

本文深入探讨了Java中的volatile关键字,解释了其保证可见性和有序性的机制,以及为何不能保证原子性。volatile通过禁止特定类型的编译器和处理器重排序来实现其内存语义,利用内存屏障保证了数据一致性。在X86平台上,volatile通过lock指令实现,既保证了可见性也保证了原子性。对于开发者而言,理解volatile的工作原理对于写出高效且线程安全的代码至关重要。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

volatile是什么

volatile是Java的一个关键字。

它提供的一种轻量级同步机制, 能够保证可见性和有序性,但是不能保证原子性。

可见性

对于volatile的可见性,先看看这段代码的执行

 

  • flag 默认为 true

  • 创建一个线程A去判断 flag 是否为 true ,如果为 true 循环执行 i++ 操作

  • 两秒后,创建另一个线程B将 flag 修改为 false

  • 线程A没有感知到 flag 已经被修改成 false 了,不能跳出循环

这相当于啥呢?

相当于你的女神和你说,你好好努力,年薪百万了就嫁给你,你听了之后,努力赚钱。3年之后,你年薪百万了,回去找你女神,结果发现你女神结婚了,她结婚的消息根本没有告诉你!难不难受?

女神结婚可以不告诉你,可是Java代码中的属性都是存在内存中,一个线程的修改为什么另一个线程为什么不可见呢?

这就不得不提到Java中的内存模型了,Java中的内存模型,简称JMM,JMM定义了线程和主内存之间的抽象关系,定义了线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

注意!JMM是一个屏蔽了不同操作系统架构的差异的抽象概念,只是一组Java规范。

了解了JMM,现在我们再回顾一下文章开头的那段代码,为什么线程B修改了flag线程A看到的还是原来的值呢?

  • 因为线程A复制了一份刚开始的 flage=true 到本地内存,之后线程A使用的 flag 都是这个复制到本地内存的flag。

  • 线程B修改了 flag 之后,将flag的值刷新到主内存,此时主内存的flag值变成了 false 。

  • 线程A是不知道线程B修改了 flag ,一直用的是本地内存的 flag = true 。

那么,如何才能让线程A知道flag被修改了呢?或者说怎么让线程A本地内存中缓存的flag无效, 实现线程间可见 呢?

用volatile修饰flag就可以做到:

 

我们可以看到,用volatile修饰flag之后,线程B修改flag之后线程A是能感知到的,说明了volatile保证了线程同步之间的可见性。

重排序

在阐述volatile有序性之前,需要先补充一些关于重排序的知识。

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

为什么要有重排序呢?简单来说,就是为了提升执行效率。为什么能提升执行效率呢?我们看下面这个例子:

 

可以看到重排序之后CPU实际执行省略了一个读取和写回的操作,也就间接的提升了执行效率。

有一点必须强调的是,上图的例子只是为了让读者更好的理解为什么重排序能提升执行效率,实际上Java里面的重排序并不是基于代码级别的,从代码到CPU执行之间还有很多个阶段,CPU底层还有一些优化,实际上的执行流程可能并不是上图的说的那样。不必过于纠结于此。

重排序可以提高程序的运行效率,但是必须遵循as-if-serial语义。as-if-serial语义是什么呢?简单来说,就是不管你怎么重排序,你必须保证不管怎么重排序,单线程下程序的执行结果不能被改变。

有序性

上面我们已经介绍了Java有重排序情况,现在我们再来聊一聊volatile的有序性。

先看一个经典的面试题:为什么DDL(double check lock)单例模式需要加volatile关键字?

因为 singleton = new Singleton() 不是一个原子操作,大概要经过这几个步骤:

  • 分配一块内存空间

  • 调用构造器,初始化实例

  • singleton 指向分配的内存空间

实际执行的时候,可能发生重排序,导致实际执行步骤是这样的:

  • 申请一块内存空间

  • singleton 指向分配的内存空间

  • 调用构造器,初始化实例

在 singleton 指向分配的内存空间之后, singleton 就不为空了。但是在没有调用构造器初始化实例之前,这个对象还处于 半初始化状态 ,在这个状态下,实例的属性都还是默认属性,这个时候如果有另一个线程调用 getSingleton() 方法时,会拿到这个半初始化的对象,导致出错。

而加volatile修饰之后,就会禁止重排序,这样就能保证在对象初始化完了之后才把 singleton 指向分配的内存空间,杜绝了一些不可控错误的产生。volatile提供了happens-before保证,对volatile变量的写入happens-before所有其他线程后续对的读操作。

实现原理

从上面的DDL单例用例来看,在并发情况下,重排序的存在会导致一些未知的错误。而加上volatile之后会防止重排序,那volatile是如何禁止重排序呢?

为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:

总结来说就是:

  • 第二个操作是volatile写,不管第一个操作是什么都不会重排序

  • 第一个操作是volatile读,不管第二个操作是什么都不会重排序

  • 第一个操作是volatile写,第二个操作是volatile读,也不会发生重排序

如何保证这些操作不会发送重排序呢?就是通过插入内存屏障保证的,JMM层面的内存屏障分为读(load)屏障和写(Store)屏障,排列组合就有了四种屏障。对于volatile操作,JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障

  • 在每个volatile写操作的后面插入一个StoreLoad屏障

  • 在每个volatile读操作的后面插入一个LoadLoad屏障

  • 在每个volatile读操作的后面插入一个LoadStore屏障

上面的屏障都是JMM规范级别的,意思是,按照这个规范写JDK能保证volatile修饰的内存区域的操作不会发送重排序。

在硬件层面上,也提供了一系列的内存屏障来提供一致性的能力。拿X86平台来说,主要提供了这几种内存屏障指令:

  • lfence指令:在lfence指令前的读操作当必须在lfence指令后的读操作前完成,类似于读屏障

  • sfence指令:在sfence指令前的写操作当必须在sfence指令后的写操作前完成,类似于写屏障

  • mfence指令:在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成,类似读写屏障。

JMM规范需要加这么多内存屏障,但实际情况并不需要加这么多内存屏障。

以我们常见的X86处理器为例,X86处理器不会对 读-读 、 读-写 和 写-写 操作做重排序,会省略掉这3种操作类型对应的内存屏障,仅会对 写-读 操作做重排序。

所以volatile 写-读 操作只需要在volatile写后插入StoreLoad屏障。在《The JSR-133 Cookbook for Compiler Writers》中,也很明确的指出了这一点:

 

而在x86处理器中,有三种方法可以实现实现StoreLoad屏障的效果,分别为:

  • mfence指令:上文提到过,能实现全能型屏障,具备lfence和sfence的能力。

  • cpuid指令:cpuid操作码是一个面向x86架构的处理器补充指令,它的名称派生自CPU识别,作用是允许软件发现处理器的详细信息。

  • lock指令前缀:总线锁。lock前缀只能加在一些特殊的指令前面。

实际上HotSpot关于volatile的实现就是使用的lock指令,只在volatile标记的地方加上带lock前缀指令操作,并没有参照JMM规范的屏障设计而使用对应的mfence指令。

加上 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp JVM参数再次执行main方法,在打印的汇编码中,我们也可以看到有一个 lock addl $0x0,(%rsp) 的操作。

在源码中也可以得到验证:

lock addl $0x0,(%rsp) 后面的 addl $0x0,(%rsp) 其实是一个空操作。add是加的意思,0x0是16进制的0,rsp是一种类型寄存器,合起来就是把寄存器的值加0,加0是不是等于什么都没有做?

这段汇编码仅仅是lock指令的一个载体而已。其实上文也有提到过,lock前缀只能加在一些特殊的指令前面,add就是其中一个指令。

至于Hotspot为什么要使用lock指令而不是mfence指令,按照我的理解,其实就是省事,实现起来简单。

因为lock功能过于强大,不需要有太多的考虑。而且lock指令优先锁缓存行,在性能上,lock指令也没有想象中的那么差,mfence指令更没有想象中的好。所以,使用lock是一个性价比非常高的一个选择。而且,lock也有对可见性的语义说明。

在《IA-32架构软件开发人员手册》的指令表中找到lock:

lock指令的实现原理和细节 我不打算在这里深入阐述 ,这很容易陷入堆砌技术术语中,而且也超出了本文的范围,有兴趣的可以去看看《IA-32架构软件开发人员手册》。

我们只需要知道lock的这几个作用就可以了:

  • 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。

  • 禁止该指令与前面和后面的读写指令重排序。

  • 把写缓冲区的所有数据刷新到内存中。

总结来说,就是lock指令既保证了可见性也保证了原子性。

重要的事情再说一遍,是lock指令既保证了可见性也保证了原子性,和什么缓冲一致性协议啊,MESI什么的没有一点关系。

为了不让你把缓存一致性协议和JMM混淆,在前面的文章中,我特意没有提到过缓存一致性协议,因为这两者本不是一个维度的东西,存在的意义也不一样。 这一部分,我们下次再聊。

总结

全文重点是围绕volatile的可见性和有序性展开的,其中花了不少的部分篇幅描述了一些计算机底层的概念,对于读者来说可能过于无趣,但如果你能认真看完,我相信你或多或少也会有一点收获。

不去深究,volatile只是一个普通的关键字。深入探讨,你会发现volatile是一个非常重要的知识点。volatile能将软件和硬件结合起来,想要彻底弄懂,需要深入到计算机的最底层。但如果你做到了。你对Java的认知一定会有进一步的提升。

只把眼光放在Java语言,似乎显得非常局限。发散到其他语言,C语言,C++里面也都有volatile关键字。我没有看过C语言,C++里面volatile关键字是如何实现的,但我相信底层的原理一定是相通的。

它提供的一种轻量级同步机制, 能够保证可见性和有序性,但是不能保证原子性。

<think>好的,我现在需要帮助用户理解volatile关键字的含义和用法,以及它的作用和使用场景。首先,我要回忆一下相关的知识,并结合提供的引用内容来组织回答。 用户的问题是关于编程中的volatile关键字,所以需要覆盖它在不同语言中的通用概念,但可能具体例子会提到C++和Java,因为引用内容中提到了这些。首先,volatile的主要作用是防止编译器优化,确保变量直接从内存读取而不是寄存器,这对于多线程或硬件访问的场景很重要。 根据引用[1],volatile告诉编译器变量可能被未知因素更改,比如操作系统、硬件或其他线程,因此编译器不应进行激进的优化。这涉及到易变性和不可优化性。然后引用[2]提到顺序性,即volatile变量的操作前后不能重排序,这保证了指令的执行顺序。 接着,引用[3]解释了volatile的三个特性:易变、不可优化、顺序性,并提到其设计背景,可能需要简要提及。对于Java,引用[4]和[5]指出volatile保证有序性和可见性,但不保证原子性,这里需要区分不同语言中的差异。 用户可能想知道何时使用volatile。常见场景包括多线程共享变量、硬件寄存器访问、信号处理等。同时需要指出其局限性,比如在C++中不保证原子性,而Javavolatile提供了更强的保证,但也不是万能。 需要组织回答结构:先定义volatile,然后作用分点说明,再使用场景,最后注意事项。注意引用对应的引用编号,比如[^1]、[^2]等。同时生成相关问题,可能涉及与原子变量对比、不同语言的差异、错误使用案例等。 需要确保回答清晰,避免技术错误,比如区分C++和Java中的volatile差异。可能用户会混淆这些,所以需要明确说明。此外,代码示例可能帮助理解,比如在C++中访问硬件寄存器的例子,或者在Java中的标志位使用。</think>### Volatile关键字的含义与作用 **volatile** 是编程语言中的类型修饰符,用于声明变量可能被“意外修改”,强制编译器/运行时环境避免对其进行优化。其核心作用体现在三个方面: #### 一、核心特性 1. **易变性** volatile变量每次访问时都直接从内存读取,而非使用寄存器中的缓存值。适用于硬件寄存器访问或共享内存场景[^1]。 ```c++ volatile int* hardware_reg = (volatile int*)0xFFFF0000; // 直接读取硬件寄存器 ``` 2. **不可优化性** 编译器不得优化掉对volatile变量的操作(如删除“冗余”读取或写入指令)[^3]。 ```c++ volatile bool flag = false; while (!flag) {} // 循环条件不会被优化为一次性判断 ``` 3. **顺序性(部分保证)** 在C/C++中,volatile变量的操作具有顺序性:其前的操作必须完成,其后的操作不能提前[^2]。 Java则通过volatile实现更严格的内存屏障(happens-before规则)[^4]。 #### 二、典型使用场景 1. **多线程共享标志位** 用于轻量级线程通信(如终止信号): ```java // Java示例 private volatile boolean isRunning = true; public void stop() { isRunning = false; } ``` 2. **硬件访问** 直接操作内存映射的硬件寄存器(如嵌入式开发)[^1]。 3. **信号处理** 信号处理函数修改的变量需声明为volatile,避免主程序读取旧值。 #### 三、注意事项 1. **非原子性** volatile不保证复合操作(如`i++`)的原子性,需配合锁或原子类(如Java的`AtomicInteger`)[^5]。 2. **语言差异** - **C/C++**:仅阻止编译器优化,不保证多线程可见性(需结合内存屏障)。 - **Java**:严格遵循happens-before规则,提供跨线程可见性[^4]。 3. **性能影响** 频繁访问volatile变量可能降低性能(绕过缓存),需谨慎使用。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值