Java 8中的Unsafe类:xxxFence()指令

51

Java 8 在 Unsafe 类中添加了三个内存屏障指令 (来源):

/**
 * Ensures lack of reordering of loads before the fence
 * with loads or stores after the fence.
 */
void loadFence();

/**
 * Ensures lack of reordering of stores before the fence
 * with loads or stores after the fence.
 */
void storeFence();

/**
 * Ensures lack of reordering of loads or stores before the fence
 * with loads or stores after the fence.
 */
void fullFence();

如果我们使用以下方式定义内存屏障(我认为这种方式更易于理解):
考虑X和Y为操作类型/类,它们可以被重新排序, X_YFence() 是一个内存屏障指令,确保在屏障之前的所有X类型的操作都在屏障之前完成,在屏障之后开始任何Y类型的操作。
现在可以将Unsafe中的屏障名称映射到此术语中: loadFence() 变成 load_loadstoreFence()storeFence() 变成 store_loadStoreFence()fullFence() 变成 loadstore_loadstoreFence()
最后,我的问题是 - 为什么没有load_storeFence()store_loadFence()store_storeFence()load_loadFence()
我的猜测是-它们不是真正必要的,但我目前不明白原因。所以,我想知道为什么没有添加它们。对此的猜测也欢迎(希望这不会导致这个问题不适合作为基于意见的问题)。
谢谢。

不是C++专家,但不安全的指令可能只是将C++中可用的内容映射到标准汇编中可用的内容。 - assylias
@assylias 目前无法证明,但我认为C++指令可以非常灵活,并允许不同类型的屏障。按照常规逻辑,禁止仅加载应该比同时禁止加载和存储更容易。 - Alexey Malev
3
最终,这取决于处理器级别可用的指令。例如,请参阅:http://gee.cs.oswego.edu/dl/jmm/cookbook.html - assylias
@assylias 非常感谢,我会看一下的。 - Alexey Malev
4个回答

64

总结

CPU核心具有特殊的内存顺序缓冲区,以帮助它们进行乱序执行。这些缓冲区可以是(通常是)用于加载和存储的独立的:载入顺序缓冲区(LOB)和存储顺序缓冲区(SOB)。

Unsafe API选择的栅栏操作基于以下假设:底层处理器将具有单独的载入顺序缓冲区(用于重新排序载入),存储顺序缓冲区(用于重新排序存储)。

因此,基于此假设,从软件角度来看,您可以向CPU请求以下三种事情之一:

  1. 清空LOB(loadFence):意味着在LOB中的所有条目被处理之前,该核心上不会启动任何其他指令。在x86中,这是一个LFENCE。
  2. 清空SOBs(storeFence):意味着在SOB中的所有条目被处理之前,在该核心上不会启动任何其他指令。在x86中,这是一个SFENCE。
  3. 清空LOB和SOB(fullFence):即上述两种情况。在x86中,这是一个MFENCE。

实际上,每个特定的处理器体系结构都提供不同的内存顺序保证,这些保证可能比上述更严格或更灵活。例如,SPARC体系结构可以重新排序加载存储和存储加载序列,而x86不会这样做。此外,存在一些体系结构,其中LOB和SOB无法单独控制(即只能使用全栅栏)。在这两种情况下:

  • 当架构更灵活时,API仅作为选择问题不提供对“更宽松”排序组合的访问

  • 当架构更严格时,API在所有情况下都实现更严格的排序保证(例如,所有3个调用实际上都作为完全栅栏实现)

选择特定API的原因在JEP中解释,正如assylias所提供的答案完全准确。如果您了解内存顺序和缓存一致性,那么assylias的答案就足够了。我认为它们与C++ API中的标准指令匹配是一个重要因素(大大简化了JVM的实现):http:// en.cppreference.com / w / cpp / atomic / memory_order 很可能实际实现将调用相应的C ++ API而不是使用某些特殊指令。

下面有一个基于x86的详细说明,其中提供了理解这些内容所需的所有上下文。实际上,以下部分(以下部分回答了另一个问题:“您能够提供有关内存栅栏如何在x86体系结构中控制缓存一致性的基本示例吗?”)

因为我自己是一名软件开发者而不是硬件设计师,所以我曾经很难理解什么是内存重排序,直到我学习了如何在x86中实际工作的缓存一致性的具体示例。这为讨论内存栅栏在一般情况下(也适用于其他架构)提供了宝贵的上下文。最后,我使用从x86示例中获得的知识稍微讨论了SPARC。

参考文献[1]提供了更详细的解释,并为讨论以下每个方面提供了单独的部分:x86、SPARC、ARM和PowerPC,因此如果您对更多细节感兴趣,它是一个非常好的阅读材料。


x86体系结构示例

x86提供三种类型的栅栏指令:LFENCE(加载栅栏)、SFENCE(存储栅栏)和MFENCE(加载-存储栅栏),因此它与Java API完全对应。

这是因为x86具有单独的加载顺序缓冲区(LOB)和存储顺序缓冲区(SOB),因此LFENCE / SFENCE指令确实适用于相应的缓冲区,而MFENCE适用于两者。

SOB用于存储传出值(从处理器到缓存系统),而缓存一致性协议工作以获取写入缓存行的权限。LOB用于存储无效请求,以便无效可以异步执行(减少接收端的停顿,希望在那里执行的代码实际上不需要该值)。

乱序存储和SFENCE

假设您有一个双处理器系统,其两个CPU 0和1执行以下例程。考虑缓存线最初由CPU 1拥有的持有“failure”的情况,而缓存线最初由CPU 0拥有的持有“shutdown”的情况。

// CPU 0:
void shutDownWithFailure(void)
{
  failure = 1; // must use SOB as this is owned by CPU 1
  shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
  while (shutdown == 0) { ... }
  if (failure) { ...}
}
在没有存储栅栏的情况下,CPU 0 可能会因故障发出关闭信号,但 CPU 1 将退出循环并且不会进入故障处理 if 块。这是因为 CPU 0 将向存储排序缓冲区中写入值为 1 的 failure,并发送缓存一致性消息以获取对缓存行的独占访问权限。然后它将继续执行下一条指令(同时等待独占访问),立即更新 shutdown 标志(该缓存行已被 CPU 0 独占所有,因此无需与其他内核进行协商)。最后,当它稍后收到来自 CPU1 的失效确认消息(关于失败)时,它将继续处理 failure 的 SOB 并将该值写入缓存(但此时顺序已经反转)。插入 storeFence() 将修复问题:
// CPU 0:
void shutDownWithFailure(void)
{
  failure = 1; // must use SOB as this is owned by CPU 1
  SFENCE // next instruction will execute after all SOBs are processed
  shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
  while (shutdown == 0) { ... }
  if (failure) { ...}
}
值得一提的最后一个方面是,x86具有存储转发功能:当CPU写入的值被卡住(由于缓存一致性)而被暂存在SOB中时,它可能随后尝试在SOB被处理并传递到缓存之前执行相同地址的load指令。因此,CPU在访问缓存之前会首先查看SOB,因此在这种情况下检索到的值是来自SOB中最后写入的值。这意味着来自此核心的存储永远不会与随后的来自此核心的加载重新排序,无论如何都不会。

乱序加载和LFENCE

现在,假设你已经设置了存储栅栏,并且确信shutdown不能在到达CPU 1之前超过failure,并关注另一侧。即使存在存储栅栏,仍然存在错误情况。考虑这样的情况:failure存在于两个缓存中(共享),而shutdown仅存在于CPU0的缓存中并且由其独占拥有。以下情况可能会发生:
  1. CPU0向failure写入1;它还发送一条消息给CPU1,作为缓存一致性协议的一部分,来使其失效共享缓存行的副本。
  2. CPU0执行SFENCE并暂停,等待用于failure的SOB提交。
  3. CPU1由于while循环而检查shutdown(意识到缺少该值),发送一个缓存一致性消息以读取该值。
  4. CPU1收到来自CPU0的步骤1中使failure无效的消息,并立即对其进行确认。注意:这是使用失效队列实现的,因此实际上它仅输入一个注释(在其LOB中分配一个条目),以稍后执行失效,但在发送确认之前并不会实际执行失效操作。
  5. CPU0接收到failure的确认并通过SFENCE继续执行下一条指令。
  6. CPU0向shutdown写入1,而不使用SOB,因为它已经独占拥有缓存行。不发送额外的失效消息,因为缓存行专属于CPU0。
  7. CPU1接收到shutdown的值并将其提交给其本地缓存,然后继续执行下一行。
  8. CPU1检查if语句中的failure值,但由于失效队列(LOB注释)尚未处理,它使用来自其本地缓存的值0(不进入if块)。
  9. CPU1处理失效队列并将failure更新为1,但现在已经太迟了......
我们所谓的加载顺序缓冲区实际上是无效请求的排队,可以通过以下方式修复:
// CPU 0:
void shutDownWithFailure(void)
{
  failure = 1; // must use SOB as this is owned by CPU 1
  SFENCE // next instruction will execute after all SOBs are processed
  shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
  while (shutdown == 0) { ... }
  LFENCE // next instruction will execute after all LOBs are processed
  if (failure) { ...}
}

关于x86的问题

现在你已经知道SOB/LOB是什么了,考虑一下你提到的组合:

loadFence() becomes load_loadstoreFence();

不,装载屏障会等待大型对象(LOBs)被处理,从而清空失效队列。这意味着所有后续的装载将看到最新的数据(没有重新排序),因为它们将从缓存子系统(具有一致性)中获取。存储不能与后续的装载重新排序,因为它们不经过LOB。 (而且存储转发会处理本地修改的高速缓存行)。从此特定核心的视角来看(执行装载屏障的核心),在所有寄存器加载数据之后,跟随装载屏障的存储将执行。这是无法避免的。

load_storeFence() becomes ???

针对这种情况,没有必要使用load_storeFence,因为它没有意义。要存储某些内容,您必须使用输入进行计算。要获取输入,您必须执行加载操作。存储将使用从加载操作中获取的数据发生。如果要确保在加载操作后查看来自所有其他处理器的最新值,请使用loadFence。加载操作在栅栏之后,存储转发会处理一致排序。

所有其他情况类似。


SPARC

SPARC甚至更灵活,并可以重新排序存储和随后的加载(以及加载和随后的存储)。我不太熟悉SPARC,所以我的猜测是没有store-forwarding(当重新加载地址时不会使用SOBs),因此可能存在“脏读”的情况。实际上我错了:在[3]中找到了SPARC体系结构,并且现实是store-forwarding是线程化的。来自第5.3.4节:

所有加载操作都检查存储缓冲区(仅相同线程)是否存在read after write(RAW)危险。当加载操作的dword地址与STB中存储的地址匹配且加载操作的所有字节都在存储缓冲区中时,发生全局RAW。当dword地址匹配但存储缓冲区中未全部字节都有效时,发生部分RAW。(例如,对同一地址的ST(字存储)后跟LDX(dword加载)会导致部分RAW,因为完整的dword不存在于存储缓冲区条目中。)

因此,不同的线程会查找不同的存储顺序缓冲区,因此在存储操作之后可能存在脏读。


参考资料

[1] Memory Barriers: a Hardware View for Software Hackers, Linux Technology Center, IBM Beaverton http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.07.23a.pdf

[2] Intel® 64 and IA-32 ArchitecturesSoftware Developer’s Manual, Volume 3A http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf

[3] OpenSPARC T2 Core Microarchitecture Specification http://www.oracle.com/technetwork/systems/opensparc/t2-06-opensparct2-core-microarch-1537749.html


1
@assylias 我写了关于x86的内容,因为这是我最熟悉的架构(也是大多数人)。由于有这么多的架构,每个都需要单独的问题。参考文献[1]解释了特定于架构的内容。请记住,Java需要在大多数架构上实现。在不支持单独的LFENCE / SFENCE的情况下(ARM可能是这样),它们都被实现为MFENCE。在存在更细粒度控制的情况下(SPARC确实具有StoreLoad和LoadStore),则该原语在Java中不可用(可移植性更重要)。 - Alexandros
1
@Alexandros 实际上,在x86上,一些Java同步操作被翻译成无操作,因为处理器提供的保证比语言要求的更强。你可以想象Java可能有StoreLoad或LoadStore指令,并且对于那些不支持这些指令的CPU,它将被翻译为提供至少所需语义的最接近可用指令。我只是想说的是,他们本可以采用更细粒度的实现,但肯定有很好的理由他们没有这样做。 - assylias
1
我理解你的意思并且同意。这就是我在上面的评论中所说的“当存在更细粒度的控制时(SPARC确实具有StoreLoad和LoadStore),则该原语在Java中不可用(可移植性更为重要)”的意思。事实上,在那条评论之后,我编辑了帖子以反映前两段的情况。SPARC就是这样一个例子(同样,参考文献[1]解释了大部分内容),但是为每个架构提供一个示例需要很长时间,并且会使答案变长10倍。需要另外一个关于“架构X中缓存一致性如何工作”的问题。 - Alexandros
1
你写道“在SOB中的所有条目被处理之前,此核心不会执行任何其他指令”,但这与SFENCE的作用非常不同。这个描述会使它成为一个完整的屏障,在后续的加载(或存储)可以执行之前清空存储缓冲区。只有MFENCE或lock操作才能在让后续的加载执行之前清空存储缓冲区。x86的汇编内存模型是程序顺序+带有存储转发的存储缓冲区,因此**SFENCE和LFENCE对于内存排序无用**,除了movnt弱排序存储之外。 - Peter Cordes
1
存储屏障只会在早期的存储可见之前阻止后续的存储提交到缓存。据我所知,在任何架构上,包括阻止StoreLoad重排序(在后续加载之前排空存储缓冲区)的任何屏障,都是阻止其他所有操作的完整屏障。相比其他任何操作,StoreLoad的成本要高得多。https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ - Peter Cordes
显示剩余12条评论

8

一个很好的信息来源是JEP 171本身

理由:

这三种方法提供了三种不同类型的内存栅栏,一些编译器和处理器需要确保特定访问(加载和存储)不会被重新排序。

实现(摘录):

对于C ++运行时版本(在prims / unsafe.cpp中),通过现有的OrderAccess方法实现:

    loadFence:  { OrderAccess::acquire(); }
    storeFence: { OrderAccess::release(); }
    fullFence:  { OrderAccess::fence(); }

换句话说,新方法与JVM和CPU级别的内存栅栏实现密切相关。它们也与C++中可用的内存屏障指令相匹配,而Hotspot就是用这种语言实现的。更细粒度的方法可能是可行的,但好处并不明显。例如,如果您查看JSR 133 Cookbook中的CPU指令表,您会发现大多数架构上的LoadStore和LoadLoad映射到相同的指令,即两者都有效地是Load_LoadStore指令。因此,在JVM级别只有一个Load_LoadStore(loadFence)指令似乎是一个合理的设计决策。

6

storeFence()的文档有误。请参见https://bugs.openjdk.java.net/browse/JDK-8038978

loadFence()是LoadLoad加上LoadStore,通常被称为获取屏障。

storeFence()是StoreStore加上LoadStore,通常被称为释放屏障。

LoadLoad LoadStore StoreStore是廉价的屏障(在x86或Sparc上为nop,在Power上便宜,但在ARM上可能很昂贵)。

IA64具有不同的获取和释放语义指令。

fullFence()是LoadLoad LoadStore StoreStore加上StoreLoad。

StordLoad屏障很昂贵(几乎在所有CPU上),几乎与完整屏障一样昂贵。

这证明了API设计的合理性。


请参阅 https://preshing.com/20120930/weak-vs-strong-memory-models/。 - Peter Cordes

0

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接