Java中内存屏障的行为

在阅读了更多的博客/文章等之后,我现在对内存屏障之前/之后的加载/存储行为感到非常困惑。

以下是Doug Lea在他关于JMM的一篇澄清文章中引用的2句话,这些话都非常直截了当:

  1. 当线程 A 写入易失性字段 f 时,线程 A 可见的任何内容在读取 f 时对线程 B 可见。
  2. 请注意,两个线程都必须访问相同的易失性变量,以便正确设置发生前关系。当线程 A 写入易失性字段 f 时,线程 A 在读取易失性字段 g 后,并不是线程 B 可以看到的所有内容都变得可见的情况并非如此。

但是当我查看另一个关于记忆障碍的博客时,我得到了这些:

  1. 存储屏障(x86 上的“sfence”指令)强制在屏障之前的所有存储指令在屏障之前发生,并将存储缓冲区刷新到发出该屏障的 CPU 的缓存中。
  2. 负载屏障(x86 上的“lfence”指令)强制屏障之后的所有加载指令在屏障之后发生,然后等待负载缓冲区耗尽该 CPU。

对我来说,Doug Lea的澄清比另一个更严格:基本上,这意味着如果负载屏障和商店屏障位于不同的监视器上,则数据一致性将无法保证。但后者意味着即使障碍位于不同的监视器上,数据一致性也会得到保证。我不确定我是否正确地理解了这2个,我也不确定其中哪个是正确的。

考虑以下代码:

  public class MemoryBarrier {
    volatile int i = 1, j = 2;
    int x;

    public void write() {
      x = 14; //W01
      i = 3;  //W02
    }

    public void read1() {
      if (i == 3) {  //R11
        if (x == 14) //R12
          System.out.println("Foo");
        else
          System.out.println("Bar");
      }
    }

    public void read2() {
      if (j == 2) {  //R21
        if (x == 14) //R22
          System.out.println("Foo");
        else
          System.out.println("Bar");
      }
    }
  }

假设我们有 1 个写入线程 TW1 首先调用 MemoryBarrier 的 write() 方法,然后我们有 2 个读取器线程 TR1 和 TR2 调用 MemoryBarrier 的 read1() 和 read2() 方法。考虑这个程序在不保留排序的CPU上运行(x86 DO为这种情况保留排序),根据内存模型,W01 / W02之间将存在StoreStore屏障(假设SB1),以及R11 / R12和R21 / R22之间的2个LoadLoad屏障(假设RB1和RB2)。

  1. 由于 SB1 和 RB1 位于同一监视器 i 上,因此调用 read1 的线程 TR1 应始终在 x 上看到 14,并且始终打印“Foo”。
  2. SB1 和 RB2 位于不同的显示器上,如果 Doug Lea 是正确的,线程 TR2 将不能保证在 x 上看到 14,这意味着“Bar”可能会偶尔打印出来。但是,如果内存屏障像Martin Thompson在博客中描述的那样运行,则Store屏障会将所有数据推送到主内存,而Load barrier会将所有数据从主内存拉取到缓存/缓冲区,那么TR2也将保证在x上看到14。

我不确定哪一个是正确的,或者两者都是正确的,但Martin Thompson所描述的只是针对x86架构的。JMM 不保证对 x 的更改对 TR2 是可见的,但 x86 实现是可见的。

谢谢~


答案 1

Doug Lea是对的。您可以在 Java 语言规范§17.4.4 节中找到相关部分:

§17.4.4 同步顺序

[..]对易失性变量 v (§8.3.1.4) 的写入与任何线程对 v 的所有后续读取同步(其中“后续”是根据同步顺序定义的)。[..]

具体机器的内存模型并不重要,因为Java编程语言的语义是根据抽象机器定义的 - 独立于混凝土机器。Java 运行时环境有责任以这种方式执行代码,使其符合 Java 语言规范提供的保证。


关于实际问题:

  • 如果没有进一步的同步,该方法可以打印,因为可以在之前执行。read2"Bar"read2write
  • 如果有额外的同步与 a 来确保在 之后执行,则方法将永远不会打印,因为 与 的同步删除了 上的数据竞跑CountDownLatchread2writeread2"Bar"CountDownLatchx

独立易失性变量:

对易失性变量的写入与对任何其他易失性变量的读取不同步是否有意义?

是的,这是有道理的。如果两个线程需要相互交互,它们通常必须使用相同的变量来交换信息。另一方面,如果一个线程使用易失性变量而不需要与所有其他线程交互,我们不想为内存屏障付出代价。volatile

这在实践中实际上很重要。让我们举个例子。以下类使用易失性成员变量:

class Int {
    public volatile int value;
    public Int(int value) { this.value = value; }
}

假设此类仅在方法中本地使用。JIT 编译器可以很容易地检测到该对象仅在此方法中使用(转义分析)。

public int deepThought() {
    return new Int(42).value;
}

使用上述规则,JIT 编译器可以删除读取和写入的所有影响,因为不能从任何其他线程访问该变量。volatilevolatile

这种优化实际上存在于Java JIT编译器中:


答案 2

据我所知,这个问题实际上是关于易失性读/写及其先发生保证。说到那部分,我只有一件事要补充到nosid的答案中:

易失性写入不能在正常写入之前移动,易失性读取不能在正常读取后移动。这就是为什么结果会像nosid所写的那样。read1()read2()

说到障碍 - 定义对我来说听起来不错,但可能让你感到困惑的一件事是,这些是在热点中实现JMM中描述的行为的东西/工具/方式/机制(随便你怎么称呼它)。使用 Java 时,您应该依赖 JMM 保证,而不是实现细节。