这是一个后续问题
有许多文章和博客提到了 Java 和 JVM 指令重新排序,这可能会导致用户操作出现反直觉的结果。
当我要求演示导致意外结果的 Java 指令重新排序时,有几条评论指出,更普遍的关注领域是内存重新排序,并且很难在 x86 CPU 上演示。
指令重新排序只是内存重新排序、编译器优化和内存模型等更大问题的一部分吗?这些问题真的是 Java 编译器和 JVM 独有的吗?它们是否特定于某些 CPU 类型?
这是一个后续问题
有许多文章和博客提到了 Java 和 JVM 指令重新排序,这可能会导致用户操作出现反直觉的结果。
当我要求演示导致意外结果的 Java 指令重新排序时,有几条评论指出,更普遍的关注领域是内存重新排序,并且很难在 x86 CPU 上演示。
指令重新排序只是内存重新排序、编译器优化和内存模型等更大问题的一部分吗?这些问题真的是 Java 编译器和 JVM 独有的吗?它们是否特定于某些 CPU 类型?
无需在源代码与 asm 中对操作进行编译时重新排序,就可以进行内存重新排序。由运行线程的 CPU 完成的内存操作(加载和存储)到一致共享缓存(即内存)的顺序也与其执行这些指令的顺序不同。
执行加载是访问缓存(或存储缓冲区),但在现代 CPU 中执行“存储”与其他内核实际可见的值是分开的(从存储缓冲区提交到 L1d 缓存)。执行存储实际上只是写将地址和数据放入存储缓冲区;在存储退出之前不允许提交,因此已知是非推测性的,即肯定会发生。
将内存重新排序描述为“指令重新排序”是一种误导。即使在按顺序执行 asm 指令的 CPU 上,您也可以获得内存重新排序(只要它有一些机制来找到内存级并行性并让内存操作以某些方式无序完成),即使 asm 指令顺序匹配源顺序。因此,该术语错误地暗示仅以正确的顺序(在 asm 中)具有简单的加载和存储指令对于与内存顺序相关的任何事情都是有用的;至少在非 x86 CPU 上不是这样。这也很奇怪,因为指令对寄存器有影响(至少加载,并且在一些具有后增量寻址模式的 ISA 上,存储也可以)。
谈论诸如 StoreLoad 重新排序之类的事情在加载x = 1
后“发生”是很方便的tmp = y
,但要谈论的是效果何时发生(对于加载)或对于其他内核可见(对于存储)与此线程的其他操作相关. 但是在编写 Java 或 C++ 源代码时,关心它是发生在编译时还是运行时,或者该源代码是如何变成一条或多条指令的,几乎没有意义。此外,Java 源代码没有指令,它有语句。
也许该术语可以用于描述在.class
与 JIT 编译器生成本机机器代码中的字节码指令之间的编译时重新排序,但如果是这样,那么将其用于一般的内存重新排序是一种误用,而不仅仅是编译/JIT不包括运行时重新排序的时间重新排序。仅突出显示编译时重新排序并不是很有帮助,除非您有信号处理程序(如 POSIX)或在现有线程的上下文中异步运行的等效程序。
这种效果根本不是 Java 独有的。(尽管我希望“指令重新排序”术语的这种奇怪用法是!)它与 C++ 非常相似(例如,我认为 C# 和 Rust,可能是大多数其他想要正常编译的语言,并且需要特殊的东西在source 来指定您希望您的内存操作何时相互排序,并立即对其他线程可见)。https://preshing.com/20120625/memory-ordering-at-compile-time/
C++ 对非同步访问非变量的定义甚至比 Java 还要少,atomic<>
以确保永远不会与其他任何东西并行写入(未定义的行为1)。
甚至出现在汇编语言中,根据定义,源代码和机器代码之间没有重新排序。除了少数几个像 80386 这样的古老 CPU 之外,所有 SMP CPU 也在运行时进行内存重新排序,因此缺乏指令重新排序不会为您带来任何好处,尤其是在具有“弱”内存模型的机器上(大多数现代 CPU,除了 x86) :https ://preshing.com/20120930/weak-vs-strong-memory-models/ - x86 是“强排序”,但不是 SC:它是程序顺序加上带有存储转发的存储缓冲区。因此,如果您想在 x86 上实际演示Java 中因排序不足而造成的破坏,要么是编译时重新排序,要么是缺乏顺序一致性通过 StoreLoad 重新排序或存储缓冲区效果。其他不安全的代码(例如您之前问题的已接受答案可能恰好适用于 x86)在弱排序 CPU(如 ARM)上会失败。
(有趣的事实:现代 x86 CPU 积极地无序执行加载,但根据 x86 的强排序内存模型检查以确保它们被“允许”这样做,即它们从中加载的缓存行仍然是可读的,否则滚动将 CPU 状态恢复到之前的状态:machine_clears.memory_ordering
perf 事件。因此它们保持遵守强大的 x86 内存排序规则的错觉。其他 ISA 的命令较弱,并且可以在没有后续检查的情况下积极地乱序执行负载。)
一些 CPU 内存模型甚至允许不同的线程对其他两个线程完成的存储顺序存在分歧。因此 C++ 内存模型也允许这样做,因此 PowerPC 上的额外障碍仅用于顺序一致性(atomic
与memory_order_seq_cst
Java 类似volatile
)而不是获取/释放或更弱的命令。
有关的:
如何使用按序提交进行加载->存储重新排序?- 通过其他效果在有序 CPU 上进行内存重新排序,例如在允许此操作的弱排序 ISA 上使用可以执行未命中命中的缓存和/或来自存储缓冲区的无序提交的记分板加载。(还在 OoO exec CPU 上进行 LoadStore 重新排序,仍然按顺序退出指令,这实际上比在具有特殊机制以允许内存级并行加载的有序 CPU 上更令人惊讶,而 OoO exec 可以取代。)
由于 cpu 无序执行或缓存一致性问题,是否需要内存屏障?(基本上是这个的副本;我没说太多,那不在这里)
加载和存储是唯一被重新排序的指令吗?(在运行时)
推测性执行的 CPU 分支能否包含访问 RAM 的操作码?- 存储执行顺序甚至与线程之间的内存排序无关,仅从存储缓冲区提交顺序到 L1d 缓存。存储缓冲区对于将推测执行(包括存储指令)与其他内核可见的任何内容解耦是必不可少的。(以及这些商店的缓存未命中。)
为什么在 x86 上对自然对齐的变量进行整数赋值是原子的?- 在 asm 中为真,但在 C/C++ 中不安全;您需要std::atomic<int>
使用 memory_order_relaxed 来获得相同的 asm,但以便携安全的方式。
全局不可见的加载指令- 加载数据来自哪里:存储转发是可能的,所以说 x86 的内存模型是“程序顺序 + 带有存储转发的存储缓冲区”比说“只有 StoreLoad 重新排序”更准确,如果你曾经关心这个核心重新加载自己最近的商店。
为什么内存重新排序在单核/处理器机器上不是问题?- 就像编译器的 as-if 规则一样,乱序 exec(和其他效果)必须保持指令的错觉(在一个内核中,因此在线程内),按照程序顺序一次完全执行一个指令,没有它们的影响重叠。这基本上是 CPU 架构的基本规则。
LWN:谁害怕一个糟糕的优化编译器?- 编译器可以对使用普通(非易失性/非_Atomic
访问)的 C 代码做令人惊讶的事情。这主要与 Linux 内核相关,它使用内联 asm 滚动自己的原子来处理一些事情,比如屏障,但也只是 C用于纯加载/纯存储(这与 Java 2volatile
非常不同。)volatile
脚注 1: C++ UB 不仅意味着加载了一个不可预测的值,而且 ISO C++ 标准对于在遇到 UB 之前或之后的任何时间在整个程序中可以/不可以发生的事情没有任何说法。在内存排序的实践中,结果通常是可以预测的(对于习惯于查看编译器生成的 asm 的专家),具体取决于目标机器和优化级别,例如,将负载提升到循环中会破坏无法使用的自旋等待循环atomic
。但是当然,当您的程序包含 UB 时,您完全受制于编译器所做的任何事情,而不是您可以依赖的东西。
但是,Java 或 C++ 运行多个线程的所有真实系统都具有一致的缓存。在循环中无限期地看到陈旧数据是编译器将值保存在寄存器(线程私有)中的结果,而不是 CPU 缓存彼此不可见的结果。 这就是使 C++volatile
在实践中用于多线程的原因(但实际上并没有这样做,因为 C++11 std::atomic 已经过时了)。
从未看到标志变量更改的影响是由于编译器将全局变量优化到寄存器中,而不是指令重新排序或cpu缓存。您可以说编译器在寄存器中“缓存”了一个值,但您可以选择其他不太可能使不了解线程私有寄存器与连贯缓存的人混淆的措辞。
脚注 2:在比较 Java 和 C++ 时,还要注意 C++volatile
不保证任何关于内存排序的内容,事实上,在 ISO C++ 中,即使使用 volatile,多个线程同时写入同一个对象也是未定义的行为。如果std::memory_order_relaxed
您想要线程间可见性而不订购 wrt,请使用。周围的代码。
(Javavolatile
就像std::atomic<T>
具有默认值的C++ 一样std::memory_order_seq_cst
,AFAIK Java 没有办法放松这一点以进行更高效的原子存储,即使大多数算法只需要其纯加载和纯存储的获取/释放语义,x86可以做到这一点免费. 为顺序一致性而耗尽存储缓冲区需要额外的成本. 与线程间延迟相比并不多,但对每个线程的吞吐量很重要,如果同一个线程对相同的数据做一堆事情而没有来自其他线程。)