volatile域的语义及其实现

0.背景-缓存一致性

根据维基百科的定义:
在一个共享内存多处理器系统中,每一个处理器都有一个单独的缓存,能够有不少共享数据副本:一个在主内存中,一个在每一个请求它的处理器的本地缓存中。 当一个数据副本被更改时,其余副本必须反映该更改。 缓存一致性是确保共享操做数(数据)值的更改及时在整个系统中传播的学科。
下面图1是缓存不一致的示例图,图2是缓存一致的示例图
缓存不一致
缓存一致
其实Java的volatile某种意义上也是来解决这种缓存不一致的状况。html

更多缓存一致性的知识,能够参看维基百科的词条,也能够看medium上的这篇文章java

1.JMM提供的volatile域的语义

1.1 可见性

根据JSR-133 FAQ中的说明,volatile字段是用于在线程之间传递状态的特殊字段。 每次读取volatile时,都会看到任意一个线程对该volatile的最后一次写入。 实际上,程序员将volatile字段指定为不能接受因为缓存或重排序而致使的“过期”值的字段。 禁止编译器和运行时在寄存器中分配它们。 它们还必须确保在写入后将其从高速缓存(cache)中刷新到主存(memory),以便它们能够当即对其余线程可见。 一样,在读取volatile字段以前,必须使高速缓存无效,以即可以看到主内存中的值而不是本地处理器高速缓存中的值。 git

也就是说每次读取volatile都是从主存读取,写入也会刷新到主存,于是保证了不一样线程拿到的都是最新值,即保证了共享资源对各个CPU上的线程的可见性,这其实就是保证了缓存一致性。程序员

1.2. 重排序限制

在旧的内存模型下(Java1.5以前),对volatile变量的访问不能相互重排序,但能够与nonvolatile变量访问一块儿重排序。 这破坏了volatile字段做为从一个线程到另外一线程发信号通知状态的一种手段。github

在新的内存模型下(Java1.5及以后),volatile变量不能相互从新排序仍然为true。区别在于,如今对它们周围的正常字段访问进行重排序再也不那么容易了。编程

写入一个volatile 字段具备与monitor释放相同的存储效果,而从一个volatile 字段中读取具备与monitor获取相同的存储效果。windows

实际上,因为新的内存模型对volatile 字段访问与其余字段访问(不管是否为易失性)的从新排序施加了更严格的约束,所以当线程A写入volatile 字段f时,对线程A可见的任何内容,这些内容在线程B读取f时均可见。缓存

这是一个如何使用易失性字段的简单示例:性能优化

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

假定一个线程在调用writer方法,而另外一个在调用reader方法。在writer方法中对v的写操做,会将对x的写操做也更新到主存中,而对v的读操做则从主存中获取该值。数据结构

所以,若是reader方法看到v的值为true,那么也保证能够看到在它以前发生的对42的写入。

在旧的内存模型下,状况并不是如此。若是v不是volatile,则编译器能够对writer方法中的写入进行重排序,而reader方法对x的读取可能会看到0。关于重排序的示例,能够参见这篇文章

有效地,volatile的语义已获得实质性加强,几乎达到了同步(synchronization)的水平。出于可见性目的,对volatile 字段的每次读取或写入都像 "half" a synchronization (半同步)同样。

重要说明:请注意,两个线程访问相同的volatile变量很重要,以便正确设置 happens-before 关系。在线程A写入volatile字段f时,对线程A的可见的一切,并不必定对读取volatile字段 g以后的线程B可见。

释放和获取必须“匹配”(即在相同的volatile 字段上执行)以具备正确的语义。

1.3.若是x为volatile域,那么x++ 是原子操做吗?

首先先解释一下什么是原子操做:

An atomic operation is an operation that will always be executed without any other process being able to read or change state that is read or changed during the operation

原子操做是这样一个操做,该操做执行期间读取或改变的状态不会被任何其余进程读取或改变。

1.3.1 与预期不符

假如咱们有下面的代码:

package volatileTest;

import juc.CountDownLatch;

/**
 * * @Author: cuixin
 * * @Date: 2020/8/5 19:25
 */
public class VolatileAdder {
    private  volatile int x;
    public  void add(){
        //不是原子操做
        x++;
    }
    public  int get(){
        return x;
    }


    public static void main(String[] args) throws Exception
    {
        VolatileAdder instance = new VolatileAdder();
        int taskNum = 2;
        CountDownLatch countDownLatch = new CountDownLatch(taskNum);
        for(int i=0; i<taskNum; i++){
            new Thread(new Task(instance, countDownLatch)).start();  
        }
        countDownLatch.await();
        System.out.println(instance.get());
    }

    private static class Task implements Runnable{
        private VolatileAdder adder;
        private CountDownLatch latch;
        Task(VolatileAdder adder,  CountDownLatch latch){
            this.adder = adder;
            this.latch = latch;
        }

        @Override
        public void run() {
            for (int i = 0; i < 100000; i++)
            {
                adder.add();
            }
            latch.countDown();
        }
    }
}

(注:这里的使用CountDownLatch只是为了确保,两个线程运行完任务后,主线程才会调用instance.get(),输出x的值。)
咱们运行上面的程序,发现结果并非预想的200000,要比这个值小一些(若是在你的机器上不是,你能够适当调大run方法中的循环次数)。

1.3.2 jvm指令层面看看x++

下面咱们先从jvm指令层面看看x++是否是原子的。

执行

javac volatileTest/VolatileAdder.java 

javap -v  volatileTest/VolatileAdder > volatileTest/VolatileAdder.disasm

拿到jvm层面反汇编代码,查看volatileTest/VolatileAdder.disasm 文件,能够发现 add 方法里面的一行 x++,用的四行 jvm 指令实现的。以下图:
valatileAdder-add

对上面标红四条JVM指令说明一下:

getfield 获取字段x的值并放入操做数栈顶,

iconst_1 将1放入操做数栈栈顶;

iadd 从操做数栈顶取出两个元素相加并将结果放回到栈顶;

putfield 从操做数栈顶拿到上面的相加结果,并赋值给字段x。

因为一个 ++ 操做须要四条 JVM 指令,那么就可能存在下面这种执行序列,此时至关于少作了一次++操做。

线程A 线程B
getfield
getfield
iconst_1
iadd
putfield
iconst_1
iadd
putfield

因为线程A执行 ++x操做期间,混杂着线程B 执行++x操做,因此说这不是原子操做。

那么如何解决呢,若是多线程下须要++操做,不妨使用Atomic相关类替代(预告,后面文章会介绍使用及原理)。

若是你还不放心,觉得上面的jvm对应的机器指令不必定也有这么多。

1.3.3 从机器指令看x++

首先尝试运行下面的命令,将字节码文件转换成本地机器指令文件。

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly volatileTest/VolatileAdder> volatileTest/VolatileAdder.native

这时候在个人机器上报了一个Could not load hsdis-amd64.dll; library not loadable; PrintAssembly is disabled的错误。

这个根据不一样的操做系统和 cpu 上面的报错会有所不一样,你能够按照这个地址本身编译来解决上面的问题,也能够本身搜搜看有没有现成的(好比,我用的就是别人弄好的文件),而后放到了JAVA_HOME/bin路径下,再执行就不报错了。

VolatileAdder.native中搜索 'add' ,能够看到 x++,也是由四条机器指令实现的,一样的道理再一次说明了x++不是原子操做。

在这里插入图片描述

2.内存屏障 memory barrier

2.1 概念

下面的这几段介绍来自维基百科

A memory barrier, also known as a membar, memory fence or fence instruction, is a type of barrier instruction that causes a central processing unit (CPU) or compiler to enforce an ordering constraint on memory operations issued before and after the barrier instruction. This typically means that operations issued prior to the barrier are guaranteed to be performed before operations issued after the barrier.

内存屏障,也称为 membar,,memory fence或 fence instruction,是一种屏障指令,它使中央处理单元(CPU)或编译器对于在屏障指令以前和以后发出的存储器操做执行一种排序约束。

这一般意味着能够保证在屏障以前发布的操做能够在屏障以后发布的操做以前执行。

Memory barriers are necessary because most modern CPUs employ performance optimizations that can result in out-of-order execution. This reordering of memory operations (loads and stores) normally goes unnoticed within a single thread of execution, but can cause unpredictable behaviour in concurrent programs and device drivers unless carefully controlled. The exact nature of an ordering constraint is hardware dependent and defined by the architecture's memory ordering model. Some architectures provide multiple barriers for enforcing different ordering constraints.

内存屏障是必需的,由于大多数现代CPU都采用了性能优化,这些性能优化可能会致使乱序执行。

一般在单个执行线程中不会注意到这种内存操做(load和store)的从新排序,可是除非仔细控制,不然可能在并发程序和设备驱动程序中引发不可预测的行为。

排序约束的确切性质取决于硬件,并由体系结构的内存排序模型定义。某些体系结构为执行不一样的排序约束提供了多个内存屏障。

Memory barriers are typically used when implementing low-level machine code that operates on memory shared by multiple devices. Such code includes synchronization primitives and lock-free data structures on multiprocessor systems, and device drivers that communicate with computer hardware.

当实如今多个设备共享的内存上运行的低级机器代码时,一般使用内存屏障。此类代码包括多处理器系统上的同步原语和无锁数据结构,以及与计算机硬件进行通讯的设备驱动程序。

2.2 Intel 64的内存屏障指令及内存排序限制

2.2.1 内存屏障指令

上面主要是说了Java 内存模型提供的 volatile 语义,那么这些语义是如何实现的呢?

其实上面 VolatileAdder.native文件已经给出了答案,关键就在lock addl前面的lock前缀

image2020-8-7_14-28-45.png
经过查看英特尔®64和IA-32架构软件开发人员手册卷2A, 能够找到 lock 的说明,下面是节选:

image2020-8-6_20-41-13.png
使处理器的LOCK#信号在执行伴随的指令的过程当中被声明(将指令转换为原子指令)。在多处理器环境中,LOCK#信号可确保在断言该信号时,该处理器拥有对任何共享内存的独占使用。

也就是上面在 addl 添加前缀 lock ,这会致使该处理器执行addl时拥有对任何共享内存的独占使用。

其实x86-64中相似的内存屏障还有不少,好比mfencelfence, cpuid 等。

好比下面是Intel 64中mfence的节选说明:

Performs a serializing operation on all load-from-memory and store-to-memory instructions that were issued prior the MFENCE instrunction.
This serializing operation guarantees that every load and store instruction that preceds the MFENCE instruction in program order becomes globally visible before any load or store instruction that follows the MFENCE instruction.

对在MFENCE指令以前发出的全部 load-from-memory 和 store-to-memory 执行序列化操做。此序列化操做可确保,按照程序顺序在 MFENCE 指令以前的每一个 load 和 store 指令,对于 MFENCE 指令以后的任何 load 或store指令都是全局可见的。

2.2.2 内存排序限制

这里有个文件是关于Intel® 64内存排序的说明,你们也能够看下。

2.3 Java内存模型

上面这只是关于Intel® 64相关的内存屏障指令和内存排序的说明,每一个CPU架构都不一样呢?是否是有点绝望。。。嗯,还好有大神

下面是Doug Lea整理的关于不一样处理器相关的内存屏障指令和原子指令。
在这里插入图片描述

你们必定要去看看Doug Lea写的这篇“The JSR-133 Cookbook for Compiler Writers”。
看了以后JVM会确保生成的机器指令会在volatile字段周围插入合适的内存屏障指令,从而实现JSR-133定义的volatile语义。 上面给出的示例VolatileExample就会在以下位置插入内存屏障指令StoreStore和LoadLoad。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;   
    //在这之间插入StoreStore屏障, 等价于在v的值true刷到主存以前,先将x的值42刷到主存。
    v = true;
  }

  public void reader() {
   //在获取v的值以后插入LoadLoad屏障,等价于先从主存加载v的值,若是v的值为true,再从主存加载x的值。
   if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

读完这篇文章能够发现,能够看到不一样CPU架构提供不一样的内存屏障指令(主要由硬件工程师实现)和内存排序限制;为了对上层隐藏各类CPU架构的不一样,Doug Lea基于此又提出了JVM层面的LoadLoad,StoreStore等内存屏障(由JVM实现者实现);而后JVM实现者则提供统一的Java内存模型(Java语言规范 第八版 17章);而后咱们这些普通的Java开发者就在这统一的Java内存模型上写跨平台的应用

这里是否是有点像搭积木同样,一层层落上去,一层层地抽象上去。虽然按理说普通的Java开发者只须要熟悉Java内存模型便可编写并发程序,可是为了更好地理解如何使用Java内存模型提供的语义,为了更好地将本身的理解迁移到其余编程语言,理解这些底层的机制十分有必要。

3.总结

这篇文章首先是推荐的缓存一致性的文章,给你们一个背景。而后主要是对volatile的语义进行了介绍,并设计示例VolatileAdder从JVM指令和机器指令两个层面来讲明volatile域++操做不是原子操做。

下面有针对示例VolatileAdder的机器代码中的lock addl指令进行了说明,进而引出Intel64内存屏障指令和内存排序限制,而后JVM对不一样CPU架构进行封装抽象提供了统一的Java内存模型给普通开发者。

是否是没有想到,一个看起来简简单单的volatile,后面居然隐藏了那么多秘密。

4.参考

https://en.wikipedia.org/wiki...
https://docs.oracle.com/javas...
http://gee.cs.oswego.edu/dl/j...
https://www.cs.umd.edu/~pugh/...
https://en.wikipedia.org/wiki...
https://docs.oracle.com/javas...
https://wiki.openjdk.java.net...
https://jpbempel.github.io/20...
https://www.infoq.com/article...
https://jpbempel.github.io/20...

相关文章
相关标签/搜索