Java中内存屏障的行为

36

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

以下是Doug Lea在他关于JMM的澄清文章中的两个引用,它们都非常直观:

  1. 当线程A写入volatile字段f时可见的任何内容,当线程B读取f时变得可见。
  2. 请注意,为了正确建立happens-before关系,必须让两个线程访问同一个volatile变量。并不是当线程A写入volatile字段f时可见的所有内容,在线程B读取volatile字段g之后就变得可见。

但是当我查看另一篇关于内存屏障的blog时,我发现了这些:

  1. 存储栅栏,x86上的“sfence”指令,强制在栅栏之前发生的所有存储指令都在栅栏之前发生,并将存储缓冲区刷新到发出指令的CPU的缓存中。
  2. 加载栅栏,x86上的“lfence”指令,强制在栅栏之后发生的所有加载指令都在栅栏之后发生,并等待该CPU上的加载缓冲区排空。

对我来说,Doug Lea的澄清比其他人更严格:基本上,这意味着如果加载栅栏和存储栅栏位于不同的监视器上,则无法保证数据一致性。但后者意味着即使栅栏位于不同的监视器上,也会保证数据一致性。我不确定我是否正确理解了这两个问题,也不确定哪一个是正确的。

考虑以下代码:

  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");
      }
    }
  }

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

  1. 由于SB1和RB1都在同一台显示器i上,因此调用read1的线程TR1应始终在x上看到14,同时“Foo”总是被打印出来。
  2. SB1和RB2位于不同的显示器上,如果Doug Lea是正确的,那么线程TR2将不能保证在x上看到14,这意味着“Bar”可能会偶尔被打印出来。但如果内存屏障按照Martin Thompson在blog中描述的方式运行,存储屏障将把所有数据推送到主内存中,加载屏障将从主内存中拉取所有数据到缓存/缓冲区中,那么TR2也将被保证在x上看到14。

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

谢谢~


12
在 x86 架构下,你不需要关心内存屏障。Java 语言和 Java 内存模型的语义是在“抽象机器”上定义的,这才是最重要的事情。Java 运行时会确保在运行过程中满足“抽象机器”所做出的保证。 - nosid
2
事实上,x86语义(包括缓存一致性)比jmm要求的更强,但如果您不是为x86编写Java运行时,那么您无需关心其中任何内容,正如nosid所指出的那样。 - Voo
你的担忧是有道理的。读者2可能会打印出Bar,但是,除非读取线程之前已经与内存屏障类进行交互并缓存了x的值,否则读者2将打印foo,因为它将首次访问x。与写操作的交互意味着对x的更改将被显示。也许一个更有趣的测试是在W1之前和之后让read1和read2都执行。 - Brett Okken
3
CountDownLatch 引入了额外的同步机制。因此,如果你使用 CountDownLatch 来确保 write 执行后 read2 执行,那么 read2 将始终打印 “Foo”。 - nosid
我认为 sfence 不会将存储缓冲区刷新到缓存中。Peterson/Decker 同步需要 mfence - ninjalj
显示剩余2条评论
2个回答

19
Doug Lea是正确的。您可以在《Java语言规范》的§17.4.4部分找到相关内容:
“同步顺序” §17.4.4 Synchronization Order [...]对易失变量v(§8.3.1.4)的写入与随后任何线程对v的读取(其中“随后”根据同步顺序定义)同步。[...]
具体机器的内存模型并不重要,因为Java编程语言的语义是基于一个抽象机器定义的——独立于具体机器。Java运行时环境有责任以这样的方式执行代码,以使其符合《Java语言规范》所给出的保证。

关于实际问题:

  • 如果没有进一步的同步,方法read2可以打印"Bar",因为read2可以在write之前执行。
  • 如果使用CountDownLatch进行额外的同步以确保read2write之后执行,则方法read2将永远不会打印"Bar",因为与CountDownLatch的同步消除了x上的数据竞争

独立的易失性变量:

一个易失性变量的写操作不会与任何其他易失性变量的读操作同步,这是否有意义?

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

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

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

假设这个类仅在方法内部使用。JIT编译器可以轻松检测到该对象仅在此方法内部使用(逃逸分析)。

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

使用上述规则,JIT编译器可以消除所有volatile读写的影响,因为volatile变量不能从任何其他线程访问。
实际上,这种优化存在于Java JIT编译器中:

为了澄清,如果“写入(write)”在时间上发生在“读取(read2)”之后,并且没有进行任何其他同步,那么“Bar”是否仍然会被打印出来? - Sotirios Delimanolis
@AlexeyMalev 是的,但在此之前,它读取的是 j 而不是 i,所以我认为引用中的 1. 不适用。 - Sotirios Delimanolis
@SotiriosDelimanolis:如果没有额外的同步,你如何判断read2在时间上是在write之后发生的? - nosid
这是唯一的原因吗?Doug 的第一个引用是因为 j 是由 read2 线程检查,而不是另一个 volatile i,所以它没有看到对 x 的更改? - Sotirios Delimanolis
1
@Voo 我猜 x86 提供了比 JMM 更强的保证,使得对 x 的更改能够被 read2 线程看到。JMM 本身并不保证这种情况发生。 - asticx
显示剩余4条评论

1

据我理解,这个问题实际上涉及到了volatile读/写以及其先行发生保证的问题。在这方面,我只有一点补充,与nosid的回答相同:

volatile写入不能被移动到普通写入之前,volatile读取不能被移动到普通读取之后。这就是为什么read1()read2()的结果将与nosid所写的相同。

至于屏障 - 对我来说,定义听起来还可以接受,但可能会让你感到困惑的一件事是,这些都是在hotspot中实现JMM中描述的行为的工具/方式/机制(无论你喜欢称其为什么)。当使用Java时,您应该依赖于JMM的保证,而不是实现细节。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接