无需编译时重新排序源代码与 asm 中的操作即可实现内存重新排序。由运行线程的 CPU 对连贯共享缓存(即内存)执行的内存操作(加载和存储)顺序也与它执行这些指令的顺序不同。
执行加载是访问缓存(或存储缓冲区),但在现代 CPU 中执行“存储与其他内核实际可见的值是分开的(从存储缓冲区提交到 L1d缓存)。执行存储实际上只是将地址和数据写入存储缓冲区;在存储退出之前不允许提交,因此已知是非推测性的,即肯定会发生。
将内存重新排序描述为“指令重新排序”是一种误导。即使在按顺序执行 asm 指令的 CPU 上,您也可以进行内存重新排序(只要它有一些机制可以找到内存级别的并行性并让内存操作在某些方面无序完成 ),即使 asm 指令顺序与源顺序匹配。因此,该术语错误地暗示仅以正确的顺序(在 asm 中)具有简单的加载和存储指令对于与内存顺序相关的任何事情都是有用的;至少在非 x86 CPU 上不是这样。这也很奇怪,因为指令对寄存器有影响(至少加载,并且在一些具有后增量寻址模式的 ISA 上,存储也可以)。
在 tmp = y 加载之后谈论类似 StoreLoad 重新排序为 x = 1 “发生”之类的事情很方便,但要谈论的是效果何时发生(对于加载)或对其他核心可见(对于存储) 与此线程的其他操作有关。但是在编写 Java 或 C++ 源代码时,关心它是发生在编译时还是运行时,或者该源代码是如何变成一条或多条指令的,几乎没有意义。此外,Java 源代码没有 指令,它有语句。
也许该术语可以用来描述 .class 中的字节码指令与 JIT 编译器生成的本机机器代码之间的编译时重新排序,但如果是这样,那么将其用于内存重新排序通常是一种误用,而不仅仅是编译/JIT 时重新排序,不包括运行时重新排序。只强调编译时重新排序并不是很有帮助,除非您有信号处理程序(如 POSIX)或在现有线程的上下文中异步运行的等效程序。
这种效果根本不是 Java 独有的。(尽管我希望“指令重新排序”术语的这种奇怪用法是!)它与 C++ 非常相似(我认为 C# 和 Rust例如,可能大多数其他语言想要正常高效地编译,并且需要在源代码中使用特殊的东西来指定您何时希望您的内存操作相互排序,并立即对其他线程可见)。 https://preshing.com/20120625/memory-ordering-at-compile-time/
C++ 对非atomic<> 变量的访问定义甚至比 Java 还要少,无需同步,以确保永远不会与其他任何东西并行写入(未定义的行为1)。
甚至出现在汇编语言中,根据定义,源代码和机器代码之间没有重新排序。所有的 SMP CPU,除了一些像 80386 这样的古老 CPU 也进行在运行时重新排序内存,所以缺乏指令重新排序不会给你带来任何好处,尤其是在具有“弱”内存模型的机器上( x86 以外的大多数现代 CPU):https://preshing.com/20120930/weak-vs-strong-memory-models/ - x86 是“强排序的”,但不是 SC:它是程序顺序加上带有存储转发的存储缓冲区。因此,如果您想真正演示在 x86 上的 Java 中由于排序不足而造成的破坏,它要么是编译时重新排序,要么是 lack of sequential consistency 通过 StoreLoad 重新排序或存储缓冲区效果。其他不安全的代码(例如您之前问题的已接受答案)可能会在 x86 上运行,但在 ARM 等弱排序 CPU 上会失败。
(有趣的事实:现代 x86 CPU 积极地无序执行加载,但是根据 x86 的强排序内存模型检查以确保它们被“允许”这样做,即它们从中加载的缓存行仍然可读, 否则将 CPU 状态回滚到之前的状态:machine_clears.memory_ordering perf 事件。因此它们保持遵守强大的 x86 内存排序规则的错觉。其他 ISA 的命令较弱,并且可以在没有后续检查的情况下积极地乱序执行加载。 )
某些 CPU 内存型号甚至是 allow different threads to disagree about the order of stores done by two other threads。因此 C++ 内存模型也允许这样做,因此 PowerPC 上的额外屏障仅用于顺序一致性(atomic 和 memory_order_seq_cst,如 Java volatile)而不是获取/释放或更弱的命令。
相关:
脚注 1: C++ UB 不仅意味着加载了一个不可预知的值,而且 ISO C++ 标准对于整个程序之前或遇到UB之后。在内存排序的实践中,结果通常是可预测的(对于习惯于查看编译器生成的 asm 的专家)取决于目标机器和优化级别,例如hoisting loads out of loops breaking spin-wait loops 无法使用 atomic。但是当然,当您的程序包含 UB 时,您完全受制于编译器所做的任何事情,而不是您可以依赖的东西。
缓存是一致的,尽管存在常见的误解
然而,Java 或 C++ 跨多个线程运行的所有现实世界系统确实 具有一致的缓存;在循环中无限期地看到陈旧数据是编译器将值保存在寄存器(线程私有)中的结果,而不是 CPU 缓存彼此不可见的结果。 This is what makes C++ volatile work in practice for multithreading (but don't actually do that because C++11 std::atomic made it obsolete).
从未看到标志变量更改的影响是由于编译器将全局变量优化到寄存器中,而不是指令重新排序或 cpu 缓存。您可以说编译器将值“缓存”在寄存器中,但您可以选择其他措辞,这样不太可能让那些不了解线程私有寄存器与连贯缓存的人混淆。
脚注 2:在比较 Java 和 C++ 时,还要注意 C++ volatile 不保证任何关于内存顺序的事情,事实上,在 ISO C++ 中,多线程写入是未定义的行为即使使用 volatile,也可以同时使用同一个对象。如果您想要线程间可见性而不订购 wrt,请使用 std::memory_order_relaxed。周围的代码。
(Java volatile 就像 C++ std::atomic<T> 与默认的 std::memory_order_seq_cst 一样,AFAIK Java 没有办法放宽这一点来做更有效的原子存储,尽管大多数算法只需要获取/释放语义来实现它们的纯-loads 和 pure-stores,x86 can do for free。为了顺序一致性而耗尽存储缓冲区需要额外的成本。与线程间延迟相比并不多,但对每个线程的吞吐量很重要,如果同一个线程正在做一个大问题一堆东西到相同的数据没有来自其他线程的争用。)