为什么 Unsafe.fullFence() 在我的示例中不能确保可见性?

Why does Unsafe.fullFence() not ensuring visibility in my example?

我正在尝试深入研究 Java 中的 volatile 关键字并设置 2 个测试环境。我相信他们都使用 x86_64 并使用热点。

Java version: 1.8.0_232
CPU: AMD Ryzen 7 8Core

Java version: 1.8.0_231
CPU: Intel I7

代码在这里:

import java.lang.reflect.Field;
import sun.misc.Unsafe;

public class Test {

  private boolean flag = true; //left non-volatile intentionally
  private volatile int dummyVolatile = 1;

  public static void main(String[] args) throws Exception {
    Test t = new Test();
    Field f = Unsafe.class.getDeclaredField("theUnsafe");
    f.setAccessible(true);
    Unsafe unsafe = (Unsafe) f.get(null);

    Thread t1 = new Thread(() -> {
        while (t.flag) {
          //int b = t.someValue;
          //unsafe.loadFence();
          //unsafe.storeFence();
          //unsafe.fullFence();
        }
        System.out.println("Finished!");
      });

    Thread t2 = new Thread(() -> {
        t.flag = false;
        unsafe.fullFence();
      });

    t1.start();
    Thread.sleep(1000);
    t2.start();
    t1.join();
  }
}

"Finished!" 从未打印过,这对我来说没有意义。我期待线程 2 中的 fullFence 使 flag = false 全局可见。

根据我的研究,Hotspot 使用 lock/mfence 在 x86 上实现 fullFence。并根据 Intel's instruction-set reference manual entry for mfence

This serializing operation guarantees that every load and store instruction that precedes the MFENCE instruction in program order becomes globally visible before any load or store instruction that follows the MFENCE instruction.

即使 "worse",如果我在线程 2 中注释掉 fullFence 并在线程 1 中取消注释任何一个 xxxFence,代码会打印出 "Finished!" 这更没有意义,因为至少 lfence is "useless"/no-op in x86

也许我的信息来源不准确或者我误解了什么。请帮忙,谢谢!

重要的不是栅栏的运行时效果,而是强制编译器重新加载内容的编译时效果。

您的 t1 循环不包含 volatile 读取或任何其他可以与另一个线程同步的内容,因此不能保证它会 永远 注意任何变量的任何变化。 即,当 JITing 到 asm 中时,编译器可以创建一个循环,将值加载到寄存器中一次,而不是每次都从内存中重新加载它。这是您始终希望编译器能够对非共享数据执行的优化,这就是为什么该语言有规则允许它在不可能同步时执行此操作。

然后当然可以将条件提升到循环之外。因此,没有障碍或任何东西,你的 reader 循环可以 JIT 到实现这个逻辑的 asm:

if(t.flag) {
   for(;;){}  // infinite loop
}

除了排序之外,Javavolatile的另一部分是假设其他线程可能会异步更改它,因此不能假设多次读取给出相同的值。

但是 unsafe.loadFence(); 使 JVM 每次迭代都从(缓存一致的)内存中重新加载 t.flag。我不知道这是否是 Java 规范所要求的,或者仅仅是使其起作用的实现细节。

如果这是带有非 atomic 变量的 C++(这在 C++ 中是未定义的行为),您会在像 GCC 这样的编译器中看到完全相同的效果。 _mm_lfence 也将是一个编译时完全屏障,并发出无用的 lfence 指令,有效地告诉编译器所有内存可能已更改,因此需要重新加载。因此它无法重新排序负载,或将它们提升到循环之外。

顺便说一句,我不太确定 unsafe.loadFence() 甚至 JIT 到 x86 上的 lfence 指令。 对内存排序毫无用处(除了非常晦涩的东西,比如从 WC 内存中屏蔽 NT 加载,例如从视频 RAM 复制,JVM 可以假设这不会发生),所以 JVM x86 的 JITing 可以将其视为编译时障碍。就像 C++ 编译器为 std::atomic_thread_fence(std::memory_order_acquire); 所做的一样 - 阻止编译时跨障碍加载的重新排序,但不发出 asm 指令,因为主机的 asm 内存 运行 JVM 已经足够强大。


在线程2中,unsafe.fullFence();我认为没用。它只是让 that 线程等待,直到更早的存储变得全局可见,然后才能发生任何以后的 loads/stores。 t.flag = false; 是一个无法优化的可见副作用,因此无论是否有障碍,它肯定会在 JITed asm 中发生,即使它不是 volatile。而且它不能被延迟或与其他东西合并,因为同一个线程中没有其他东西。

Asm 存储总是对其他线程可见,唯一的问题是当前线程是否等待其存储缓冲区耗尽,然后再在此线程中执行更多操作(尤其是加载)。即防止所有重新排序,包括 StoreLoad。 Java volatile 这样做,就像 C++ memory_order_seq_cst(通过在每个存储之后使用一个完整的屏障),但是没有屏障它仍然像 C++ memory_order_relaxed 一样是一个存储。 (或者当 JITing x86 asm 时,loads/stores 实际上和 acquire/release 一样强。)

缓存是连贯的,存储缓冲区总是尽可能快地自行耗尽(提交给 L1d 缓存),以便为更多存储执行腾出空间。


警告:我对 Java 了解不多,我也不知道在一个线程中分配一个非 volatile 并读取它到底有多不安全/未定义在另一个没有同步的情况下。根据您所看到的行为,对于非 atomic 变量(启用优化,就像 HotSpot 总是那样)

,这听起来与您在 C++ 中看到的完全一样

(根据@Margaret 的评论,我更新了一些关于我如何假设 Java 同步工作的猜测。如果我说错了什么,请编辑或评论。)

在 C++ 中,非 atomic 变量的数据竞争始终是未定义的行为,但当然,在针对真正的 ISA(不进行硬件竞争预防)进行编译时,结果有时是人们想要的。