首页 > 解决方案 > Java 指令重新排序和 CPU 内存重新排序

问题描述

这是一个后续问题

如何演示 Java 指令重新排序问题?

有许多文章和博客提到了 Java 和 JVM 指令重新排序,这可能会导致用户操作出现反直觉的结果。

当我要求演示导致意外结果的 Java 指令重新排序时,有几条评论指出,更普遍的关注领域是内存重新排序,并且很难在 x86 CPU 上演示。

指令重新排序只是内存重新排序、编译器优化和内存模型等更大问题的一部分吗?这些问题真的是 Java 编译器和 JVM 独有的吗?它们是否特定于某些 CPU 类型?

标签: javacpu-architecturememory-barriersinstruction-reordering

解决方案


无需在源代码与 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_orderingperf 事件。因此它们保持遵守强大的 x86 内存排序规则的错觉。其他 ISA 的命令较弱,并且可以在没有后续检查的情况下积极地乱序执行负载。)

一些 CPU 内存模型甚至允许不同的线程对其他两个线程完成的存储顺序存在分歧。因此 C++ 内存模型也允许这样做,因此 PowerPC 上的额外障碍仅用于顺序一致性(atomicmemory_order_seq_cstJava 类似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可以做到这一点免费. 为顺序一致性而耗尽存储缓冲区需要额外的成本. 与线程间延迟相比并不多,但对每个线程的吞吐量很重要,如果同一个线程对相同的数据做一堆事情而没有来自其他线程。)


推荐阅读