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

2023-12-07

我正在尝试深入研究volatileJava 中的关键字和设置 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();
  }
}

“完成的!”从未被打印过,这对我来说没有意义。我期待着fullFence在线程 2 中使flag = false全球可见。

根据我的研究,Hotspot 使用lock/mfence实施fullFence在 x86 上。并根据Intel mfence的指令集参考手册入门

此串行化操作保证按程序顺序位于 MFENCE 指令之前的每个加载和存储指令在 MFENCE 指令之后的任何加载或存储指令之前变得全局可见。

甚至“更糟”,如果我评论出来fullFence在线程 2 中并取消注释任一xxxFence在线程 1 中,代码打印出“Finished!”这更没有意义,因为至少lfence在 x86 中是“无用”/无操作.

也许我的信息来源不准确或者我误解了某些东西。请帮忙,谢谢!


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

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

当然,条件可以被提升到循环之外。所以没有任何障碍或任何东西,您的阅读器循环可以 JIT 到实现此逻辑的 asm 中:

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

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

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

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

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


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

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

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


警告:我对Java了解不多,而且我不知道分配一个非-到底有多么不安全/未定义。volatile在一个线程中读取它并在另一个线程中读取它,而无需同步。根据您所看到的行为,这听起来与您在 C++ 中看到的与非非相同的事情完全相同atomic变量(启用优化,就像 HotSpot 总是做的那样)

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

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


PS: 仅仅使用屏障来强制编译器重新读取值通常并不安全:即使源将其复制到局部变量,它也可以选择多次重新读取该值。因此,同一个 tmp var 在一次执行中可能看起来既是 true 又是 false。至少在 C 和 C++ 中是这样,因为数据竞争在这些语言中是未定义的行为。看谁害怕一个糟糕的优化编译器?在 LWN 上,如果您只使用屏障和普通(非volatile)变量。再说一次,我不知道这是否是 Java 中可能存在的问题,或者语言规范是否会禁止 JVM 在int tmp = shared_plain_int; if tmp在函数调用中多次使用。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

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

随机推荐