Java内存模型语义

原文:https://shipilev.net/blog/2014/jmm-pragmatics/

1、前言

Java内存模型是Java规范中最复杂的部分,库和运行时的开发者必须能理解。然而,规范的内容比较粗糙,需要一些资深开发者展开解释。当然,大多数开发并没有按照规定使用JMM规则,而是根据规则自己想一些结构,更糟糕的是,盲目地复制高级开发者的结构,而不了解其限制。如果你是一个没有接触核心并发的普通开发,可以通过这篇文章了解,然后阅读进阶书籍如“Java Concurrency in Practice”。如果你是对这些感兴趣的高级开发,请继续阅读!

这篇文章是我今年在一些“Java Memory Model Pragmatics”演讲的内容,主要是俄语。很少有会议可以容纳这么长的讨论,为了给今年的JVMLS上的JMM研讨会提供一些背景阅读,我决定把它写下来。

我会复用之前的幻灯片,基于它们来做说明。当幻灯片不言自明时,我会跳过不做说明。幻灯片有俄语英语版本。下面的幻灯片是做了处理,分辨率很大,你可以放大查看。聪明的浏览器能调整图像大小,放大后可以看到更多细节。(我会在SVG中制作插图,但是当我在一个页面上渲染150多个时,我的iPad崩溃了!)

译者注:为了便于阅读,已将幻灯片部分翻译,并以代码块的格式给出,并标注页码PN。

感谢Brian Goetz,Doug Lea,David Holmes,Sergey Kuksenko,Dmitry Chyuko,Mark Cooper,C。Scott Andreas,Joe Kearney以及其他人提供意见和建议。final字段部分包含Vladimir Sitnikov和Valentin Kovalenko的解答,这是他们关于Final字段语义这一话题的摘录。

2、简介

P1 抽象机(abstract machine)

1.每种编程语言都通过执行其源程序的抽象机语义来描述其语义
2.语言规范=抽象机规范^1
如Brainfuck是一门非常率直的图灵机汇编语言。
注1:Java!=Java字节码,Java规范!=JVM规范
注2:Brainfuck,一种极小、但图灵完全计算机语言

阅读任何语言规范,会发现它可以在逻辑上划分为两个相关但不同的部分。首先,一个非常简单的部分是语法,它描述了如何用该语言编写程序。其次,最大的部分是语义,它描述了特定语法结构的含义。语言规范通常通过执行程序的抽象机的行为来描述语义,因此这种语言规范只是抽象机规范。
P2 内存模型

1. 抽象机规范的重要部分是:机器存储模型=存储模型
2. 为了达到它的目的,内存模型只需要回答一个简单的问题:
    程序中的某一次读取返回什么值?

当语言具备存储(以变量,堆内存等形式)特性时,抽象机也具备存储,必须定义一组有关存储行为的规则,这就是通常所说的内存模型。如果你的语言没有显式存储(例如在调用上下文中传递数据),那么你的内存模型会非常简单。在以存储见长的语言中,内存模型回答了一个简单的问题:“特定读取可以观察到什么值?”
P3 顺序程序是如此简单

顺序执行的程序,内存模型是确定的:应读取到最后一次写入的值
大多数人认为内存模型实际上是指包含多线程程序语义的内存模型。

在顺序程序中,因为有顺序,所以按照给定顺序存储到内存,自然按顺序观察到最新的写入。这就是人们通常只针对多线程程序解释内存模型的原因,它让这个问题变得复杂了。然而,即使在顺序的情况下,内存模型也很重要(尽管它们经常在顺序程序的概念中被忽略了)。
P4 然而并不简单

C89/99中最著名的例子是
    int i=5;
    i=(++i + ++i);
    assert (13 == i); // FAILS
缺少顺序导致未定义的行为,还是需要内存模型来解释单线程程序

例如,C程序中臭名昭着的未定义行为例子,代码之间包含一些自增操作。它可能满足断言,但也可能失败,或者导致其他问题。有人可能会争辩说这个程序的结果不同,是因为自增量的计算顺序不同。但它不能解释,当两个自增量都没有看到另一个的字面量时,结果是12。这就是内存模型的关注点:每个自增量应该看到什么值(并且进一步,它将存储什么值)。
P5 回到实际

语言的实现其实是在如下两件事之一
1.模拟抽象机并在上运行源程序的模拟(解释)
2.给源程序的定制抽象机,以及运行生成的可执行文件(编译)
在这两种情况下,实现需要匹配抽象机的语义
意思是
解释器不能免疫内存模型问题的影响。

无论哪种方式,当提出要实现某种特定语言时,我们可以采用两种方式之一:解释或将抽象机编译到目标硬件。无论如何,解释和编译都通过 Futamura映射(Futamura Projections)连接的。
实际上解释器和编译器都要模拟抽象机。编译器通常被指责搞砸了内存模型和多线程程序,但解释器也不能避免。解释器未能满足抽象机规范可能会导致内存模型违规。最简单的例子:将字段值缓存在解释器中的volatile读取上就完事了,这让我们进行了一次有趣的权衡。
P6 内存模型是一种权衡

使用一种语言有多难?
VS.
构建语言实现有多难?
VS.
构建合适的硬件有多难?
搞一门新语言X,提供大量美味多汁的功能,但需要花了一百万年的时间来打造高性能和一致性的IT设施呢?

编程语言本身仍然需要开发设计的原因是没有超级编译器。“超级”并不夸张:编译器工程中的一些问题是不可判定的,理论上不可解决,更不用说在实践中了。其他一些的问题理论可行,但不实用。因此,为了使实用(优化)编译器成为可能,我们需要在语言中引入一些不便,硬件也是如此,因为(至少对图灵机而言)这些只是二氧化硅中的算法。
P7 本次会谈的结构

这次会谈的内容如下
1. 表达下我们对语言语义学的渴望
2. 看看现实世界中到底有什么
3. 了解1和2之间的规范平衡
4. 看看保守的实现是啥样的
5. 对比下其他语言
正式的JMM定义包含在这样的块【】中

3、第一部分:访问原子性

3.1、我们想要什么

P8 访问原子性:理想

我们想要什么?
所有内置类型的访问原子性
也就是说,对于任何内置的T:
T t = V1;
------------------------------
t = V2;|T r1=t; assert (r1∈{V1,V2})

在JMM中最容易理解的是保证访问的原子性。为了严格的指定,我们引入一些符号,在幻灯片的示例中,可以看到两行。
第一行的场景都已经发生:所有变量已定义,所有初始化的存储已提交等等。下面两列是不同的线程,线程1将一些值V2存储到全局变量中t。线程2读取变量,并断言读取值。在这里,我们要保证线程只读取到已知值,而不是之间的某些值。

3.2、我们有什么

P9访问原子性:现实

需要原子读/写的硬件支持
注意:
大型读取缺乏硬件辅助
在32位x86上如何读取8字节?32位ARM呢?
内存子系统要求:例如通过缓存线,通常会失去访问原子性

对于正常的编程语言来说,这看似是一个显而易见要求:你怎么能违反这个,为什么?这就是原因。
为了保持并发访问的原子性,你必须至少让机器指令以给定宽度的操作数操作,否则原子性在指令级别被破坏:如果你需要将访问分成几个子访问,它们可以交叉。但即使你有所需宽度的指令,它们仍然可以是非原子的:例如,对于PowerPC来说,2字节和4字节读取的原子性保证是未知的(虽然它们暗示是原子的)。

译者注:1字节=8位,8字节=64位,简单点说,32位的机器如果想读取64位的数据,得拆成32+32的方式读取,操作变成了两步,这就不是原子的了,要是才读一半另一个线程就来读了,数据就不对了。而在Java中,long和double长度为64位。
P10 访问原子性:妥协1/2

【读/写对于所有东西都是原子的,除了long和double】
【volatile long和volatile double是原子性的】
引用有机器位元
2004年前几乎所有的硬件都能同时读取32位数据,64位读/写放宽
可以重新获得原子性(有性能惩罚)

大多数平台都能保证32位访问的原子性。这就是我们在JMM中做出妥协的原因,它放宽了64位的原子性保证。当然,仍有一些方法可以为64位强制执行原子性,例如通过悲观锁更新和读取,但这需要付出代价,因此我们提供了一个语法糖:用户将volatile放在需要原子性的位置,无论需要多大的成本,VM和硬件都会协同工作来保证它。
P10 访问原子性:妥协2/2

通常不对齐的访问会失去原子性
(几乎所有地方都损失性能)
强制实现对齐数据:
o.o.j. samples. JOLSample-02_Alignment. A
OFFSET SIZE TYPE DESCRIPTION
0       12        Cobject header)
12      4         (alignment/padding gap)
16      8   long  A.f

但是,在大多数硬件上,使用所需宽度的操作来维持原子性是不够的。例如,如果多个事务提交到内存,只要我们执行了单个访问指令,原子性就会关闭。例如,在x86中,如果读/写跨越两个高速缓存行,则不保证原子性,因为它需要两个内存事务。这就是为什么通常只有对齐的读/写是原子的,这迫使VM对齐数据。

JOL的示例中,我们可以看到在对象start的偏移量16处分配了long字段。大小是8字节,完美对齐。从而避免放到偏移量12处导致违反内存模型,如果它不是volatile,那就只能在x86上工作(其他平台上强烈反对执行不对齐访问),并可能导致性能损失。

3.3、测试你的理解

P11 访问原子性:测试

它会打印什么?
AtomicLong al =new AtomicLong()
--------------------------------
al. set(-1L); |println(al. get();
为什么不是 0 x FFFF FFFF 0000 0000

让我们通过一个简单的测验来测试我们的理解。设置-1L相当于将long的所有bit置为1。
答案:这没有魔法, AtomicLong内部的一个volatile long保证了原子性。这是语言规范所要求的,无需对VM端的AtomicLong进行特殊处理。

3.4、值(Value)类型和C/C++

P11 访问原子性:值类型

每个人都认为他们想要有值类型。在好处之外,它们带来了一些新的内存模型问题
例如,C/C+11原子要求任何POD都有原子性。
            typedef struct TT {
                    int a, b,C,..., z;//104 bytes
            } T;
            std: atomic<T> atomic();
            ---------------------------------
            atomic set(T());|T t= atomic.get();
这个实现是在逼它唱征服

在Java中,我们“幸运”拥有小宽度的内置类型。在提供值类型的其他语言中,类型宽度是任意的,这给内存模型带来了挑战。
如上,C ++通过结构体来遵循C的兼容性。C++11还支持std::atomic,它要求每个普通旧式数据(Plain Old Data,POD)类型T的访问原子性。因此,如果我们在C++11这么做,将被迫原子的读写104字节的内存块。没有机器指令可以保证这些宽度的原子性,应该采用CAS或锁或其他方式。
(这就有意思了,因为C ++允许单独编译:链接器的任务是确定这个特殊的std::atomic使用了什么锁/ CAS来保证,用不同编译器编译上面的代码,我不确定线程执行的话会发生什么。)

3.5、JMM更新

本节介绍在更新Java内存模型时有关原子性的注意事项。更详细的解释在这篇文章
P12 访问原子性 JMM 9

long/double特殊处理在2004年是符合实际的
- 32位x86的机器无处不在
- 非常直接,没有64位的机器
现在都4012年了
- 还有32位的机器还多吗?
- 32位机器也可以选择64位指令了
- 大多数平台已经有原子性的long/double
- 但无论如何我们还是要保留volatile,因为WORA(Write Once, Run Anywhere)
那么问题来了:现在是处理这些问题的好时机吗?

2014年了,我们是否要重新考虑64位的问题了,这里有一些用例表明更新long和double是有意义的,例如在可伸缩的概率计数器中,开发人员可能希望long/double的访问在64位平台上是原子的,但是如果在32位上运行的话,仍需要volatile。volatile标记字段会产生内存屏障的开销。
换句话说,由于volatile有两个含义:a)访问原子性; 和b)内存顺序 - 你不能像拿行李一样,拿了一个就不能拿另一个。可以猜想下怎么解决64位的问题:由于VM通过发出特殊指令序列来分别处理访问原子性,因此可以在必要时将VM改为无条件地发出原子指令序列。
P13 访问原子性 JMM 9

理解此图需要一些时间。我们可以测量long的读写 - 每种访问模式分别三次(正常,volatile和Unsafe.putOrdered)。如果我们正确地实现了该功能(指64位的原子访问),那么64位平台上读与写应该没有区别。实际测试下来,确实没有区别。
注意volatile long的写是多么慢,如果我想通过它实现原子性,得为内存排序付出额外开销。

P14 访问原子性 JMM 9

32位平台时会变复杂。需要特殊的指令序列来获得原子性。但在32位平台中,x86的FPU存取是64位宽。额外开销不是那么多。
P15 访问原子性 JMM 9

在非x86平台上,必须使用替代指令序列来重新获得原子性,自然有性能影响。同32位x86一样,volatile稍微慢一点,但这是系统问题,因为需要将值转储到long字段中以防止某些编译器优化。

参考:All Accesses Are Atomic,译文见本专栏《所有的访问都是原子的》一文。

4、第二部分:字分裂(Word Tearing)

4.1、我们想要什么

P16 字分裂:理想

我们想要什么?
分开独立操作(字段、数组、元素等)
To as new T[...]; as [1]= as [2]= VO 
------------------------------------
as[1]=V1;|as[2]=V1|Tr1=as[1];
                  |T r2=as[2]
                  |assert(r1=r2)

字分裂与访问原子性有关。
如果两个不同的变量,那么对它们的操作也应该是不同的,并且不应受相邻元素上的操作影响。而反例是:如果我们的硬件无法分别操作数组中的单个元素,它将被强制一次性读取多个元素,甚至整个数组,然后修改这一堆中的一个,然后将这一堆放回去。
如果两个线程在它们各自的元素上进行操作,则可能会发生另一个线程将其自己的结果存回内存,从而覆盖由前一个线程更新的元素。如果开发者不知道这一点,这可能会引起很多麻烦,如果语言规范中的没有明确规定,运行时就可以自由发挥,然后导致难以诊断的错误。

参考:

4.2、我们有什么

P17 字分裂:现实

需要硬件支持独立读写
注意
对于小型元素,缺少硬件辅助读写
如你最小只能写m(n≥8)位,怎么原子的写1位布尔值?

如果我们想防止字分裂,需要硬件支持给定宽度的访问。在boolean[]数组或一组布尔字段的简单场景中,无法轻松的在大多数硬件上访问单个内存位(bit),因为最小的可寻址边界通常是单个字节(byte)。
P18字分裂:折中方案

【禁止字分裂】
1. 大多数硬件可以寻址8位及以上
2. 如果硬件可以寻址低至n位,则一种完善的编程语言的最小基本类型宽度为n位
3. 例如,在大多数平台上,Java基本类型不会浪费空间(除了8位的boolean)

我必须向程序员们解释字分裂。大多数从事系统开发的程序员非常熟悉它,并且理解在真实的系统中排查这样的错误是多么的痛苦。
因此,Java真是一种理智的语言,禁止字分裂。Bill Pugh(开发了FindBugs,也是JMM JSR 133的领导者)对此非常清楚。我曾经在C ++程序中排查一个字分裂的错误,这不好玩。
这个要求看起来很容易适配当前的硬件:可能需要关心的唯一数据类型是boolean,采用完整字节而不是单个位。当然,还需要处理缓冲读写以及相邻数据的编译器优化。
P19字分裂:实验证明

对象按8字节对齐
除Boolean之外
宽度与值域匹配
$ java -jar jol-internal.jar
Running 64-bit Hotspot VM.
Using compressed references with 3-bit shift
Objects are 8 bytes aligned
Field sizes by type: 4, 1, 1, 2, 2,4, 4,8,8 [bytes]
Array element sizes: 4, 1, 1,2,2, 4,4,8,8 [bytes

大多数人都会在文档中查找原始值范围,并从那里推断机器表示的宽度。但这只意味着最小的宽度,比如long是2^64。实际上它并不强制运行时为每个long分配8个字节; 原则上可以使用128字节,只要它有理由。
但是,我所知道的大多数运行时都是实用主义,并且机器表示的宽度非常适合值域,不会浪费空间,boolean是唯一例外。用JOL可以找出实际的机器宽度,你可以在幻灯片上看到刻度。这些数字分别是reference,boolean,byte,short,char,int,float,long和double 所占的字节数 ,其他平台可能会被认为是奇怪的

4.3 测试你的理解

P20字分裂:测试

        BitSet bs = new BitSet();
------------------------------------
bs.set(1);|bs.set(2);|println(bs.get(1))
          |          |println(bs.get(2))

答案:(true,true),(false,true),(true,false)都是对的,因为BitSet存储在long []数组的位中,并使用位移来访问特定位。它在内存占用方面有优势,但它打破了语言的默认保证。(BitSet Javadocs说多线程场景需要加synchronized,所以这可以说是一个人为的例子)

4.4 布局控制和C/C++

P21字分裂: Bit 字段

每个人都认为他们需要一种通用的方法来控制对象Java中的布局。
就像这个
            typedef struct TT{
                    unsigned a:7
                    unsigned b:3
            }T;
                      T t;
            --------------------
                t.a=42;|r1=t.b;
无论对A或B的访问都得面对字分裂问题(C/C++11 放宽了限制)

很多人希望控制特定类的内存布局,以便在某些情况下实现更小的空间占用,或更好的性能。但是在允许对其变量进行任意布局的语言中,你没法禁止字分裂,必须付出代价,如示例。
没有机器指令可以一次写入7位,或者一次只读取3位,因此如果想避免字分裂,那就得想办法了。 C/C ++11允许你使用这个双刃剑,但是,一旦开始,所有问题得自己扛了。
没人反对禁止字分裂。

5.第三部分:SC-DRF

5.1、我们想要什么

P22 SC-DRF:理想

一种解释正确性的简单方法
-------------
opA();|opD();
opB();|opE();
opC();|opF();
如果每个线程按顺序执行,很容易理解线程交叉执行

现在我们开始讨论内存模型中最有趣的部分:论证程序读取逻辑。很自然的可以认为程序会以某种全局顺序执行语句,有时会在线程之间切换。这是一个非常简单的模型,Lamport为我们定义了它:顺序一致性。

Lamport算法:又称面包房算法,先来先服务算法。
P23 SC-DRF:理想(通俗说法)

顺序一致性 Sequential Consistency(SC)
(Lamport,1979):所有处理器按顺序执行的任何操作的结果都是一样的。
每个处理器的操作序列都依照程序指定的顺序。

顺序一致性并不意味着操作是按特定的全局顺序执行,(严格一致性才保证这一点)。重要的是,结果和全局顺序执行的结果相同。我们将执行称为顺序一致的执行,并将其结果称为顺序一致的结果。
P24 SC-DRF:理想(通俗说法)

SC相当棘手
我们可以用任何方式执行程序,只要结果同执行原始程序的SC结果相同。


SC显然给了我们优化代码的借口。我们不再受实际总执行顺序的约束,只要假装有顺序即可。如上,该程序变换不破坏SC,并产生相同的结果(假设没其他地方访问a和b)。
即SC允许我们缩小执行集。在极端情况下,我们可以自由选择顺序并执行下去。

5.2 我们有什么

P25 SC-DRF:现实

代码转换和内存模型之间的关系可以通过读/写重新排序来表达
这种转变会破坏SC吗?

但是,SC的可优化性被高估了。当前的优化编译器只关心当前的指令流,更不用说硬件了。如果我们在指令流中有两个读取,我们可以对它们进行重新排序并保证SC吗?
P26 SC-DRF:现实

在原程序按SC执行的最后一行可能是 r2=b 或 a=1;因此(r1,r2)的结果可能是(*,2)或(0,*)

然而做不到。如果程序的另一部分将值写到a和b中,则读取的重排序会打断SC。实际上,在SC下执行的原程序只能让结果匹配(*, 2)或者(0, *),并且修改程序,甚至以全局顺序的方式执行,产生(1, 0)的结果更会让开发人员感到困惑。
P27 SC-DRF:现实

顺序一致性是非常吸引人的模型
有人已经提交了JEP
很难说什么转换没有破坏SC
理论上讲,一些花式Global MetaOptimizer(GMO)就能够分析这个
然而,在实践中,运行时和硬件都是无GMO的
大多数优化是被禁止的

可以到出,即使是一个非常简单的变换,你都需要经过复杂分析是否合理,而这种分析并不容易推及到实际的程序。理论上讲,我们可以拥有一个可以执行此分析的智能全局优化器(GMO),还不如说我认为拉普拉斯妖的存在:)
但我们没有GMO,所以所有的优化都是保守的,因为担心无意中违反了SC,这又会导致性能损失,所以呢,我们不能进行转换,对吧?
即使是非常基本的转换也是被禁止的。想想看:如果能消除程序中其他地方的读取,即重排序,你能把变量放在寄存器上吗?
P28 SC-DRF:硬件的现实

硬件推断和重排序了很多东西(为了性能)


参考:Memory-ordering
虽然我们可以禁止编译器中的一些优化以阻止SC被破坏,但硬件没有妥协。硬件重新排序了许多东西,并提供为重排序准备了后手(“内存屏障”)。一个不能控制转换的模型和鼓励进行某些优化的模型实际上并不是好模型。例如,如果我们为了语言中的顺序一致性,我们不得不悲观地为几乎每一次内存访问设置内存屏障,以便防止硬件尝试“优化”。
P29 SC-DRF:一些说明

如果两个内存访问使用相同的内存位置,且至少一个访问是写入,就则会发生冲突
如果有两次内存访问同时发生冲突,程序将出现数据竞争(即没有同步)
原程序会产生意外的结果
语言被迫提供访问排序机制

另外,如果你的程序包含资源竞争,则当前硬件并不保证这些冲突操作的结果。汉斯·贝姆和萨里塔·阿德夫在这里看着你。
P30 SC-DRF:折中方案

需要一个较弱的模型!
(<折中,画重点>)
如果我们很小心,则可以:
1. 允许进行许多有利可图的优化
2. 大多数开发人员在学习规范后都没有自杀倾向
3. 语言规范实际上是可读的

因此,为了符合实际情况中的性能模型,我们需要削弱它。

5.3、Java内存模型

P31 SC-DRF:JMM 表述 TL;DR;

JMM规定了语言允许的结果
JMM定义了操作。 操作从中取值:例如,read(x,1)“表示我们的确从”x“中读取到了”1“
程序只允许读到操作想要的值
顺序操作在执行中汇总,有效执行产生所需的结果=>结果被允许

这是让事情变复杂的地方。由于语言规范应涵盖语言中可表达的所有可能,如果我们无法提供有限条数的解释,并保证这些解释可行,就会存在语义漏洞。
因此,JMM试图涵盖所有程序的可能。它通过描述抽象程序的操作,以及描述执行这些操作产生什么结果。操作在执行中被绑定在一起,执行将操作与的其他顺序描述操作关系组合在一起。这感觉像象牙塔式一样,所以我们马上去看看吧。

5.4、程序顺序(Program Order)



第一种顺序是程序顺序(PO),在单个线程中对操作进行排序。注意原程序,以及该程序可能的执行之一。在那里,程序从x中读到1,进入else分支,存储1到z,然后读取y的值。

程序顺序是总计(在一个线程内),即每对动作都与此顺序相关。了解一些事情很重要。
按程序顺序链接在一起的操作不排除“重新排序”。事实上,谈论行动的重新排序有点令人困惑,因为人们可能打算在程序中谈论声明重新排序,这会产生新的执行。那么这个新计划产生的执行是否违反了JMM的规定将是一个悬而未决的问题。
程序顺序没有,我再说一遍,不提供订购保证。它存在的唯一原因是提供可能的执行和原始程序之间的链接。

Comments
Write a Comment