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时都可见。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 与预期不符

假如我们有下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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++是不是原子的。

执行

1
2
3
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++

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

1
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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