首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

volatile关键字的实现原理深度解析

在Java语言规范中对volatile的定义如下:Java编程语言中允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁来确保单独获取这个变量。Java还提提供了volatile关键字,在某些情况下比锁更加方便。

volatile关键字可以说是java虚拟机中提供的最轻量级的同步机制,但它并不是锁。因此,在使用时,只有真正明白它的特性、原理才能正确的使用volatile。

1 volatile的概述

volatile可以说是java虚拟机中提供的最轻量级的同步机制。volatile不会引起线程上下文切换和调度,相比于锁更加轻量级,但它并不是锁。因此,在使用时,只有真正明白它的特性、原理才能正确的使用volatile。

1.1 volatile的特性

可见性:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

有序性:禁止进行指令重排序。

当程序执行到volatile变量的读或写时,在其前面的操作肯定全部已经执行完毕,且结果已经对后面的操作可见;在其后面的操作肯定还没有执行;

在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

原子性:对任意单个volatile变量的读/写具有原子性,但类似于i++这种复合操作不具有原子性。

1.2 volatile 的内存语义

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果。

volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。

volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

总结:

线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。

线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。

线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

2 volatile的底层实现

2.1 代码层面

代码层面的实现很简单,直接对一个变量使用volatile关键字修饰就表示该变量是一个volatile变量,具有了volatile的特性。

如下案例:

2.2 字节码层面

代码编译为字节码之后,对于volatile关键字有什么特别的实现吗?

首先去掉volatile关键字,使用jclasslib查看生成的字节码指令集,然后再加上volatile关键字,使用jclasslib查看生成的字节码指令集,就能发现volatile在字节码层面的实现。

实际上,加上volatile和不加volatile的区别很小,如下:

不加volatile:

加volatile:

实际上我们能够看到,它们在字节码层面的唯一区别就是对于字段表的access_flags-字段作用域的值不一样。

不加上volatile的字段作用域值为0x000a,查询字段访问修饰符表可知由:ACC_PRIVATE、ACC_STATIC两个字段访问标识符组成。

加上volatile的字段作用域值为0x004a,查询字段访问修饰符表可知由:ACC_PRIVATE、ACC_STATIC、ACC_VOLATILE三个字段访问标识组成。由此,我们可以认为,在字节码指令集层面,volatile的实现,仅仅是多加了一个ACC_VOLATILE字段访问标识符。

关于字节码,和jclasslib工具如果不熟悉的,可以看这两篇文章:Java的 Class(类)文件结构详解和Java的JVM字节码指令集详解。

2.3 JVM层面

JVM层面,根据JSR-133(即Java内存模型与线程规范)的要求,JVM层面实现为内存屏障(Memory Barrier)。

2.3.1 内存屏障的概述

内存屏障是什么?

内存屏障(memory barrier)是一种CPU指令。由于CPU指令可能会有重排序导致乱序执行,使用内存屏障指令之后,对该指令之前和之后的内存CPU读写内存的操作, 产生一种顺序的约束,能够确保一些特定操作执行的顺序,在一定范围内保证指令执行的有序性,组织CPU指令的乱序优化(重排序)。

对Java语言来说,内存屏障分为系统底层(CPU)的内存屏障和JVM的内存屏障。

2.3.1.1 系统底层(CPU)

系统底层(CPU)层面,不同的系统提供了不同的指令实现,x86/64系统架构中常见有内存屏障功能的指令有acquire、release、fence、lock

sfence:在sfence指令前的写操作当必须在sfence指令后的写操作前完成。

lfence:在lfence指令前的读操作当必须在lfence指令后的读操作前完成。

mfence:在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。

lock:该指令是一条前缀指令,通常lock可以与某些指令连用(ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令),它具有如下特性【来自Intel手册的总结】:

确保对内存的读-改-写操作原子执行,即确保lock后面的指令变成一个原子操作。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问共享内存,保证内存独占。很显然,这会带来昂贵的开销。从Pentium4、Intel Xeon及P6处理器开始,Intel使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低lock前缀指令的执行开销。这实际上也算一种CPU指令级别的同步。

不是内存屏障,但是具有内存屏障的功能,能够禁止该指令与之前和之后的读和写指令重排序。

如果lock后面的指令具有写操作,那么lock会导致把写缓冲区中的所有数据刷新到主存中,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据。

2.3.2 JVM层面

上面我们知道了x86/64系统架构的指令级别内存屏障的实现,但是不同硬件实现内存屏障的方式不同,Java为了实现跨平台性,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码,因此JVM定义了自己的“内存屏障”。

Java内存屏障主要有Load和Store两类:

对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据

对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

Java编译器会在生成指令序列时在适当的位置会插入内存屏障指令来禁止特定类型的指令在屏障前后重排序。Java内存模型采用保守的屏障插入策略,volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。

JVM层面提供的内存屏障的具体的要求如下:

在每个 volatile 写操作的前面插入一个StoreStore屏障(Store1,StoreStore,Store2)。该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。保证写操作刷新缓存的顺序。

在每个 volatile 写操作的后面插入一个 StoreLoad 屏障,(Store1; StoreLoad; Load2)。该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见。storeload屏障在几乎所有的现代多处理器中都需要使用,实际上volatile的写就是采用的Storeload屏障。StoreLoad屏障可以防止一个后续的load指令不正确的使用了Store1的数据,但是不能防止另一个处理器在相同内存位置写入一个新垃圾数据。

在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(Load1,Loadload,Load2)。该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。

在每个 volatile 读操作的后面插入一个 LoadStore 屏障,(Load1; LoadStore; Store2)。该屏障除了禁止了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其他线程 volatile 变量的写更新对 volatile 读操作的线程可见。

另外,JMM 针对编译器制定了 volatile 重排序规则表:

从表我们可以看出:

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

x86处理器仅会对写-读操作做重排序。X86不会对读-读,读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。

2.4 系统底层层面

上面我们知道了,我们常说的“内存屏障”其实是JVM层面的实现,并且我们还介绍了系统底层的指令级别实现方式。我们知道JVM的实现实际上是依赖了系统底层汇编语言的实现,那么从JVM字节码到系统底层指令这个过程是怎么实现的呢?系统底层又是采用哪种指令,sfence?lfence?mfence还是lock?来支持JVM规定的内存屏障呢?

我们前面在字节码层面已经知道,针对volatile修饰的字段,JVM并没有生成什么特殊字节码,仅仅是多了一加了一个访问修饰符。在我们的案例中,实际上volatile字段的和普通字段的读写就是通用的都是_putstatic和_getstatic字节码指令。那么是不是在这两个字节码指令的具体实现中对volatile修饰符做了什么特殊的操作呢?一起去看看就知道了!

这里我们就需要去看openjdk中的Hospot实现的C++源码了。关于volatile系统底层的实现,在Hospot源码中的bytecodeInterpreter.cpp文件中,这个文件又被称作“C++解释器”,被用来解析JVM字节码指令集。

我们在里面可以找到_putstatic和_getstatic的字节码指令的Hospot解释器的实现。

2.4.1 写volatile实现

字节码指令用于写入静态属性或者实例属性

在的实现中,能够找到关于字段是否是volatile修饰的判断,并且做了额外的处理:

cache->is_volatile(),表示如果变量i被volatile修饰,那么为true,接着给变量i的赋值,操作由release_xxx(类型)_field_put方法实现。

我们看到,内部调用了release_store方法,该方法在不同的系统环境中有不同的实现,我们来看看在linux_X86中的实现,该实现在orderAccess_linux_x86.inline.hpp()中:

我们可以看到第一个参数加了关键字volatile,这一这里的volatile属于C++的关键字,该关键字在C++中的作用如下:

volatile是一种类型修饰符,被volatile声明的变量表示随时可能发生变化,每次对变量的读取,都会从内存中重新加载。并且编译器对操作该变量的代码不再进行优化,比如不再使用乱序优化,实际上C++的volatile也发挥了内存屏障的作用。

在赋值之后,会调用方法,这其实就是JVM中一个的内存屏障- 的实现,该方法在同样在中能够找到,在旁边,我们还一起找到了其他JDK8定义的三个屏障的实现

storeload屏障(store,storeload, load)采用fence()方法实现,我们的案例已经看到了,对java中的volatile变量进行赋值之后,即写volatile(_putstatic),插入的就是这个屏障:

方法中,首先使用is_MP()判断是否是多核CPU,如果不是的话那就没啥事儿了,因为单线程没问题。如果是多核CPU的话,那就执行里面的内容,分为AMD64或者其它,一般都是其它实现,即storeload屏障由下面的指令实现:

实际上上面的写法叫做“内嵌汇编语法”,大概格式如下:

asm:是GCC关键字asm的宏定义,用于指示编译器在此插入(内嵌)汇编语句;

Instruction List:表示汇编模板,它包含汇编指令行。

Out:表示指定当前内联汇编语句的输出;

In:表示指定当前内联汇编语句的输入;

上面三者都可以为空!

Clobber:表示破坏描述,通知GCC当前内联汇编语句可能会对某些寄存器或内存进行修改,为了保证一致性,要求GCC做出相应处理。一般是输入、输出没有指定的情况下使用

我们再看storeload屏障的汇编语句:

_ volatile _:_ volatile _ 是GCC 关键字volatile 的宏定义(非C++的volatile关键字,但是作用基本一致),禁止禁止编译器对代码进行优化,即按原来的样子处理这这里的汇编,括号里面就是汇编指令;

memory:破坏描述符,告诉GCC 内存已经被修改,GCC会保证在此内联汇编之前,如果某个内存的内容被装入了寄存器,那么在这个内联汇编之后,如果需要使用这个内存处的内容;就会在这段指令之前,插入必要的指令将寄存器中的变量值先写回主存,指令之后读取时也会直接到这个内存处重新读取,而不是使用被存放在寄存器中的拷贝。

_  asm _ _ volatile _  ( " " : : : "memory"  ):创建一个编译器层的存储屏障(memory barrier),并将括号内的指令作为内存屏障,告诉编译器不要越过该屏障优化存储器的访问顺序,即禁止重排序。

addl $0,0(%%esp):表示将数值0加到esp寄存器中,而该寄存器指向栈顶的内存单元。加上一个0,esp寄存器的数值依然不变。即这是一条看起来无用的汇编指令,但是配合 lock前缀指令,可以将写操作的值同步到主存,配合前面的内存屏障,实现storeload屏障。插入storeload屏障后,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

另外,可以看到loadload屏障(load1,loadload, load2)和loadstore屏障(load,loadstore, store)都是采用acquire()方法实现:

插入该屏障之后,屏障之前的load操作完成之后,然后才能执行屏障之后的load操作,可以保证load操作的数据在下个store指令之前准备好。

storestore屏障(store1,storestore, store2)采用release()方法实现:

插入该屏障后,屏障之前的store操作执行完毕,然后才能执行屏障之后的store操作,保证store1写入的数据在执行store2时对其它CPU可见。

2.4.2 读volatile实现

用于读取静态或者实例属性,会将读取的结果放入栈顶中。

在的实现中,能够找到关于字段是否是volatile修饰的判断,并且做了额外的处理:

cache->is_volatile(),表示如果变量i被volatile修饰,那么为true,接着获取变量i的值,操作由xxx(类型) _field_acquire方法实现。

我们看到,内部调用了acquire方法,该方法在不同的系统环境中有不同的实现,我们来看看在linux中的实现,该实现在orderAccess_linux_x86.inline.hpp中:

我们可以看到第一个参数加了关键字volatile,这一这里的volatile属于C++的关键字,该关键字在C++中的作用如下:

volatile是一种类型修饰符,被volatile声明的变量表示随时可能发生变化,每次对变量的读取,都会从内存中重新加载。并且编译器对操作该变量的代码不再进行优化,比如不再使用乱序优化,这也就是内存屏障的作用。

这里我们可以看到读volatile就是使用的C++的volatile关键字控制的,并没有手动插入编译器屏障。我们也可以发现,实际上C++的volatile关键字和手动插入的编译器屏障【_  asm _ _ volatile _  ( " " : : : "memory"  )】效果是一致的,能够禁止重排序,同时能够获取到最新的值。

2.5 总结

volatile的实现如下:

代码层面:volatile关键字

字节码层面:ACC_VOLATILE字段访问标识符

JVM层面:JMM要求实现为内存屏障。

(Hospot)系统底层:

读volatile基于c++的volatile关键字,每次从主存中读取。

写volatile基于c++的volatile关键字和   指令的内存屏障,每次将新值刷新到主存,同时其他cpu缓存的值失效。

C++的volatile禁止对这个变量相关的代码进行乱序优化(重排序),也就具有内存屏障的作用了,另外Linux内核也可以手动插入内存屏障:。

2.5.1 JIT查看汇编指令

下面我们以JIT的角度看看volatile关键字对汇编指令的影响:

使用hsdis查看jit生成的汇编代码,可以找到如下代码:

3 不能保证复合操作的原子性

3.1 概述

由于volatile对所有线程立即可见,对volatile的写操作会立即反应到其它线程,因此基于volatile的变量的运算在并发下是安全的吗?这是错误的,原因是volatile所谓的其它线程立即知道,是其它线程在使用的时候会读内存然后load到自己工作内存,如果这时候其它线程进行了修改,本线程的volatile变量状态会被置为无效,会重新读取,但如果本线程的变量已经被读入执行栈帧,那么是不会重新读取的;那么两个线程都把本地工作内存内容写入主存的时候就会发生覆盖问题,导致并发错误。

虽然volatile其要求对变量的(read、load、use)、(assign、store、write)必须是连续出现,即以组的形式出现,但是这两组操作还是分开的。比如说,两个线程同时完成了第一组操作(read、load、use),但是还没进行第二组操作(assign、store、write),此时是没错的,然后两个线程开始第二组操作,这样最终其中一个线程的操作会被覆盖掉,导致数据的不准确。

3.2 案例

初始值 race = 0,10个线程同时执行 race++ 操作,每个线程都执行1000次,最终结果可能小于 10000。

原因是每个线程执行race++,简单的可以分为以下 3 个步骤:

线程从主内存读取最新的 race的值到执行引擎;

在执行引擎中把 race值加1;

线程工作内存把 race值刷新到主内存;

有可能某一时刻 2 个线程A、B在步骤 1 读取到的值都是 100,执行完步骤 2 得到的值都是 101,最后刷新了 2 次 101 保存到主内存。

因为 happens-before 中的 volatile 变量规则只规定了对一个变量的写操作 happens-before 后面对这个变量的读操作。所以中间的过程(从 Load 到 Store)是不安全的。

例如执行到步骤 2 时,线程B 对变量 i 进行了修改,但是线程 A 是不会感知的,因为线程A已经读去过了race的值。只有线程 A 完成本次循环,进行下一次读取时,由于可见性才会重新获取最新的新值,但此时A、B线程的值已经相互覆盖了。

下面来使用javap反编译race++来查看更详细的步骤:

上面的指令中,将“race++”分解成了4条字节码指令:

getstatic指令用于把race变量的值取到操作数栈顶,此时volatile可以关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd等指令的时候,其他线程可能已经把race的值加大了,而在操作栈顶的值就变成了过期的数据(注意操作数栈保存的只是值)。

iconst_1 用于将 int 常量 1 推到操作数栈。

iadd指令也只是把栈顶两个 int 型数值相加并将结果入栈,此时完成自增1。

putstatic表示为类的静态域赋值,所赋的值,就是iadd指令计算出来的值,所以putstatic指令执行的时候就可能把较小的、旧的race值同步回主内存之中。

在执行getstatic时,两条线程一定拿到的是最新的值,也就是相同的i,但是从后面的指令开始就有问题了,有可能出现首先两条线程A、B都执行了getstatic拿到了i的最新值,然后线程A自增并回写了i,此时主存的i确实是最新的,但此时B早已通过getstatic拿过了i,并不会再取一次,接着B又使用过期的值自增,这样造成了值的覆盖。

5 volatile的总结和使用

从上面的案例分析可知,由于volatile变量只能保证可见性,只能保证拿到的变量一定最新的,并且线程对volatile变量的一次操作只需要获取一次该变量,至于拿到的变量之后做了其他什么操作,或者该值是不是最新的值,volatile也没有办法保证,因此在不符合以下两条规则的运算场景中,仍然需要通过加锁保证原子性:

运算结果并不依赖变量的当前值或者能够确保只有单一的线程修改变量的值;因为如果依赖当前值,将是获取一计算一写入三步操作,这三步操作不是原子性的,而volatile 不保证原子性。

变量不需要与其他的状态变量共同参与不变约束。例如基本运算操作,就不是原子性的。

具体的应用总结起来,就是:“一次写入,到处读取”,某一线程负责更新变量,其他线程只读取变量(不更新变量),并根据变量的新值执行相应逻辑。例如:

状态标志:布尔状态标志,用于指示发生了一个重要的一次性事件。

单例模式:解决双重检查锁定(double-checked-locking)的问题。

观察者模式标志位变量值的更改。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20211014A014VW00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券