x86 上 Java 的最小侵入性编译障碍
Posted
技术标签:
【中文标题】x86 上 Java 的最小侵入性编译障碍【英文标题】:Least intrusive compile barrier for Java on x86 【发布时间】:2013-01-17 14:31:46 【问题描述】:如果我有一个 Java 进程通过共享的 ByteBuffer 或类似的方式与其他进程交互,那么 C/C++ 中编译器屏障的侵入性最小的等价物是什么?不需要可移植性 - 我对 x86 特别感兴趣。
例如,我有 2 个进程根据伪代码读取和写入内存区域:
p1:
i = 0
while true:
A = 0
//Write to B
A = ++i
p2:
a1 = A
//Read from B
a2 = A
if a1 == a2 and a1 != 0:
//Read was valid
由于 x86 上严格的内存排序(加载到单独的位置不重新排序,读取到单独的位置不重新排序)这在 C++ 中不需要任何内存屏障,只是每次写入和每次读取之间的编译屏障(即 asm易挥发的)。
如何以最便宜的方式在 Java 中实现相同的排序约束。有什么比写到 volatile 更不打扰的吗?
【问题讨论】:
【参考方案1】:sun.misc.Unsafe.putOrdered 应该做你想做的事 - 一个带有 volatile 在 x86 上隐含的锁的存储。我相信编译器不会在它周围移动指令。
这与 AtomicInteger 和朋友上的lazySet 相同,但不能直接与 ByteBuffer 一起使用。
与volatile
或AtomicThings
类不同,该方法适用于您使用它的特定写入,而不是成员的定义,因此使用它并不意味着读取任何内容。
看起来您正在尝试实现类似seqlock 的东西——这意味着您需要避免在读取版本计数器A
和读取/写入数据本身之间重新排序。一个普通的 int 不会削减它 - 因为 JIT 可能会做各种顽皮的事情。我的建议是为您的计数器使用 volatile int,然后使用 putOrdered
将其写入。这样,您无需为易失性写入付出代价(通常是十几个周期或更多),同时获得易失性读取隐含的编译器屏障(并且这些读取的硬件屏障是无操作的,使它们快速)。
说了这么多,我认为你在这里处于灰色地带,因为lazySet
不是正式记忆模型的一部分,也不能完全符合发生前的推理,所以你需要更深入的了解实际的 JIT 和硬件实现,看看是否可以通过这种方式组合。
最后,即使使用 volatile 读写(忽略 lazySet
),从 java 内存模型的角度来看,我不认为您的 seqlock 是合理的,因为 volatile 写入仅在该写入之间设置了一个发生前然后在另一个线程上读取,以及写入线程中的早期操作,但不在写入线程上的读取和写入之后的操作之间。换句话说,它是单向栅栏,而不是双向栅栏。我相信即使读取 A == N 两次,读取线程也可以看到以版本 N+1 写入您的共享区域。
评论中的澄清:
Volatile 只设置了单向障碍。它与 WinTel 在某些 API 中使用的获取/释放语义非常相似。例如,假设 A、Bv 和 C 最初都为零:
Thread 1:
A = 1; // 1
Bv = 1; // 2
C = 1; // 3
Thread 2:
int c = C; // 4
int b = Bv; // 5
int a = A; // 6
在这里,只有 Bv 是易失的。这两个线程在概念上与您的 seqlock 写入器和读取器执行类似的操作 - 线程 1 以一个顺序写入一些内容,线程 2 以相反的顺序读取相同的内容,并尝试从中推理排序。
如果线程 2 有 b == 1,那么 a == 1 总是,因为 1 发生在 2 之前(程序顺序),5 发生在 6 之前(程序顺序),最关键的是 2 发生在 5 之前,因为 5 读取在 2 处写入的值。因此,Bv 的写入和读取就像栅栏一样。上面 (2) 的东西不能“移到下面” (2),下面的东西 (5) 不能“移到上面” 5. 请注意,我只将每个线程的移动直接限制在一个线程中,但不能同时限制两个线程的移动,这将我们带到下一个示例:
与上述相同,您可能会假设如果 a == 0,那么 c == 0 也一样,因为 C 在 a 之后写入,在 a 之前读取。但是,挥发物不能保证这一点。特别是,上述发生之前的推理并不能阻止 (3) 被移到 (2) 之上,正如线程 2 所观察到的那样,它们也不能阻止 (4) 被推到 (5) 之下。
更新:
让我们具体看看你的例子。
我认为可能发生的是,展开 p1 中发生的写入循环。
p1:
i = 0
A = 0
// (p1-1) write data1 to B
A = ++i; // (p1-2) 1 assigned to A
A=0 // (p1-3)
// (p1-4) write data2 to B
A = ++i; // (p1-5) 2 assigned to A
p2:
a1 = A // (p2-1)
//Read from B // (p2-2)
a2 = A // (p2-3)
if a1 == a2 and a1 != 0:
假设 p2 看到 a1 和 a2 为 1。这意味着在 p2-1 和 p1-2(以及扩展为 p1-1)之间,以及 p2-3 和 p1-2 之间之前发生过。但是,在 p2 和 p1-4 中的任何内容之间都有发生之前。所以事实上,我相信在 p2-2 对 B 的读取可以观察到在 p1-4 的第二次(可能部分完成)读取,这可以“超越”p1-2 和 p1-3 的易失性写入。
这很有趣,我认为您可能仅凭这一点就提出一个新问题 - 忘记更快的障碍 - 即使使用 volatile,这是否也有效?
【讨论】:
同意 JVM 不移动指令,但 CPU 可能不移动。 是的,但它仍然具有特定的语义 - 特别是不与其他存储重新排序,这在 x86 上就像普通存储一样简单,这就是 putOrdered 和lazySet 在该平台上实现的方式。所以该方法应该做OP想要的。当然,确切的语义记录很少,因此可能需要仔细检查 JDK 源代码。 谢谢,这看起来很合适。在阅读方面呢? 我在上面添加了更多细节来解决这个问题。我担心即使完全 volatile 读/写,您的 seqlock 也不起作用(理论上,我认为它可能在实践中起作用),因为 volatile 读取意味着您将看到在相应写入之前发生的所有事情,但请不要阻止您看到在那篇文章之后发生的更多事情。 感谢您的更新。为了确认(我不精通 Java,所以请原谅我的无知),您认为 volatile 读取只能保证针对早期写入而不是早期读取的排序,这意味着从 B 读取或从 A 的初始读取可能是从 A 的第二次读取重新排序?那么唯一的解决方案是原始写入 volatile?【参考方案2】:您可以使用lazySet,它可以比设置易失性字段快10 倍,因为它不会停止CPU 管道。例如AtomicLong lazySet 或者您可以根据需要使用 Unsafe 等效项。
【讨论】:
以上是关于x86 上 Java 的最小侵入性编译障碍的主要内容,如果未能解决你的问题,请参考以下文章