分享

你真的了解volatile吗?

 码农9527 2021-12-03

 无论是在面试时,还是在实际开发中,高并发问题已经成为了现在的主旋律。

  并发问题的定位和重现是一件很棘手且难以解决的事情,为了尽可能的减少并发问题的产生,正确的编写并发程序显得尤其重要。

  解决并发问题,我们一般需要从原子性、可见性和有序性三方面入手,借助Java关键字及各种同步工具类来实现。

  原子性、可见性、有序性三特性:

  原子性:原子性就是说一个操作不能被打断,要么执行完要么不执行。

  可见性:可见性是指一个变量的修改对所有线程可见。即当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

  有序性:为了提高程序的执行性能,编辑器和处理器都有可能会对程序中的指令进行重排序。

  其中,volatile作为Java中最轻量级的同步机制,可以被用来解决实例属性的可见性问题。

  volatile的两种特性,决定了它的作用

  volatile关键字是Java提供的最轻量级的同步机制,为字段的访问提供了一种免锁机制,使用它不会引起线程的切换及调度。

  一个变量被定义为volatile之后就具备了两种特性:

  可见性:简单地说就是volatile变量修改后,所有线程都能立即实时地看到它的最新值。

  有序性:指系统在进行代码优化时,不能把在volatile变量操作后面的语句放到其前面执行,也不能将volatile变量操作前面的语句放在其后执行。

  Java中的volatile关键字可以解决多线程可见性问题。那它是何时以及如何使用呢?

  下面我们一起来揭秘。

  初识Volatile:保证多线程下共享变量的可见性

  下面的两个例子演示了变量使用volatile和未使用volatile时,变量更新对多线程执行的影响。

  在VolatileDemo中,停止标识stop使用volatile关键字修饰,初始值为false。

  创建子线程thread1并启动,在子线程thread1任务中,当不满足停止条件时,线程会一直运行;当满足停止条件,终止任务。

  稍后,我们在主线程中设置停止标识为true。执行代码,结果如下图。

  我们可以看到在主线程设置stop=true后,子线程同时感知到stop的变化终止了任务。

  NonVolatileDemo中,停止标识stop未使用volatile关键字修饰,初始值为false。其他代码和VolatileDemo完全一致。

  执行代码,结果如下图。

  我们可以看到在主线程设置stop=true后,子线程未及时感知到stop的变化,还在继续执行任务。

  可以看到,volatile关键字保证了多线程下共享变量的可见性,那到底什么是可见性问题呢?

  在单线程中,如果修改了一个变量的值,之后如果读取这个值,读取到的值肯定是最新值。

  但是在多线程环境下,如果在一个线程中修改了一个变量的值,之后其他的线程读取这个值时有可能不能立即获得这个变量的最新值。这就是可见性问题。

  那volatile是如何保证了可见性的呢?这我们就需要从硬件层面来了解可见性的本质。

  从根源深度分析:volatile是怎么实现功能的?

  volatile基于内存屏障实现可见性

  可见性问题主要由以下两个现象引起的:

  多处理器能够暂时在寄存器或本地缓冲区(如写缓冲区)中保存内存中的值,这样,不同处理器上的线程同时可能保存着同一个内存位置的不同的值;

  为了使吞吐量最大化,编译器和处理器在不会改变代码语义的情况下,可以改变指令执行的顺序。

  以写缓冲区为例,我们来看看它是如何影响数据的可见性的。

  现代计算机的核心硬件由CPU,内存,磁盘和以及其他的IO设备组成。

  由于程序的代码和数据都存储在内存中,计算机在完成一项任务的过程中,免不了要与内存进行交互,如读取指令,读取数据、存储运算结果等。

  而现在内存的IO操作速度远远跟不上CPU的运算速度,如果内存中的数据迟迟进不了CPU,CPU就会一直处于等待状态。

  为了弥补内存速度的不足,人们在每个处理器和内存之间加入了尽可能接近处理器速度的高度缓存。

  高度缓存会将CPU运算需要使用的数据从主内存复制到缓存中,当运算结束后再将缓存同步回主内存之中,这样就提高了CPU访问程序和数据的速度。

  在多核处理器系统中,每个处理器都有自己独享的高速缓存,它们共享主内存。高速缓存虽然解决了CPU和内存速度不匹配的问题,但是这会带来另外一个问题:

  当两个内核同时使用同一数据时,如果一方修改了数据,未及时通知到对方,导致双方缓存中持有的该数据的信息不一致,这就是缓存一致性问题。

  为了解决缓存一致性问题,保证每个处理器读写数据时都能得到最新的值,这就需要所有的处理器访问缓存时都遵守一些协议,在读写数据时要根据协议来进行操作。

  其中比较经典,最出名的是Intel的MESI协议,MESI协议保证了每个处理器的缓存中使用的共享变量的副本是一致的。

  高速缓存是以缓存行(Cache line)为单位存储的,Cache line是缓存和内存进行数据交换的最小单位。

  在MESI协议中,每个Cache line有四个状态,它们分别是M、E、S、I状态。

  在MESI协议中,每个Cache不仅知道自己的读写操作,而且同时也监听其他Cache的读写操作。

  每个Cache line的状态会根据本核和其他核的读写操作在4个状态间进行变换。

  MESI协议解决了缓存一致性问题,但是同时也带来了一些问题。

  当多个 CPU 缓存持有相同的数据时(S 状态),如果其中一个 CPU 要对数据进行修改,需要等待其他 CPU 将数据失效(I 状态),那么这里就会有空等期(stall),这对于频率很高的CPU来说,简直不能接受!

  为了避免以上问题带来的CPU资源浪费,现代的处理器引入了写缓冲区。

  当写操作数据被提交时,会把写操作先放入 Store Buffer 中,同时向其他的CPU发送 invalidate 消息,随后CPU可以去处理别的事情。

  当收到其他所有 CPU 反馈的 invalidate acknowledge 消息时,再将写缓冲区中的数据写入主存。

  对于同一个 CPU 而言,在读取 X 变量的时候,如若发现 Store Buffer 中有尚未写入到缓存的数据 X,则直接从 Store Buffer 中读取。

  这就保证了单线程中的代码执行顺序,从CPU外部看该CPU一直在进行读写操作, 而不必等待, 这极大地提高了CPU的效率。

  但是写缓冲区会带来另外一个问题,对于当前CPU来说读写指令的执行顺序没有变化。

  如下图,对于本地CPU来说,指令的执行顺序还是先Store后Load,但是对于内存和其他CPU来说,Load指令是先于Store指令执行。

  这种情况可以认为是一种指令重排序,而它会带来内存可见性问题。

  我们来看下面的例子。线程T1.T2共享变量x,y初始值为0.两个线程T1.T2在两个处理器内核中分别执行。T1在Core1中执行,T2在Core2中执行:

  在Core1 和Core2指令的执行顺序是在本地处理上不变的,但是由于写缓冲区的引入,所以对于其他处理器存在以下的执行顺序和结果:输出 (ry,rx)=(0.0)。

  重排序有可能会造成不可预知的问题,那有没有什么办法来禁止重排序呢?

  在计算机指令中,计算机提供了内存屏障指令,用于禁止处理器的重排序。

  内存屏障是一组CPU指令,它的作用是禁止重排序,强制数据访问内存的顺序和程序代码顺序一样。

  内存屏障是一种标准,不同的处理器厂商和操作系统可能会采用不同的实现。X86的内存屏障指令包括读屏障、写屏障、全屏障。

  那Java开发时,我们如何内存屏障呢?

  Java作为一种一次编写,多处运行的语言,开发者是不需要考虑平台相关性的,同样这个问题也不需要也不应该由程序员来关心。

  在需要确保代码执行顺序时,JVM会在适当位置插入一个内存屏障命令,用来禁止特定类型的重排序。

  所以,Java虚拟机封装了底层内存屏障的实现,提供了四种内存屏障指令(在后面有说明),编译器会根据底层的计算机架构,将内存屏障替换为相应的CPU指令。

  volatile 变量的原生实现

  volatile 变量就是是基于内存屏障实现的。下面我们一起从源码来剖析下volatile是如何实现了内存可见性。

  步骤 0:下载源码

  OpenJDK的源码是托管在 Mercurial代码版本管理平台上的,可以使用Mercurial的代码管理工具直接从远程仓库(Repository)中下载获取源码。

  我们选用的项目是OpenJDK 8u,代码远程仓库地址:

  https://hg.openjdk./jdk8u/jdk8u/

  因为下载源码过程中需要执行脚本文件get_source.sh,所以需要在Linux平台下下载。

  大家可以在安装了Linux环境的机器上下载,或者在自己的机器上安装Linux虚拟机后进行下载。

  获取源代码过程如下:

  OpenJDK 目录结构

  步骤 1:hsdis工具查看机器指令

  使用hsdis可以查看 Java 编译后的机器指令。

  使用方法:把编译好的 hsdis-amd64.dll放在 $JAVA_HOME/jre/bin/server 目录下。

  就可以使用如下命令,查看程序的机器指令。

  在类VolatileFieldTest中属性volatileField为volatile变量。

  在Linux中,执行

  对于 volatile 修饰的变量进行写操作,在生成汇编代码中,会有如下的指令:

  上面的操作就相当于一个内存屏障。

  lock指令:会使紧跟在其后的指令变成原子操作,lock指令是一个汇编层面的指令,作为前缀加在其他汇编指令之前,可以保证其后汇编操作的原子性。

  在多CPU环境中, LOCK指令可以确保一个处理器能独占使用共享内存。

  在计算机中每个CPU拥有自己的寄存器,缓冲区和高速缓存,所有CPU共享主内存。

  如果是单核CPU环境,所有线程都会运行相同CPU上,使用的是相同存储空间不存在一致性问题,就不需要内存屏障;

  如果是多核 CPU环境中,线程可能运行在不同的CPU内核上, 共享主内存,就需用内存屏障保障一致性。

  addl指令:加法操作指令。

  addl $0.0(%%esp)表示将数值0加到rsp寄存器中。esp寄存器指向栈顶的内存单元,加上一个0.esp寄存器的数值依然不变。

  addl $0.0(%%esp) 使用此汇编指令再配合lock指令,实现了CPU的内存屏障。

  步骤 2:volatile源码解析

  下面我们通过OpenJDK源码,来看看JVM是怎么实现volatile赋值的。

  查看volatile字段字节码:ACC_VOLATILE

  通过javap命令javap -v -p VolatileFieldClass.class可以看到volatile的属性的字节码flags标识中有个关键字ACC_VOLATILE。

  在不同的计算机架构(操作系统和CPU架构)下,内存屏障的实现也会不同,所以对应的JVM的volatile底层实现在不同的平台上也是不同的。

  下面我们以linux_x86为例,来一层层解开volatile的面纱。

  is_volatile方法:判断一个变量是否是volatile类型

  通过关键字ACC_VOLATILE,我们可以定位到JVM源文件vm\utilities\accessFlags.hpp文件,代码如下:

  可以看到is_volatile函数,这个函数是判断变量字节码中是否有 ACC_VOLATILE这个flag。

  Java中volatile变量赋值:C++实现

  Java字节码是通过BytecodeInterpreter解释器来执行,我们在bytecodeInterpreter.cpp文件根据关键字is_volatile搜索,可以看到如下代码:

  在这段代码中,cache->is_volatile()这段代码,cache代表变量在常量池缓存中的实例(本例中为volatileField),作用是判断变量是否被 volatile修饰。

  接着,根据当前变量的类型来赋值,会先判断volatile变量类型(tos_type变量),后面有不同的基础类型的调用,比如int类型就调用release_int_field_put。

  release_int_field_put这个方法的实现在文件oop.inline.hpp中:

  赋值动作int_field_addr外面包装执行了OrderAccess::release_store方法。

  我们看看 OrderAccess::release_store做了什么,

  它的定义在 :

  vm\runtime\orderAccess.hpp中,

  linux_x86中实现在:

  os_cpu\linux_x86\vm\orderAccess_linux_x86.inline.hpp

  可以看到volatile操作,第一步是实现了C++的volatile变量的原生赋值实现。

  C/C++中的volatile关键字,用来修饰变量,表明变量可能会通过某种方式发生改变。

  被volatile声明的变量,会告诉编译器与该变量有关的运算,不要进行编译优化,且变量的值都必须直接写入内存地址或从内存地址读取。

  volatile使用内存屏障禁止重排序

  赋值操作完成以后,我们可以看到最后一行执行的语句是:

  这是JVM的storeload一个内存屏障。

  JMM 把内存屏障指令分为了四类,可以在vm\runtime\orderAccess.hpp找到对应的实现方法:

  内存屏障的解释也可以在orderAccess.hpp该文件中看到:

  其中StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。

  现代的多处理器大都支持该屏障,执行该屏障开销会很昂贵。

  以linux_x86为例,我们可以在:

  os_cpu\linux_x86\vm\orderAccess_linux_x86.inline.hpp看到它们的实现:

  当调用storeload屏障时,会调用fence()方法:

  在上面的代码中,我们看到了熟悉的汇编指令 lock; addl $0.0(%%esp):

  os::is_MP(),会判断当前环境是否是多核。单核不存在一致性问题,多核CPU才需要使用内存屏障。

  cc代表的是寄存器,memory代表是内存。

  同时使用“cc”和“memory”,会通知编译器和处理器volatile变量在内存或者寄存器内的值已经发生了修改,要重新加载,需要直接从主内存中读取。

  那我们平时在使用volatile时应该遵循什么原则呢?

  针对不同场景,关于volatile的使用建议

  正确用法

  使用 volatile 变量的主要原因是单个字段同步操作的简易性。

  如果只使用了volatile就能实现线程安全,那就放心的使用它,如果同时还需要添加其他的同步措施,那就不要使用。

  正确使用的场景举例:变量本身标识是一种状态,或者引用变量的某些属性状态,在代码中需要确保这些状态的可见性,这时就可使用volatile。

  volatile 变量仅仅是一个状态标识,用于指示发生了一个重要的一次性事件,例如完成初始化标识或请求终止标识。

  这样只要任何一个线程调用了shutdown(),其他线程在执行doWork时都可以立即感知到stop变量的变化,这时就可以大胆的使用volatile。

  这种类型的状态标记的一个公共特性是:通常只有一种状态转换,如标志从false 转换为true。

  这时使用volatile要比synchronized要简单有效的多,如果使用synchronized还会影响系统的吞吐量。

  错误用法

  我们知道基本数据类型的单次读、写操作时具有原子性。同样单个volatile 变量单次的读、写操作也具有原子性。

  但是对于类似于 ++,--,逻辑非!这类复合操作,这些操作整体上是不具有原子性的。

  如下面例子:

  造成这种情况的原因是因为++操作分三次操作完成的。

  我们执行反编译命令:

  javap -c VolatileTest.class,

  可以看到increase()函数中race++是由以下字节码指令构成:

  字节码释义如下:

  从字节码层面很容易分析出来并发失败的原因了。

  假如有两条线程同时执行race++:

  线程A,线程B同时执行getfield指令把race的值压入各自的操作栈顶时,volatile关键字可以保证来race的值在此时是正确(最新的值)的;

  线程A依次执行完了后续操作iadd和putfield,此时主内存中race的值已被增大1;

  线程A执行完毕后,线程B操作栈顶的race值就变成了过期的数据,这时线程B执行iadd、putfield后就会把较小的值同步会主内存了。

  在这种场景中,我们仍然要通过加锁来保证原子性,此时就不建议使用volatile。

  以下为正确实现,使用synchronized保证 race++操作的原子性。

  到此,我们彻底的揭开了volatile 的面纱。

  现在我们明白了 volatile 保证有序性和可见性的原理,也知道了使用时应遵循的原则。

  希望大家不虚此行,可以在日后的工作中能熟练的运用 volatile 关键字。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多