乐趣区

关于volatile:volatile域的语义及其实现

0. 背景 - 缓存一致性

依据维基百科的定义:
在一个共享内存多处理器零碎中,每个处理器都有一个独自的缓存,能够有很多共享数据正本:一个在主内存中,一个在每个申请它的处理器的本地缓存中。当一个数据正本被更改时,其余正本必须反映该更改。缓存一致性是确保共享操作数(数据)值的更改及时在整个零碎中流传的学科。
上面图 1 是缓存不统一的示例图,图 2 是缓存统一的示例图


其实 Java 的 volatile 某种意义上也是来解决这种缓存不统一的状况。

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

1.JMM 提供的 volatile 域的语义

1.1 可见性

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

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

1.2. 重排序限度

在旧的内存模型下(Java1.5 之前),对 volatile 变量的访问不能互相重排序,但能够与 nonvolatile 变量拜访一起重排序。这毁坏了 volatile 字段作为从一个线程到另一线程发信号告诉状态的一种伎俩。

在新的内存模型下(Java1.5 及之后),volatile 变量不能互相从新排序依然为 true。区别在于,当初对它们四周的失常字段拜访进行重排序不再那么容易了。

写入一个 volatile 字段具备与 monitor 开释雷同的存储成果,而从一个 volatile 字段中读取具备与 monitor 获取雷同的存储成果。

实际上,因为新的内存模型对 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 指令实现的。如下图:

对下面标红四条 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 add l 后面的lock 前缀


通过查看英特尔®64 和 IA-32 架构软件开发人员手册卷 2A, 能够找到 lock 的阐明,上面是节选:


使处理器的 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 内存模型提供的语义,为了更好地将本人的了解迁徙到其余编程语言,了解这些底层的机制非常有必要。

4. 总结

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

上面有针对示例 VolatileAdder 的机器代码中的 lock addl 指令进行了阐明,进而引出 Intel64 内存屏障指令和内存排序限度,而后 JVM 对不同 CPU 架构进行封装形象提供了对立的 Java 内存模型给一般开发者。

是不是没有想到,一个看起来简简单单的 volatile,前面居然暗藏了那么多机密。

5. 参考

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…

退出移动版