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 指令实现的。如下图:

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/Cache_coherence
https://docs.oracle.com/javase/specs/jls/se8/html/index.html
http://gee.cs.oswego.edu/dl/jmm/cookbook.html
https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#volatile
https://en.wikipedia.org/wiki/Volatile_(computer_programming)#cite_note-9
https://docs.oracle.com/javase/8/docs/technotes/tools/windows/javap.html
https://wiki.openjdk.java.net/display/HotSpot/PrintAssembly
https://jpbempel.github.io/2015/12/30/printassembly-output-explained.html
https://www.infoq.com/articles/memory_barriers_jvm_concurrency/
https://jpbempel.github.io/2015/05/26/volatile-and-memory-barriers.html

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,830评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,992评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,875评论 0 331
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,837评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,734评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,091评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,550评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,217评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,368评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,298评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,350评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,027评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,623评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,706评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,940评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,349评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,936评论 2 341