深入理解JAVA内存模型

原文:https://shipilev.net/blog/2016/close-encounters-of-jmm-kind/
译者:初开,http://chukai.link

1.简介

两年前,我绞尽脑计的写了了JMM语用学会谈记录一文,希望告诉大家Java内存模型的不为人知的故事,让那些无法花费数年时间学习表面功夫的人,能从中获得实用的见解。JMM语用学已经帮助了很多人理解JMM,但是对于内存模型究竟在保证以及没保证什么仍然有很多困惑。

在这篇文章中,我们将通过实际案例,跟进有关Java内存模型的一些误解。这些示例使用jcstress套件中的API,这些API很简洁,并且可运行。

这是一篇相当长的文章,而在这个碎片化阅读的时代,本应该写成一个系列的,所以,我们只能假装下:如果你没时间/耐心阅读,请自行添加书签,下一次从你离开的地方继续。

这篇文章中的大多数例子来自我们多年来的公开和私下的讨论。我不会保证这涵盖所有内存模型的应用场景。如果你有一个有意思的案例在这里没有涉及到,不要犹豫,给我发邮件,看看是否能放进来。
这里有一个邮件列表concurrency-interest,你可以参与讨论。

1.1Java并发压力测试

jcstress的AP很简单,如下示例:

@JCStressTest
@State
@Outcome(id = "1, 2", expect = ACCEPTABLE, desc = "线程1更新, 线程2更新.")
@Outcome(id = "2, 1", expect = ACCEPTABLE, desc = "线程2更新, 然后线程1更新.")
@Outcome(id = "1, 1", expect = ACCEPTABLE, desc = "线程1和线程2同时更新.")
class VolatileIncrementAtomicityTest {
  volatile int v;

  @Actor
  void actor1(IntResult2 r) {
    r.r1 = ++v;
  }

  @Actor
  void actor2(IntResult2 r) {
    r.r2 = ++v;
  }
}

jcstress将用两个线程执行方法actor1和actor2,有时一个线程访问一个方法,有时同时访问一个方法,
通过许多VolatileIncrementAtomicityTest的实例,执行产生的结果或样本,并赋值到IntResult2中,我们可以来探讨volatile自增的原子性。

运行此测试的结果是:

[OK] net.shipilev.jmm.VolatileIncrementAtomicityTest
(fork: #1, iteration #1, JVM args: [-server])
Observed state   Occurrences   Expectation  Interpretation
          1, 1     1,543,069    ACCEPTABLE  线程1和线程2同时更新.
          1, 2    29,034,989    ACCEPTABLE 线程1更新, 线程2更新.
          2, 1    26,223,172    ACCEPTABLE  线程2更新, 线程1更新.

其中1, 1所显示的是-这是两个线程在同一个volatile字段上碰撞了,并且没有原子地自增。
即使1, 1的场景不存在,也不意味着volatile自增是原子的。并发性测试本质上是概率事件,即使测试失败,也不代表不存在,没看到证据并不代表没有证据。

1.2 硬件和运行时模式

由于并发性测试是概率性的,有三个问题需要理解:
a.测试应尽可能快,收集更多样本。样本越多,检测出至少一个结果的机会就越多。虽然可以花几个小时内运行一次测试,但对于数千次测试来说这是不切实际的。因此,基础设施要尽可能的快。在上面的示例中,运行5秒获得了数千万样本,每个样本耗时仅100ns,瓶颈主要在volatile字段赋值时。jcstress通过优化运行器来处理这个问题。

在这篇文章中,使用jcstress的默认模式足够了。对于大多数场景,机器的速度无关紧要,尽管更快的机器有更多的实验数据。
b. 即使我们按顺序编写的测试用例,但因为处理器架构之间的排序规则不同,程序仍可能无序执行,这会让测试变得复杂。在一个平台上测试可能在另一个平台上失败。
在这篇文章中,将主要使用x86和POWER作为平台,但这里不讨论微处理器架构的实现细节,它们都有多核版本,并在两者上运行JVM也类似。OpenJDK在两个平台上都是免费提供的,可构建​​和运行。
c.优化编译器/运行时可以用不同的方式来运行程序,因此,一般测试需要尝试不同的运行模式。有时,一些结果是在瞬态模式下生成的(例如,一半代码在解释模式中运行,一半在编译模式下运行),因为运行时的不同部分的并发实现可能不同。使用随机编译模式有助于发现一些特殊的行为,在HotSpot中,我们使用-XX:+StressLCM -XX:+StressGCM选项(如果可用)随机化指令调度。
jcstress经常会运行数十种不同的配置,在这篇文章中,我们只展示部分案例的配置。

1.3 模型回顾

我们先回顾一下Java Memory Model。更详细的解释可以在“JMM语用学”中找到,所以如果发现读不懂,请先阅读JMM语用学

概览:Java内存模型(JMM)通过描述哪些操作符合规则来描述程序的输出结果。通过引入操作,操作顺序和一致性规则来确定哪些操作+顺序+约束构成有效执行。如果程序的结果可以通过某些有效执行来解释,那么在JMM下则允许该结果。
表面来看这么几个基本内容:

  1. 程序顺序(PO):定义每个线程内的操作顺序,即程序的单线程执行与其生成的指令操作之间的对应关系,程序指令与原始程序不一致的执行不能用于推断程序的结果。
  2. 同步顺序(SO):定义同步操作的顺序(volatile读/写,synchronized进入/退出等),它有两个重要的一致性规则:SO一致性和SO-PO一致性。
    1. SO一致性:在同步顺序中后出现的所有读取都会看到对该位置的最后一次写入,不允许在同步操作中产生其他结果。
    2. SO-PO一致性:单个线程中SO与PO一致。
    3. SO一致性和SO-PO一致性意味着所有符合规则的执行,同步动作看起来是顺序一致的。
  3. Synchronized顺序(Synchronized-with order)(SW):SO的一种,涵盖彼此“看到”的同步操作对,此顺序是不同线程之间的通讯桥梁。
  4. Happens-before顺序(HB):PO和SW的混合体,与SO不同,HB是部分顺序,仅涉及某些操作,而不是每对操作。此外,HB能够关联非同步操作,我们通过排序保证来涵盖所有重要操作。HB具有以下重要的一致性规则:
    1. HB一致性:每次读取都可以看到happens-before顺序中的最新写入(注意这里使用SO的对称性),或者没有按HB排序的任何其他写入(这允许竞争)。
  5. 因果关系规则:对其他符合要求的操作执行额外验证,以排除因果循环。这通过一个特殊的过程来验证,该过程是执行“执行中的操作”,并验证没有自我申辩的行为发生。
  6. final字段规则:这是模型的补充,并描述final字段的其他约束,例如在final字段存储与读取之间happens-before顺序。

再一次,如果你不理解本节中的内容,请先阅读JMM语用学,我们不会停下来讨论基础知识。

2.一般误解

2.1。神话:机器做我告诉他们做的事
第一项业务是语言规范之间的混淆,以及真正的硬件之间的混淆。阅读语言规则并认为它正是机器将要做的事情,这很容易,也很舒服。

但是,这是一个非常误导性的思考问题的方式。语言规范描述了执行程序的抽象机器的行为。模拟抽象机器的行为是运行时的工作。这里争论的焦点是兼容的运行时没有义务完全按照源代码中编写的程序编译程序。

实际需求要弱得多:运行时必须产生结果,好像存在支持结果的兼容抽象机器执行。运行时执行实际计算的内容取决于运行时。这都是烟雾和镜子。

购物车wtf做
例如,如果你写:

int m() {
int a = 42;
int b = 13;
int r = a + b;
return r;
}
...要求语言运行时实际上为所有三个局部变量分配存储,在那里存储值,加载它们,添加它们等等,这将是非常奇怪的。整个方法应该可以优化到这样的事情:

mov %eax, 55;
ret
实际上,它在大多数语言中都是可优化的,并且允许发生,因为执行的观察结果是抽象机器执行的结果之一。只要程序不能调用运行时的虚张声势(=检测语言规范不允许的东西),运行时就可以自由地进行覆盖。

高级语言中描述 的意图与现实中发生的事情之间的明显脱节是高级语言成功的基石。通过抽象出物理现实的混乱,程序员可以专注于重要的事情:正确性,安全性和令人愉悦的客户,具有像素完美的渲染。
当你调试程序,调试器试图重建的幻想例如,为了获得一步一步的调试,观察局部变量的值等在Java中,通常调试器观察抽象渣机的状态,而不是JVM的内部工作。如果与之关联的信息不足以重建Java机器状态,则有时需要对生成的代码进行去优化。
类似地,当JMM说(例如)程序操作绑定到同步顺序时,并不意味着实际的物理实现应该将这些加载和存储发送到机器代码!

例如,如果我们有一个程序:

volatile int x;
void m() {
x = 1;
x = 2;
System.out.println(x);
}
......这个程序实际上可以优化为:

mov %eax, 2 # first argument
call System_out_println
尽管规范说明了动作 (x = 1),(x = 2)并且(r1 = x)同步动作与同步顺序相关联,但是,这并不意味着实际运行时必须执行所有程序操作。然而,大多数运行时都是如此,因为这种分析 - 一个人是否应该能够观察(x = 1) - 通常是相当复杂的。

只要运行时可以保持抽象机器语义的外观,就可以自由地进行复杂的转换。缺乏运行时执行某些特定合理转换的经验证据不能作为缺少任何类似转换的证据。
编写可靠的软件应该基于实际的语言保证,而不是基于当今语言运行时所做的轶事观察。一旦你开始依赖轶事,准备受苦。
2.2。神话:JSR 133 Cookbook是JMM简介
相当多的人都被抽象的JMM规则所淹没,他们将目光投向JSR 133 Cookbook for Compiler Writers。所有这些甜蜜,易于理解的障碍比正式模型的雕像更容易掌握。因此,许多人大胆地建议Cookbook是Java Memory Model的简要描述(甚至是简短的等价)。

但你有没有看过那里的序言?

......许多人没有。 虽然本指南保持准确,但有关这些不断发展的细节尚不完整。 ...对于编译器和JVM ...我们无法保证解释是正确的。
JSR 133 Cookbook是实现JMM的可能但保守的规则之一。只要满足JMM要求,符合实现的可能方法之一就是不必遵循Cookbook。保守意味着它不会进入模型的复杂性,而是提供一个非常简单但粗略的实现。实际使用可能会不必要地强大。我们可以更深入地了解我们的保守主义,并且仍然可以实现符合JMM的实现:确保JVM在单个核心上运行,或者具有全局解释器锁定,然后并发性是微不足道的。

撰写Cookbook是为了帮助实际的编译器编写者快速提出符合要求的实现。您是编译器编写者,正在寻找实施指南吗?没有?认为这样。然后移动。消化JSR 133 Cookbook之后发生的坏事就是你开始相信......

2.3。神话:障碍是严峻的心理模型
购物车更多填充
......事实上,它们不是:它们只是一个实现细节。屏障作为心理模型不可靠的最简单示例是以下简单的测试用例,其中包含两个背靠背synchronized语句:

@JCStressTest
@State
public class SynchronizedBarriers {
int x, y;

@Actor
void actor() {
synchronized(this) {
x = 1;
}
synchronized(this) {
y = 1;
}
}

@Actor
void observer(IntResult2 r) {
// Caveat: get_this_in_order()-s happen in program order
r.r1 = get_this_in_order(y);
r.r2 = get_this_in_order(x);
}
}
天真地,您可能认为该1, 0案例是被禁止的,因为synchronized部分应该按照与程序顺序一致的顺序执行。

当然,如果不按顺序保持读取,结果1, 0很容易实现。但这并不是一个有趣的测试案例。实际测试很聪明:它使用新的VarHandles “不透明”访问模式,它禁止这些优化并以相同的顺序将读取暴露给硬件:

private static final VarHandle VH_X, VH_Y;

static {
try {
VH_X = MethodHandles.lookup().findVarHandle(Test.class,
"x", int.class);
VH_Y = MethodHandles.lookup().findVarHandle(Test.class,
"y", int.class);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}

@Actor
public void observer(IntResult2 r) {
r.r1 = (int) VH_Y.getOpaque(this);
r.r2 = (int) VH_X.getOpaque(this);
}
查找VarHandle字段
this使用“不透明”访问模式从对象获取关联的字段值
对于非内联get_this_in_order()方法,您可能会获得类似的效果,这些方法对于今天的优化器也是不透明的。与不重新排序读取的硬件相结合,您可以在程序顺序中满足读取要求。如果你想在面对较弱的硬件时更加安全,你可以在负载之间发出一个完整的屏障,尽管它会通过屏障相互作用来淹没水域。然而,这个例子的要点是看看作者方面发生了什么,假设一切都按读者方顺序发生。不要忽视作者方面,在读者方面追逐技术性。

让我们看看代码语义告诉我们什么障碍。在伪代码中,这将做:

void actor() {
[LoadStore] // between monitorenter and normal store
x = 1;
[StoreStore] // between normal store and monitorexit
[StoreLoad] // between monitorexit and monitorenter

[LoadStore] // between monitorenter and normal store
y = 1;
[StoreStore] // between normal store and monitorexit
[StoreLoad] // between monitorexit and monitorenter
}

void observer() {
// Caveat: get_this_in_order()-s happen in program order
r.r1 = get_this_in_order(y);
r.r2 = get_this_in_order(x);
}
是的,似乎很好。x = 1不能过去y = 1,因为它会在很久之前遇到障碍。

然而,JMM本身允许观察1, 0,因为读取x和y不依赖于任何排序约束,因此存在合理的执行来证明观察1, 0。更正式地说,无论你能想象的符合执行,读取x和y不是同步动作,因此SO规则不适用于诱导动作。读取没有绑定到HB,因此没有HB规则阻止读取racy值。观察中1, 0也没有因果关系循环。

在模型中允许此行为是故意的,原因有两个。首先,硬件应该能够以任何想要最大化性能的顺序执行独立操作。其次,这可以实现有趣且重要的优化。

例如,在上面的示例中,我们可以合并背对背锁:

void actor() {
synchronized(this) {
x = 1;
}
synchronized(this) {
y = 1;
}
}

void actor() {
synchronized(this) {
x = 1;
y = 1;
}
}
......这提高了性能(因为锁获取成本很高),并允许在synchronized块内进一步优化。值得注意的是,由于写入x和y独立,我们可能允许硬件以任意顺序执行它们,或允许优化器转移它们。

如果您在实际的JVM和硬件上运行上面的示例,这就是在x86上使用JDK 9“fastdebug”构建(需要访问指令调度模糊测试)所发生的情况:

[确定] net.shipilev.jmm.LockCoarsening
(fork:#1,iteration#1,JVM args:[ - server,-XX:+ UnlockDiagnosticVMOptions,-XX:+ StressLCM,-XX:+ StressGCM])
观察到的状态发生期望解释
0,0 4B58,372可接受所有其他情况均可接受。
0,1 22,512可接受所有其他情况均可接受。
1,0,5 1,565 ACCEPTABLE_INTERESTING X和Y以不同的顺序可见
1,1,372,341可接受所有其他情况均可接受。
注意有趣的情况,那是我们的1, 0。惊喜!

-XX:-EliminateLocks通过将此有趣案例的出现次数减少为零来禁用锁定优化:

[确定] net.shipilev.jmm.LockCoarsening
(fork:#1,iteration#1,JVM args:[ - server,-XX:+ UnlockDiagnosticVMOptions,-XX:+ StressLCM,-XX:+ StressGCM,-XX:-EliminateLocks])
观察到的状态发生期望解释
0,0 52,892,632可接受所有其他情况均可接受。
0,1 163,611可接受所有其他情况均可接受。
1,0 0 ACCEPTABLE_INTERESTING X和Y以不同的顺序可见
1,1,885,907可接受所有其他情况均可接受。
在POWER上,即使没有弄乱指令调度,也会出现有趣的情况,因为硬件保证较弱:

  [确定] net.shipilev.jmm.LockCoarsening
(fork:#1,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
0,0 7,899,607可接受所有其他情况均可接受。
0,1 4,089可接受所有其他情况均可接受。
1,062 ACCEPTABLE_INTERESTING X和Y以不同的顺序可见
1,140,​​682可接受所有其他情况均可接受。
此示例并不意味着可以枚举所有“危险”优化并禁用它们。现代优化器可用作复杂的图形匹配和运算机器,并且可靠地禁用特定类型的优化通常意味着完全禁用优化器。
围绕运行时正在制定或将来会选择的障碍还有其他类型的合理优化。甚至JSR 133 Cookbook也有“删除障碍”部分,简要概述了可以使用的省略技术。

鉴于此,如果它们通常可以移除,您如何信任障碍?

障碍是实施细节,而不是行为规范。使用它们解释并发代码的语义充其量是危险的,并使您在特定的运行时实现中保持整齐锁定。
2.4。神话:重新排序和“致力于记忆”
混淆的第二部分是承诺记忆的概念。Java 5之前的内存模型具有“线程本地缓存”和“主内存”的概念。“冲到主要记忆”有一些意义。在新的Java 5后模型中,情况并非如此。然而,虽然大多数人继续前进,但他们仍然隐含地依赖“主记忆”抽象作为他们心理模型的基础。那个天真的心理模型说:当操作最终提交到内存时,内存会将其序列化。

这种直觉在两个方面被打破了。首先,内存已经异步,因此无法保证序列化。其次,大多数有趣的事情都发生在缓存一致性的微观尺度上,而不是“实际的”主存储器上。

这里出现了“独立写入独立读取”(IRIW)测试。这很简单:

@JCStressTest
@State
class IRIW {
int x;
int y;

@Actor
void writer1() {
x = 1;
}

@Actor
void writer2() {
y = 1;
}

@Actor
void reader1(IntResult4 r) {
r.r1 = x;
r.r2 = y;
}

@Actor
void reader2(IntResult4 r) {
r.r3 = y;
r.r4 = x;
}
}
但是这个例子对你理解并发的方式很有深远意义。首先,考虑结果(r1, r2, r3, r4) = (1, 0, 1, 0)是否合理。有人可能会拿出“重新排序”的解释:重新排列r3 = y和r4 = x,其结果是平凡实现的。

那么,既然我们知道麻烦在哪里,那就让我们添加围栏来抑制那些讨厌的重新排序:

@JCStressTest
@State
public class FencedIRIW {

int x;
int y;

@Actor
public void actor1() {
    UNSAFE.fullFence(); 
    x = 1;
    UNSAFE.fullFence(); 
}

@Actor
public void actor2() {
    UNSAFE.fullFence(); 
    y = 1;
    UNSAFE.fullFence(); 
}

@Actor
public void actor3(IntResult4 r) {
    UNSAFE.loadFence(); 
    r.r1 = x;
    UNSAFE.loadFence(); 
    r.r2 = y;
    UNSAFE.loadFence(); 
}

@Actor
public void actor4(IntResult4 r) {
    UNSAFE.loadFence(); 
    r.r3 = y;
    UNSAFE.loadFence(); 
    r.r4 = x;
    UNSAFE.loadFence(); 
}

}
为了以防万一,在商店周围做完全围栏。
在负载周围加载围栏,将它们固定到位。有点过分但安全,你不觉得吗?
然后在POWER上运行它:

  [确定] net.shipilev.jmm.FencedIRIW
(fork:#7,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
...
1,0,1,0 47可接受的线程以不一致的顺序查看更新
...
该死的!但那怎么可能呢?我们使用那些抑制重新排序的魔法围栏!

麻烦来自于一些机器,特别是POWER不保证多拷贝原子性:“所有处理器都看到写入,或者没有处理器(还)。” [ 4 ] 即使你在商店周围放置围栏,没有这种财产也排除了商店总订单的存在。

一个有趣的花絮:围栏通常被指定为禁止本地重新排序,但我们需要的是强制执行全局顺序的东西,例如静止处理器间互连,或其他阴暗的东西!在POWER中,有hwsync说明。有点意外,也fullFence映射到这个指令。loadFence用fullFencein 替换FencedIRIW将消除不必要的结果 - 但这将是纯粹的附带属性fullFence!
购物车袋鼠
如果我们volatile进入IRIW示例,那么JVM将采取额外的步骤来保持顺序一致性。[ 5 ]值得注意的是,hwsyncs在挥发性读数之前。因此,这个例子就像一个魅力:

@JCStressTest
@State
public class VolatileIRIW {
volatile int x, y;

@Actor
public void actor1() {
    x = 1;
}

@Actor
public void actor2() {
    y = 1;
}

@Actor
public void actor3(IntResult4 r) {
    r.r1 = x;
    r.r2 = y;
}

@Actor
public void actor4(IntResult4 r) {
    r.r3 = y;
    r.r4 = x;
}

}
这个例子对围栏的所有用户说“好运”。除非您非常详细地了解硬件,否则您的代码可能只是偶然的。通过构建自己的低级同步来避免语言保证需要比经验丰富的人员拥有的知识更多的知识。[ 6 ]考虑一下你们自己的警告。
2.5。神话:提交语义=致力于记忆
“但是,嘿,Aleksey,看到规范的一部分实际上提出了提交!”:

几乎白噪声是人们在规范文本中实际看到的
可悲的是,第一次阅读这一章,你可以获得隧道视觉,满足熟悉的“提交”一词,为自己赋予自己的意义,并忽略本章的其余部分。JLS 17.4.8中的“提交”与提交内存无关,它是正式验证方案的一部分,它试图验证执行中没有自我纠正的动作循环。此验证方案通常不排除比赛,不排除观察不同的写入,或以非直观的顺序观察它们。它只排除了一些选定的坏循环。

2.6。神话:同步和挥发物完全不同
现在,对于完全不同的东西。当我与人们谈论记忆模型时,我常常惊讶于许多人错过了synchronized和之间的美丽对称volatile。两者都引发同步动作。解锁和volatile写入在他们的“释放”操作中类似。锁定和volatile读取在他们的“获取”操作中类似。这种对称性允许在volatile-s 上显示示例,并且几乎立即到达类似的示例synchronized。

例如,记忆效果方面,这些代码块是等效的:

class B {
T x;
public void set(T v) {
synchronized(this) {
x = v;
} // "release" on unlock
}

public T get() {
synchronized(this) { // "acquire" on lock
return x;
}
}
}
class B {
volatile T x;
public void set(T v) {
x = v; // "release" on volatile store
}

public T get() {
return x; // "acquire" on volatile load
}
}
这种对称性允许构造锁:

int a, b, x;

synchronized(lock) {
a = x;
b = 1;
}
int x;
volatile boolean busyFlag;

while (!compareAndSet(lock.busyFlag, false, true)); // burn for the mutual exclusion Gods
a = x;
b = 1;
lock.busyFlag = false;
第二个块是一个合理的synchronized部分实现(可以,没有等待/通知语义):它会在锁定获取时浪费资源自旋循环,但它是正确的。

3.陷阱:小偏差很好
本节介绍用户所犯的基本错误,并说明如何稍微偏离规则会产生破坏性后果。

3.1。陷阱:非同步是好的
让我们采用volatile增量原子性示例并略微修改它:

@JCStressTest
@State
public class VolatileCounters {
volatile int x;

@Actor
void actor1() {
for (int i = 0; i < 10; i++) {
x++;
}
}

@Actor
void actor2() {
for (int i = 0; i < 10; i++) {
x++;
}
}

@Arbiter
public void arbiter(IntResult1 r) {
r.r1 = x;
}
}
在jcstress中,@Arbiter方法在两个@Actor-s完成工作后执行。在所有并发更新之后,这对于断言最终状态非常有用。
直观地,通过查看Java并发压力测试中的非循环示例,您可以想象涉及两个线程的冲突可能会错过更新。第二个直观的延伸是你只能丢失更新,但你永远不会退缩。值得注意的是,如果您询问人们上述测试的可能结果是什么,大多数人会回答“介于10到20之间的某个地方”。

一只手推车
但如果你运行测试,那么:

  [确定] net.shipilev.jmm.VolatileCounters
(fork:#1,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
10 153,217可接受$ x $ y
11 273,440可接受$ x $ y
12 465,262可接受$ x $ y
13 611,123可接受$ x $ y
14 810,790可接受$ x $ y
15 1,139,737可接受$ x $ y
16 1,189,164可接受$ x $ y
17 1,163,565可接受$ x $ y
18 1,149,772可接受$ x $ y
19 986,010可接受$ x $ y
20 7,449,917可接受$ x $ y
6 4可接受的$ x $ y
7 6,442可接受$ x $ y
8 23,762可接受$ x $ y
9 66,175可接受$ x $ y
搞什么鬼?破碎的JVM!破碎的硬件!为什么我的非同步计数器不起作用?

但是,请考虑这个事件的时间表:

线程1:(0 ----------------------> 1)(1-> 2)(2-> 3)(...)(9- > 10)
线程2:(0-> 1)(1-> 2)(...)(8-> 9)(1 ---------------------- ----> 2)
在这里,第一个线程在第一次更新时卡住,并且在获得unstuck之后销毁了第二个线程的所有九个更新的结果。但是,它通常会赶上来,对吗?如果第二个线程在其最后一次迭代中读取“1”并且也被卡住,那么最终会相应地破坏第一个线程的更新。这给我们留下了“2”作为最终结果。

当然,当您需要更长的干扰窗口时,这种干扰的可能性会降低:这就是为什么我们的经验测试结果会在“6”时切断。如果我们获得更多样本,我们最终也会看到更低的值。

这是一个完美的例子,不正确同步的代码如何产生奇怪的不直观的结果。非原子计数器可能会遇到线程之间的灾难性干扰,将其设置为丢失几乎无限数量的更新。
3.2。陷阱:半同步很好
对模型的最常见和最有影响的误解是非常令人心碎的。令人惊讶的是,即使在详细研究了内存模型之后,它仍然存在。再次考虑这个例子:

class Box {
int x;
public Box(int v) {
x = v;
}
}

class RacyBoxy {
Box box;

public synchronized void set(Box v) {
box = v;
}

public Box get() {
return box;
}
}
太多人会点头对你的JMM解释,然后说这段代码是正确同步的。他们的推理是这样的:参考商店是原子的,因此没有必要关心其他任何事情。这个推理错过的是这里的问题不是关于访问原子性(例如,你是否可以看到引用本身的非完整版本),而是订单约束。实际失败的原因是读取对象的引用和读取对象的字段在内存模型下是不同的。

因此,鉴于此测试,您确实需要问自己:

@JCStressTest
@State
public class SynchronizedPublish {
RacyBoxy boxie = new RacyBoxy();

@Actor
void actor() {
boxie.set(new Box(42)); // set is synchronized
}

@Actor
void observer(IntResult1 r) {
Box t = boxie.get(); // get is not synchronized
if (t != null) {
r.r1 = t.x;
} else {
r.r1 = -1;
}
}
}
......结果0有道理吗?JMM说是的,因为有符合条件的执行Box从中读取引用RacyBoxy,然后0从其字段读取。

也就是说,在x86上运行示例几乎总会导致这样:

  [确定] net.shipilev.jmm.SynchronizedPublish
(fork:#1,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
-1 43,465,036 ACCEPTABLE尚未准备好
0 0 ACCEPTABLE字段尚未显示
42 1,233,714可接受一切都是可见的
万岁?拿那个,记忆模型!但是让我们在POWER上运行它:

  [确定] net.shipilev.jmm.SynchronizedPublish
(fork:#1,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
-1 362,286,539 ACCEPTABLE尚未准备好
0 2341 ACCEPTABLE字段尚不可见
42 616,150可接受一切都是可见的
哎呀,有我们可以接受的结果。请注意,它与破碎的心智模型相反,后者说“同步会在最后发出内存屏障,因此非同步读取很好”。内存一致性需要双方的合作。

编写可靠的软件应该基于实际的语言保证,而不是轶事观察今天硬件在做什么,假设你甚至知道将来会运行什么硬件。一旦你开始只依靠经验测试,就要做好准备。
3.3。陷阱:添加挥发物更接近问题的帮助
如果我们添加一些volatile-s,上面的例子是可以修复的。但重要的是知道在哪里准确添加它们。例如,您可能会看到如下示例:

@JCStressTest
@State
public class SynchronizedPublish_VolatileMeh {
volatile RacyBoxy boxie = new RacyBoxy();

@Actor
void actor() {
boxie.set(new Box(42));
}

@Actor
void observer(IntResult1 r) {
Box t = boxie.get();
if (t != null) {
r.r1 = t.x;
} else {
r.r1 = -1;
}
}
}
哦,是的,完全合法的,让我们把它放在那里
但是,这是不正确的:模型只保证在实际的volatile 存储和观察该存储的实际volatile 载荷之间发生。使容器本身易变不会有帮助,因为没有易失性写入来匹配读取。所以SynchronizedPublish_VolatileMeh以同样的方式失败了SynchronizedPublish。这将不仅有助于把volatile过RacyBoxy.box场,让一个volatile在商店RacyBoxy.set将与匹配volatile负载RacyBoxy.get。

出于同样的原因,这个例子有令人惊讶的结果:

@JCStressTest
@State
class VolatileArray {
volatile int[] arr = new int[2];

@Actor
void actor() {
int[] a = arr;
a[0] = 1;
a[1] = 1;
}

@Actor
void observer(IntResult2 r) {
int[] a = arr;
r.r1 = a[1];
r.r2 = a[0];
}
}
volatile 阵列,最好是好的!
volatile 商店?
虽然阵列本身volatile,读取和写入数组元素就不能有volatile语义。因此,结果1, 0似乎是合理的。

它可以在POWER上清楚地展示:

  [确定] net.shipilev.jmm.VolatileArray
(fork:#1,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
0,0 704,015可接受其他一切也是可以接受的。
0,1 1,291可接受其他一切也是可以接受的。
1,018 ACCEPTABLE_INTERESTING订购?你希望。
1,1,3,136,486可接受其他一切也是可以接受的。
高级API,Atomic{Integer,Long,Reference}Array如果需要,提供易失性语义。从JDK 9开始,VarHandles也提供各种高效的内存访问模式。
3.4。陷阱:以错误的顺序发布
很惊讶地看到有多少人浏览了合成示例,并且没有将它们映射到他们的日常代码中。举个例子:

@JCStressTest
@State
public class ReleaseOrderWrong {
int x;
volatile int g;

@Actor
public void actor1() {
    g = 1;
    x = 1;
}

@Actor
public void actor2(IntResult2 r) {
    r.r1 = g;
    r.r2 = x;
}

}
在这里,结果1, 0可以凭经验观察到,这看起来像是违规发生之前。是什么给出的:我们没有观察到易变的读数g = 1,我们为什么不观察x = 1?答案是释放顺序错误:我们必须做“一些写入”→易失性写入→易失性读取→“一些读取”,以保证“一些读取”看到“一些写入”。在这个例子中,g = 1并且x = 1顺序错误。有问题的结果甚至可以通过顺序一致的执行来解释!

许多人会笑掉这个例子,然后继续这样做:

public class MyListMyListMyListIsOnFire {

private volatile List list;

void prepareList() {
list = new ArrayList();
list.add(1);
list.add(2);
}

List getMyList() {
return list;
}
}
在这里,首先是易失性写入list,然后是更新。如果您认为保证呼叫者可以getMyList看到整个列表内容,请回到上面的激励示例,并再次思考!

购物车大学证书
这个失败很容易在x86上演示:

@JCStressTest
@State
public class ReleaseOrder2Wrong {

volatile List<Integer> list;

@Actor
public void actor1() {
    list = new ArrayList<>();
    list.add(42);
}

@Actor
public void actor2(IntResult1 r) {
    List<Integer> l = list;
    if (l != null) {
        if (l.isEmpty()) {
            r.r1 = 0;
        } else {
            r.r1 = l.get(0);
        }
    } else {
        r.r1 = -1;
    }
}

}
...收益率:

[确定] net.shipilev.jmm.ReleaseOrder2Wrong
(fork:#1,迭代#1,JVM args:[ - server])
观察到的状态发生期望解释
-1 65,119,848 ACCEPTABLE读取空列表
0 252,169 ACCEPTABLE_INTERESTING列表未完全填充
42 1,980,313可接受阅读填写完整的清单
哎呀。

3.5。陷阱:以错误的顺序获取
对称的情况是当你以不同的顺序观察时:

@JCStressTest
@State
public class AcquireOrderWrong {
int x;
volatile int g;

@Actor
public void actor1() {
    x = 1;
    g = 1;
}

@Actor
public void actor2(IntResult2 r) {
    r.r1 = x;
    r.r2 = g;
}

}
购物车观察错误
[确定] net.shipilev.jmm.AcquireOrderWrong
(fork:#1,迭代#1,JVM args:[ - server])
观察到的状态发生期望解释
0,0,6,839,389可接受所有其他情况均可接受。
0,1 579可接受所有其他情况均可接受。
1,041,053可接受所有其他情况均可接受。
1,140,​​122,239可接受所有其他情况均可接受。
在这种情况下,结果0, 1是完全合理的,并且也可以通过顺序地执行一致的解释:r.r1 = x(读取0), , x = 1,g = 1(r.r2 = g读取1)。再一次,很容易笑掉这个例子,然后发现隐藏在更复杂的生产代码中的实际错误。保持警惕!

3.6。陷阱:以错误的顺序获取和释放
将以前的两个陷阱合并为一个主要陷阱,通过同时获取和释放错误:

@JCStressTest
@State
public class AcquireOrderWrong {
int x;
volatile int g;

@Actor
public void actor1() {
    g = 1;
    x = 1;
}

@Actor
public void actor2(IntResult2 r) {
    r.r1 = x;
    r.r2 = g;
}

}
...也产生了一个有趣的案例。在这里,结果(1, 0) 无法通过任何顺序一致的执行来解释!但当然,这是一场赤裸裸的数据竞赛,这是我们的“坏”结果:

[确定] net.shipilev.jmm.AcquireReleaseOrderWrong
(fork:#1,迭代#1,JVM args:[ - server])
观察到的状态发生期望解释
0,0 108,771,152可接受所有其他情况均可接受。
0,1 1,137,881可接受所有其他情况均可接受。
1,015,218可接受所有其他情况均可接受。
1,129,451,719可接受所有其他情况均可接受。
3.7。避免陷阱
您甚至可以从Java内存模型中学到的最重要的事情是安全发布的概念。对于挥发物,它是这样的:

发布
请注意注意事项:偏离它们只是略微保留,如本节中的示例所示。[ 7 ] [ 8 ]这是一个黄金的例子:

@JCStressTest
@Outcome(id = "1, 0", expect = Expect.FORBIDDEN, desc = "Happens-before violation")
@Outcome( expect = Expect.ACCEPTABLE, desc = "All other cases are acceptable.")
@State
public class SafePublication {

int x;
volatile int ready;

@Actor
public void actor1() {
    x = 1;
    ready = 1;
}

@Actor
public void actor2(IntResult2 r) {
    r.r1 = ready;
    r.r2 = x;
}

}
特殊字段的命名选项有助于快速诊断源代码中的问题。了解如何调用volatile上面的字段可以ready清楚地突出显示正确的操作顺序。
......它确实没有在所有平台上显示“坏”结果。这是POWER,弱硬件模型,因为语言保证不匹配:

  [确定] net.shipilev.jmm.SafePublication
(fork:#1,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
0,0 69,358,115可接受所有其他情况均可接受。
0,1 2,402,453可接受所有其他情况均可接受。
1,0 0 FORBIDDEN发生违规行为
1,144,989,512可接受所有其他情况均可接受。
使用对称性synchronized,我们可以使用Java锁构造类似的情况。

一厢情愿的想法:我一边抱着我的啤酒......
本节介绍了更高级的JMM用户的常见滥用行为,并解释了它们无法正常工作的原因。

4.1。一厢情愿的想法:我的代码一切都发生过 - 以前!
首先,关于措辞的一点警告。你可以经常看到人们用“发生在之前”的手势,就像没有明天一样。值得注意的是,面对这段代码:

int x;
volatile int g;

void m() {
x = 1;
g = 1;
}

void r() {
int lg = g;
int lx = x;
}
推车发生在之前
......他们说这g = 1发生在以前int lg = g。这条列车进一步破坏了推理,从逻辑上得出结论int lx = x总是会看到x = 1(因为x = 1hb g = 1,而且int lg = ghb int lx = x也是如此)。这是一个非常容易犯的错误,你必须记住,之前发生的事情(以及JMM形式主义中的其他命令)应用于操作,而不是语句。

具有不同的动作名称有助于区分动作和语句。我喜欢用这个命名法:write(x, V)将值写入V变量x; 并从变量中read(x):V读取值。在那个术语中,你可以说发生在之前,因为现在你描述了实际的行动,而不仅仅是一些抽象的程序陈述。Vxwrite(g, 1)read(g):1

为了完整起见,这些是JMM下的有效执行:

write(x, 1)→ HB write(g, 1) ... read(g):0→ HB read(x):0

write(x, 1)→ HB write(g, 1) ... read(g):0→ HB read(x):1

write(x, 1)→ hb write(g, 1) → hb read(g):1 → hb read(x):1

这个执行已经打破了HB的一致性,read(x)应该观察最新的写入x,但它不会:write(x, 1)→ hb write(g, 1) → hb read(g):1 → hb read(x):0

请注意,良好的API规范会谨慎地谈论操作及其与实际可观察​​事件的关联。例如java.util.concurrent包规范说:

java.util.concurrent及其子包中所有类的方法将这些保证扩展到更高级别的同步。特别是:由Future表示的异步计算所采取的操作发生在通过另一个线程中的Future.get()检索结果之后的操作之前。
4.2。一厢情愿:以前发生的实际订购
现在,订单的概念在某种程度上破坏了人们的思想,假设JMM规范中使用的集合论的“顺序”某种程度上与物理执行顺序有关。值得注意的是,人们声称,如果两个动作按程序顺序排列,那就是它们正在执行的顺序(这排除了任何优化!); 或者如果它们在之前发生过,那么它们也会在发生之前执行 - 在订单之前执行(由于HB是PO的扩展,这也排除了任何优化)。

它涉及荒谬的例子,像这样:

@JCStressTest
@State
public class ReadAfterReadTest {
int a;

@Actor
void actor1() {
a = 1;
}

@Actor
void actor2(IntResult2 r) {
r.r1 = a;
r.r2 = a;
}
}
由于需要避开编译器优化,实际测试更复杂,但这个例子也可以。试想一下,编译器不会合并常见的读取a。

是1, 0结果是否可信?例如,如果我们1已经阅读过,我们0下次可以阅读吗?JMM说我们可以,因为产生这种结果的执行不违反内存模型要求。非正式地,我们可以说,每个读取都是孤立地做出关于特定读取可以观察到的内容的决定。由于两个读数都是活泼的,两者既可能返回0或1。

即使在其他强大的硬件上,例如x86,也可以证明这一点:

[OK] oojtvolatiles.ReadAfterReadTest
(fork:#1,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
0,0,6636,450可接受两者都提前读取。
0,1 3,941可接受做早读,不足为奇。
1,084,477 ACCEPTABLE_INTERESTING第一次看到早期的racy值,并且s ...
1,18,816,262可接受两者都读得很晚。
之前发生顺序是唯一有用的之前发生的一致性规则,它描述了写入特定的读取可以观察。它没有强制要求任何特定的物理行为顺序。例如,它不强制执行任意操作的物理执行顺序。
这违背直觉,而事实上,很多人认为,你不能看到1和再看看0在两回至后端从同一位置读取。这个论点取决于“当时”的定义,对于大多数人来说,它包括直观/天真的时间模型,它被程序顺序污染。但是在Java内存模型中,“then”是由部分发生前的顺序(对于某些配对的写 - 读操作)及其一致性规则定义的,而不是由程序顺序本身定义的。因此,在这里考虑以任何特定顺序发生的两个独立读取是没有用的。

a使用volatile修饰符 标记字段会排除1, 0结果,因为同步顺序一致性规则将对两个读取都执行操作。这将得出结论,如果第一次读取看到x = 1写入,则第二次读取也应该。
4.3。一厢情愿:同步订单是实际订购
接下来,一个更具形而上学的问题。同步顺序(SO)被指定为操作的总顺序。回到类似IRIW的例子:

@JCStressTest
@State
class IRIW {
volatile int x, y;

@Actor
void writer1() {
x = 1;
}

@Actor
void writer2() {
y = 1;
}

@Actor
void reader1(IntResult4 r) {
r.r1 = x;
r.r2 = y;
}

@Actor
void reader2(IntResult4 r) {
r.r3 = y;
r.r4 = x;
}
}
我们知道(1, 0, 1, 0)SO规则排除了结果。但是如果我们采取两个“CPU恶魔”让他们观察运行读者的CPU的机器状态,这些CPU恶魔是否有可能拥有不一致的世界观?

推车攻击船
答案是:是的,当然,我们可以看到两个CPU都对首先发生的事件有了更好的理解:x = 1或者y = 1。因此,即使规范要求操作按顺序排列,实际的物理执行顺序也可能不同,具体取决于您的观察点。

话虽如此,我们应该问自己的问题不是“机器看到了什么?” ,但“机器允许程序看到什么?” 。有些机器隐藏了这些细节(可以说是性能付费),有些则没有。在这种情况下,需要运行时协作以避免观察不需要的状态。最后,运行时只允许查看与某些事件总顺序一致的结果,即使它在物理上是混乱的。

你想和混乱的硬件谈谈吗?不?然后使用语言记忆模型,让其他人来处理。
4.4。一厢情愿:未观察到的同步具有记忆效应
如果您尚未将其内化,则Java内存模型仅保证跨匹配版本的订购和获取。这意味着未观察到的发布/获取在内存模型下没有任何意义,并且不需要产生记忆效应。但很多时候,你会看到这样的代码:

void synchronize() {
synchronized(something) {}; // derp... memory barrier?
}
我们已经知道障碍是实现细节,运行时可能会省略。实际上,通过以下测试很容易证明这一点:

@JCStressTest
@State
public class SynchronizedAreNotBarriers {
int x, y;

@Actor
public void actor1() {
x = 1;
synchronized (new Object()) {} // o-la-la, a barrier.
y = 1;
}

@Actor
public void actor2(IntResult2 r) {
r.r1 = y;
synchronized (new Object()) {} // o-la-la, a barrier.
r.r2 = x;
}
}
是1, 0结果是否可信?天真地,使用同步屏障模型是禁止的。但是运行时很容易利用同步的简单事实new Object()没有效果,并清除它们。由此产生的优化代码不会有任何障碍。所以即使在x86上也会发生这种情况:

[确定] net.shipilev.jmm.SynchronizedAreNotBarriers
(fork:#1,迭代#1,JVM args:[ - server])
观察到的状态发生期望解释
0,0 2,705,391可接受所有其他情况均可接受。
0,1,40,709可接受所有其他情况均可接受。
1,0 13,356可接受的Racy读取x
1,161,341,794可接受所有其他情况均可接受。
车没有障碍
未观察到的synchronized区块不是障碍。实际上,您甚至无法依赖障碍理解,因为运行时可以在不咨询您的情况下利用优化机会。构建可靠软件的唯一方法是遵守语言规则,而不是您几乎不了解的阴暗技术。
4.5。一厢情愿:未观察到的挥发物具有记忆效应
一个类似的例子关注volatile-s。很有可能再次阅读JSR 133 Cookbook,并想象由于volatile实现意味着障碍,我们可以使用volatile来获得屏障语义!这应该完全有效,对吧?

@JCStressTest
@State
public class VolatilesAreNotBarriers {

static class Holder {
    volatile int GREAT_BARRIER_REEF;
}

int x, y;

@Actor
public void actor1() {
    Holder h = new Holder();
    x = 1;
    h.GREAT_BARRIER_REEF = h.GREAT_BARRIER_REEF;
    y = 1;
}

@Actor
public void actor2(IntResult2 r) {
    Holder h = new Holder();
    r.r1 = y;
    h.GREAT_BARRIER_REEF = h.GREAT_BARRIER_REEF;
    r.r2 = x;
}

}
如果你在现代HotSpot上运行它,那么你会发现-server(C2)编译器仍然留下障碍。但这似乎是一种实现效率低下的问题:当它清除Holder实例和volatile操作时,它在解析后不久就会失去实际存储与相关屏障之间的关联。

我的管道录制Graal运行显示Graal似乎消除了x86上的实例和相关障碍:反汇编显示没有障碍,并且演员方法的性能提高了10倍 - 但是,唉,我们还不能在POWER上运行它。但我们当然不希望将编译器丢在公共汽车下并说这是禁止的。

这实际上意味着挥发物和栅栏不易互换。在重新订购时,挥发物比围栏弱。当你需要获得顺序一致性等高级属性时,fences比volatile更弱,除非你把fullFence-s 放在任何地方。
4.6。一厢情愿:不匹配的行动是好的
从上面的例子中可以理解,释放一个变量,并在另一个变量上获取并不能保证带来记忆效应:

@JCStressTest
@State
public class YourVolatilesWillCallMyVolatiles {
int x, y;
volatile int see;
static volatile int BARRIER;

@Actor
void thread1() {
    x = 1;
    see = 1; // release on $see
    y = 1;
}

@Actor
void thread2(IntResult3 r) {
    r.r1 = y;
    r.r3 = BARRIER; // acquire on $BARRIER
    r.r2 = x;
}

}
结果1, 0是允许在这里。BARRIER真的不是障碍!某些VM实现仍然会在两次访问中发出相同的障碍,从而意外地提供所需的语义,但这不是语言保证。

如果你在JDK类库中看到这样的代码,那并不意味着你可以在没有悔恨的情况下使用这种方法。类库有时会对其运行的确切JVM进行假设,以便为JDK用户提供内存一致性保证。在第三方库中使用相同的代码非常脆弱。
4.7。一厢情愿的想法:final-s可以替换为volatile-s
让我们把final钉子钉在volatile-as-barrier棺材上。想象一下,你有一个类volatile在构造函数中初始化了一个字段。假设您通过竞赛发布此类的实例。你能保证看到设定值吗?例如:

@JCStressTest
@State
public class VolatileMeansEverythingIsFine {
static class C {
volatile int x;
C() { x = 42; }
}

C c;

@Actor
void thread1() {
c = new C();
}

@Actor
void thread2(IntResult1 r) {
C c = this.c;
r.r1 = (c == null) ? -1 : c.x;
}
}
购物车最终报价
......结果0在这里看似合理吗?“但是 - volatile太强了!”  - 可以说。“但是那里的障碍是如此强大!”  - 别人可以说。 “当因果关系阻止这种情况时,谁会关心障碍!”  - 别人会说。

事实是JMM允许在这个例子中看到0!只有final将妨碍0,我们不能有场都final和volatile。这是模型的一个独特属性,可以通过强制所有初始化作为final一个执行来解决,可能没有过高的性能成本。[ 11 ]

请注意,这是另一个例子,数据竞争是多么邪恶。在volatile场上自己修改也无法抑制的比赛,因为它是在错误的地方:如果它被释放/获取实例本身,一切都将正常工作。
4.8。一厢情愿:TSO机器保护我们
即使优化器不在图片中,代码生成中的细微细节也可能影响结果。许多人的一厢情愿是,具有全面商店订单(TSO)的机器正在拯救我们免受坏事。这几乎总是取决于这样的信念:如果你无法想象为什么编译器会弄乱你的代码,那么它就不会。这就产生了城市传说,x86不需要“安全初始化”,因为它将保留字段初始化和发布的顺序。

让我们构造一个简单的案例,其中一个具有四个字段的对象被初始化并通过竞赛发布:

@JCStressTest
@State
public class UnsafePublication {
int x = 1;

MyObject o; // non-volatile, race

@Actor
public void publish() {
    o = new MyObject(x);
}

@Actor
public void consume(IntResult1 res) {
    MyObject lo = o;
    if (lo != null) {
        res.r1 = lo.x00 + lo.x01 + lo.x02 + lo.x03;
    } else {
        res.r1 = -1;
    }
}

static class MyObject {
    int x00, x01, x02, x03;
    public MyObject(int x) {
        x00 = x;
        x01 = x;
        x02 = x;
        x03 = x;
    }
}

}
即使在x86上它也会产生有趣的行为,好像我们没有看到构造函数中的所有商店:

  [确定] net.shipilev.jmm.UnsafePublication
(fork:#1,迭代#1,JVM args:[ -  server])

观察到的状态发生期望解释
-1 86,515,664 ACCEPTABLE该对象尚未发布
0 751 ACCEPTABLE对象已发布,但所有字段均为0。
1 297 ACCEPTABLE对象已发布,至少可以看到1个字段。
2 211 ACCEPTABLE对象已发布,至少有2个字段可见。
3 953 ACCEPTABLE对象已发布,至少有3个字段可见。
4 4,057,524 ACCEPTABLE对象已发布,所有字段均可见。
这类失败不是理论上的!在实践中,安全发布或安全初始化(例如,制作所有字段final)将禁止这些中间结果。有关“安全构建和安全初始化”的更多信息,请参阅。

安全初始化是一种非常有用的模式,可以防止出版。不要在代码中忽略或偏离它!它可以为您节省数天的调试时间。
另一种可怕的一厢情愿的想法是一种看似简单的形式:你可能会错误地认为很容易消除这种有趣的结果。以本节为例,无论你做什么consume(),它都不会让你免于正在展开的比赛。如果您的出版商不与消费者合作,则所有投注都将被取消。

final-s可以保护发布者免受非合作的活跃消费者的影响,反之亦然。
4.9。一厢情愿:良性竞赛具有弹性
有特殊形式的良性种族(矛盾,如果你问我)。他们的良性来自安全初始化规则。他们通常采用以下形式:

@JCStressTest
@State
public class BenignRace {

@Actor
public void actor1(IntResult2 r) {
MyObject m = get();
if (m != null) {
r.r1 = m.x;
} else {
r.r1 = -1;
}
}

@Actor
public void actor2(IntResult2 r) {
MyObject m = get();
if (m != null) {
r.r2 = m.x;
} else {
r.r2 = -1;
}
}

MyObject instance;

MyObject get() {
MyObject t = instance; // read once
if (t == null) { // not yet there...
t = new MyObject(42);
instance = t; // try to install new version
}
return t;
}

static class MyObject {
final int x; // safely initialized
MyObject(int x) {
this.x = x;
}
}
}
这仅适用于安全地初始化类(即只有final字段),并且instance字段只读一次。在这个例子中,这两个条件对于比赛是良性的至关重要。如果任何一个条件放松,那么种族突然突然停止良性。

例如,放宽安全初始化规则会打开陷阱中描述的故障:半同步是好的:

@JCStressTest
@State
public class NonBenignRace1 {
...
static class MyObject {
int x; // WARNING: non-final
MyObject(int x) {
this.x = x;
}
}
}
通过设置Wishful Thinking中描述的失败:放松单一读取规则也会打破良性:实际订购之前发生的事情:

@JCStressTest
@State
public class NonBenignRace2 {
...
MyObject instance;

MyObject get() {
if (instance == null) {
instance = new MyObject(42);
}
return instance; // WARNING: Second read
}
...
}
这可能看似违反直觉:如果我们null从中读取instance,我们会采取纠正措施来存储新的instance,就是这样。实际上,如果我们有干预商店instance,我们看不到默认值,我们只能看到该商店(因为它发生在我们之前的所有符合规定的执行中,其中第一次读取返回null),或者来自其他线程的商店(不是null,只是一个不同的对象)。但是当我们没有null从instance第一次读取时读取时,有趣的行为就展开了。没有干预商店。第二次读取尝试再次阅读,并且可能是原样,可能会读null。哎哟。

这实际上可被Evil Optimizers利用:

T get() {
if (a == null) {
a = compute();
}
return a;
}
引入临时变量(例如,执行SSA转换,然后执行一些):

T get() {
T t1 = a;
if (t1 == null) {
T t3 = compute();
a = t3;
return t3;
}
T t2 = a;
return t2;
}
在没有干预写入的情况下,对独立读取没有排序约束,因此请阅读读取顺序。首先,观察一旦控制进入if分支,我们永远不会到达T t2 = a,因此介入的写入a是不可见的T t2 = a,在分支之前移动读取。其次,围绕独立洗牌读取T t1和T t2 -能做到这一点,独立写着:

T get() {
T t2 = a;
T t1 = a;
if (t1 == null) {
var t3 = compute();
a = t3;
return t3;
}
return t2;
}
这个平凡让你null在return t2。

利用良性竞赛在通常的代码中很少有利可图。在库代码中,它有时会显着提高性能,尤其是在volatile读取不便宜的非TSO硬件上。但是要让比赛真正变得温和,需要极其谨慎。在使用良性比赛时,你的工作是证明为什么种族实际上是良性的。
5.恐怖马戏团:有点工作,但令人震惊
此部分作为漫画浮雕提供。这里的例子不是号召性用语。这里的例子与你的岳父在杂耍链锯的方式相同,但仍有两只手(这一分钟)。
5.1。恐怖马戏团:同步原始人
在Java中,每个对象都可能是一个锁。这包括基元的包装器,这使得可以在基元(实际上是它们的盒装包装器)上进行同步。唉,正如我们之前看到的,同步on new Object()不起作用,我们需要确保原始值映射到相同的包装器对象。幸运的是,Java规范给了我们另一个让步,并宣称一些小值被自动装箱到相同的包装器对象。这两个(可以说是被忽视的)规范的一部分产生了这个装置:

class HorribleSemaphore {
final int limit;
public HorribleSemaphore(int limit) {
if (limit < 0 || limit > 128) {
throw new IllegalArgumentException("Incorrect: " + limit);
}
this.limit = limit;
}
void guardedExecute(Runnable r) {
synchronized (Integer.valueOf(ThreadLocalRandom.current().nextInt(limit))) {
// no more than $limit threads here...
r.run();
}
}
}
它确实允许在任何给定时间$limit执行的线程数不超过多个guardedExecute。它甚至比它更快java.util.concurrent.Semaphore,但它带来了一些严重的警告:

购物车身份被盗
不保证执行线程的下限 - 它在管理争用时比单个锁更好一点,但就是这样;

锁定对象是静态的,这意味着Integer在HorribleSemaphore代码外部获取包装器上的锁定会占用其许可证。或者这可能是一个功能?想一想:你可以在不涉及HorribleSemaphore实例的情况下更改限制!谈论开放延伸!SOLID设计FTW。

实现强加的限制是128个线程。好吧,我们可以添加+128到限制声明负值,这给了我们256个线程的上限!此外,JVM具有足够的面向未来,可以为我们提供一个控制自动装箱缓存大小的JVM标志  AutoBoxCacheMax - 我们可以随时对其进行调整!

5.2。恐怖马戏团:同步弦乐
嗨,Shady Mess在这里!你厌倦了那些挂在你的程序周围的无名锁吗?你是否因为多个锁定管理人员而污染了宝贵的体液?现在你可以使用Strings作为锁!这是限量优惠。StringTable不能再坚持他来了他来的时候他不会碰到他,̕h̵issun̨ho͞lyradiańcédestro҉yingallenli̍̈̈ghtenment,unclaimedlockslea͠ki̧n͘gfr̶ǫm̡yo͟ureye͢s̸̸l̕ik͏eliquid pain,
为什么:

private final Object LOCK = new Object();

synchronized(LOCK) {
...
}
......当你可以:

synchronized("Lock") {
...
}
当然,它需要注意的是,String从String文字中获取的实例在执行过程中是共享的。但是,这是伪装的祝福 - 它也是类加载器之间共享的!这意味着您可以同步代码,而无需弄清楚如何static final在类加载器之间传递-s!真厉害!

此外,没有人阻止我们仔细命名锁定锁:

synchronized("This is my lock. " +
"You cannot have it. " +
"Get your own String to synchronize on. " +
"There are plenty of Strings for everyone.") {
...
}
......也有助于程序化访问:

public void doWith(String whosLockThatIs, Runnable r) {
synchronized(("This is " + whosLockThatIs + " lock. " +
"You cannot have it. " +
"Get your own String to synchronize on. " +
"There are plenty of Strings for everyone.").intern()) {
r.run();
}
}

doWith("mine", () -> System.out.println("Peekaboo"));
多么神奇的语言给了我这些力量!

结论和分离思想
这不是你不知道会让你陷入困境的。这是你所知道的,但事实并非如此。

  • 马克吐温

车快乐jmm
正式的JMM规则很难理解。并非每个人都有时间和能力来找出角落的情况,因此每个人都不可避免地使用了一系列工作结构。唉,这些曲目通常来自城市传说,轶事观察或盲目猜测。我们应该停止这种习惯。根据实际语言保证构建曲目!

要处理99.99%的所有内存排序问题,您必须知道安全发布(正确同步的排序规则)和安全初始化(安全规则防止无意中的比赛)。两者都在“Java中的安全发布和安全初始化”中进行了更详细的探讨。额外的0.00999%是了解良性种族如何运作。在这个级别,内存模型规则实际上非常简单和直观。

当人们试图深入探究时,问题就开始了。语言/库之外的任何东西都可以让您了解运行时,编译器和硬件的内部工作的复杂细节,这几乎没有人完全理解。试图将其简化为“简单”的解释,如蟑螂汽车旅馆,障碍等,有时会产生不正确的结果,如上面的多个例子所示。忘记硬件是如何工作的,忘记优化器是如何工作的!这种理解适用于教育和娱乐目的,但它正在积极地破坏正确性证明。主要是因为你最终将“我无法建立一个如何失败的例子”等同于“这永远不会失败”,并且狂妄自大会在最意想不到的时刻咬你。

一些API为您提供可能听起来令人兴奋的铃声和口哨:lazySet/ putOrdered,围栏,acquire/ release操作VarHandles - 但它们的存在并不意味着您必须使用它们!这些留给了能够真正推理它们的高级用户。如果你想“简单地”修改规则,你应该为跳过一个非常大的悬崖做好准备,因为这就是你正在做的事情。

所有这些都是......的积累

车形意味着
1。Gleb Smirnov(破坏他的掩护):“你好,是的,这是贝叶斯阴谋说话。缺乏证据是缺席的证据不足。”
2。在少数选定的情况下,这些优化很容易,例如m()在其自己的类的构造函数中调用。
3。这听起来非常类似于C / C ++ 11 std::atomic(…​, memory_order_relaxed),这是它的模型。对于硬件并发测试非常方便,如本例所示。
4。Peter Sewell,Susmit Sarkar和其他人的工作涵盖了这个以及许多其他有趣的例子。我强烈建议您从硬件角度阅读“ARM和POWER轻松内存模型的教程简介”。
5。有趣的事实:IRIW仍然被VarHandle getAcquire/ 打破setRelease。与在C / C ++ 11中一样,这些操作不需要可见的商店总订单,IRIW断言。
6。Doug Lea:“有些情况下,通过围栏设计比单变量更有效率。例如,当两个变量中的所有种族都可以容忍时,可能会出现这些情况,但它们仍然需要对其他变量进行一些排序保证。请注意这些也是“良性”种族的形式,但它们并没有被良好地忽视。需要花费很多心思去解决这些问题。(或者有一天可能会使用一些静态实验工具,参见Fender)“
7。有趣的事实:有时你可以替换volatile使用存储/加载setRelease/ getAcquire挤出一点更高性能的果汁。这样可以提供顺序一致性,因此需要仔细分析其正确性。这个脚注是由“顺序一致性或死亡”思想学派的先知支付的。
8。有趣的事实:并发读取器中SC缺乏任何语义需求是几乎所有分布式一致性模型和协议的起点。这个脚注是由“顺序一致性就是死亡”的思想学派的先知所支付的。
9。有趣的事实:大多数硬件实际上提供了更直观的属性,连贯性:“对一个特定位置的所有写入都以一个特定的顺序观察”(这比所有商店的总顺序弱),这排除了这里的结果。可以说,这就是人们喜欢思考的方式,但这比Java内存模型强得多。一些内存模型,如C / C ++ 11内存模型,描述了原子上的修改顺序,类似于上面的定义,并且与硬件提供的内容非常匹配。在JDK 9+中,VarHandles“opaque”模式具有相似的语义,即使在当前JMM的领域中很难指定。 1, 0
10。同样,狭义相对论中通信速度的限制引起了同时性的相对性,实际硬件上的传播延迟解构了同时性和全球时间的直观概念。(我不是第一个建立这种联系的人)
11。这是令人惊讶的,在这种行为是常态的某些平台(例如OpenJDK PPC端口)上的实现,选择在构造函数中发生volatile存储时发出适当的障碍,即使规范不要求它。
12。Doug Lea:“这个finalvs volatile问题导致了一些曲折的结构,java.util.concurrent允许0作为基本/默认值,在它不自然的情况下。这条规则很糟糕,应该改变。”

Comments
Write a Comment