JIT是否可以在某些表达式中将两个易失性读取折叠为一个?

假设我们有一个 .一个线程可以volatile int a

while (true) {
    a = 1;
    a = 0;
}

而另一个线程

while (true) {
    System.out.println(a+a);
}

现在,JIT 编译器发出对应于 而不是 的程序集是否法?2*aa+a

一方面,易失性读取的真正目的是它应该始终从内存中刷新。

另一方面,两次读取之间没有同步点,因此我看不出以原子方式处理是非法的,在这种情况下,我不明白这样的优化会如何破坏规范。a+a2*a

如能提及JLS,将不胜感激。


答案 1

简短的回答:

是的,允许此优化。折叠两个顺序读取操作会使序列的可观察行为成为原子序列,但不会显示为操作的重新排序。在单个执行线程上执行的任何操作序列都可以作为原子单元执行。通常,很难确保一系列操作以原子方式执行,并且很少导致性能提升,因为大多数执行环境都会引入以原子方式执行项的开销。

在原始问题给出的示例中,所讨论的操作顺序如下:

read(a)
read(a)

以原子方式执行这些操作可保证第一行读取的值等于第二行上读取的值。此外,这意味着在第二行上读取的值是执行第一次读取时包含的值(反之亦然,因为根据程序的可观察执行状态,两个读取操作同时发生)。所讨论的优化(将第一次读取的值重用于第二次读取)等效于以原子方式执行序列的编译器和/或 JIT,因此是有效的。a


原始较长的答案:

Java 内存模型描述了使用先发生分排序的操作。为了表达第一次读取和第二次读取无法折叠的限制,您需要证明在语义上需要一些操作才能出现在它们之间。r1r2a

线程上的操作 with 和 如下:r1r2

--> r(a) --> r(a) --> add -->

要表达某物(比如说)位于 和 之间的要求,您需要要求在 之前发生之前和之前发生。碰巧的是,没有规则将读取操作显示在发生之前关系的左侧。你能得到的最接近的是说发生在之前,但偏序也允许在 之前发生,从而折叠读取操作。yr1r2r1yyr2yr2yr1

如果不存在要求操作介于 和 之间的方案,则可以声明在 和 之间不会出现任何操作,并且不违反语言所需的语义。使用单个读取操作将等效于此声明。r1r2r1r2

编辑我的答案是被投票否决,所以我将详细介绍一下。

以下是一些相关问题:

  • 是否需要 Java 编译器或 JVM 来折叠这些读取操作?

    不。在 add 表达式中使用的表达式不是常量表达式,因此不需要折叠它们。aa

  • JVM 是否折叠了这些读取操作?

    对此,我不确定答案。通过编译程序并使用 ,很容易看出 Java 编译器不会折叠这些读取操作。不幸的是,要证明JVM不会折叠操作(甚至更难的是处理器本身)并不容易。javap -c

  • JVM 是否应该折叠这些读取操作?

    可能不是。每次优化都需要时间来执行,因此在分析代码所需的时间和您期望获得的好处之间取得了平衡。一些优化,如数组边界检查消除或检查空引用,已被证明对实际应用程序具有广泛的好处。这种特定优化有可能提高性能的唯一情况是两个相同的读取操作按顺序出现的情况。

    此外,如对此答案的响应以及其他答案所示,此特定更改将导致某些应用程序出现用户可能不希望的意外行为更改。

编辑 2:关于Rafael对两个无法重新排序的读取操作的声明的描述。此语句旨在强调以下事实,即按以下顺序缓存 的读取操作可能会产生不正确的结果:a

a1 = read(a)
b1 = read(b)
a2 = read(a)
result = op(a1, b1, a2)

假设最初,其默认值为 0。然后只执行第一个 .abread(a)

现在假设另一个线程执行以下序列:

a = 1
b = 1

最后,假设第一个线程执行行 。如果要缓存 最初读取的值,则最终会得到以下调用:read(b)a

op(0, 1, 0)

这是不正确的。由于 更新后的值是在写入之前存储的,因此没有办法先读取该值,然后再读取该值。如果不进行缓存,正确的事件序列将导致以下调用。abb1 = 1a2 = 0

op(0, 1, 1)

但是,如果您要问“有没有办法允许缓存读取?”,答案是肯定的。如果可以将第一个线程序列中的所有个读取操作作为原子单元执行,则允许缓存该值。虽然跨多个变量进行同步很困难,并且很少提供机会优化优势,但遇到异常肯定是可以想象的。例如,假设 和 各为 4 个字节,并且它们按顺序出现在内存中,并在 8 字节边界上对齐。64 位进程可以将序列实现为原子 64 位加载操作,这将允许缓存 的值(有效地将所有三个读取操作视为原子操作,而不仅仅是前两个)。aabaread(a) read(b)a


答案 2

在我最初的回答中,我反对建议的优化的合法性。我主要从 JSR-133 说明书中的信息中支持这一点,其中指出,一个易失性读取不能与另一个易失性读取重新排序,并且它进一步指出缓存读取将被视为重新排序。然而,后一种说法的表述有些含糊不清,这就是为什么我浏览了JMM的正式定义,我没有找到这样的指示。因此,我现在认为优化是允许的。但是,JMM非常复杂,此页面上的讨论表明,这个角落案例可能由对形式主义有更透彻理解的人以不同的方式决定。

表示要执行的线程 1

while (true) {
  System.out.println(a // r_1 
    + a); // r_2
} 

和要执行的线程 2

while (true) {
  a = 0; // w_1
  a = 1; // w_2
}

两次读取和两次写入按原样执行同步操作(JSR 17.4.2)。它们是外部操作,因为变量在多个线程中使用。这些操作包含在所有操作的集合中。存在所有同步操作的总顺序,该同步顺序线程 1线程 2 的程序顺序一致(JSR 17.4.4)。根据与部分顺序同步的定义,上面的代码中没有为此顺序定义边。因此,“发生之前”顺序仅反映每个线程的线程内语义(JSR 17.4.5)。r_iw_iaavolatileaA

有了这个,我们定义为一个写看函数,其中和一个值写函数(JLS 17.4.6)。我采取了一些自由并删除了,因为它使正式证明的轮廓更加简单。问题是这个建议的执行格式是否正确(JLS 17.5.7)。建议的执行服从线程内语义,发生之前是一致的,遵循同步顺序,并且每次读取都观察到一致的写入。检查因果关系要求是微不足道的(JSR 17.4.8)。我都不明白为什么非终止执行的规则是相关的,因为循环涵盖了整个讨论的代码(JLS 17.4.9),我们不需要区分可观察的操作WW(r_i) = w_2V(w_i) = w_2w_1EE

对于所有这些,我找不到任何迹象表明为什么这种优化会被禁止。但是,它不会应用于 HotSpot VM 的读取,因为可以使用 .然而,我假设性能优势很小,并且通常不会观察到这种模式。volatile-XX:+PrintAssembly

备注:在看了Java内存模型的语用学(多次)之后,我很确定,这个推理是正确的。


推荐