【问题标题】:Java memory model: single thread and multi-core CPUJava内存模型:单线程多核CPU
【发布时间】:2019-12-03 14:42:41
【问题描述】:

在 Java 应用程序中,如果对对象状态的访问发生在同一个线程上(在最简单的情况下,在单线程应用程序中),则无需同步以强制更改的可见性/一致性,因为发生了 -在关系规范之前:

“两个动作可以通过happens-before关系排序。如果一个动作发生在另一个动作之前,那么第一个动作对第二个可见并在第二个之前排序。

如果我们有两个动作 x 和 y,我们写 hb(x, y) 来表示 x 发生在 y 之前。

如果 x 和 y 是同一个线程的动作,并且 x 在程序顺序中位于 y 之前,则为 hb(x, y)。"

但是现代架构是多核的,因此 Java 线程可能在任何给定时间在其中任何一个上执行(除非这不是真的并且 Java 线程被固定到特定的内核?)。因此,如果是这种情况,如果一个线程写入变量 x,将其缓存在 L1 缓存或 CPU 寄存器中,然后开始在另一个内核上运行,该内核先前访问过 x 并将其缓存在寄存器中,那么该值是不一致的。 . 当线程从 CPU 中取出时,是否有某种机制(隐式内存屏障)?

【问题讨论】:

    标签: java concurrency synchronization memory-barriers memory-model


    【解决方案1】:

    可能在任何给定时间对其中任何一个执行

    任务不只是在内核之间自发迁移。这些事情必须发生:

    • 任务在之前运行的内核上被抢占(在内核的全局任务列表中将其标记为等待运行)
    • 另一个内核上的内核任务调度程序看到该任务正在等待 CPU 并决定运行它。

    (调度是一种分布式算法;每个内核都在该内核上有效地运行内核,非常像一个多线程进程。一个内核无法告诉另一个内核该做什么,只能将数据放在内核运行的内存中在那个核心上可以看看。)


    这不是问题,因为:

    数据缓存(L1 等)在操作系统可以调度线程的所有内核上是一致的。 Myths Programmers Believe about CPU Caches

    或者在假设的和不太可能的硬件 + 操作系统 + JVM 上运行线程跨内核具有非一致共享内存,操作系统将不得不在某个时间点将脏私有缓存刷新回实际共享内存,在一个内核上停止任务后,在将其放入全局任务队列之前,另一个内核上的任务调度程序可以运行它。

    线程从 CPU 中取出时是否存在某种机制(隐式内存屏障)?

    在真实世界的系统(一致缓存)上,操作系统只需要确保有一个完整的内存屏障,它会在另一个内核恢复任务之前耗尽一个内核上的存储缓冲区。

    这个障碍并不总是隐含地作为操作系统要做的事情的一部分; 操作系统内核可能需要为此明确包含一个屏障。但是,保存寄存器状态并将任务标记为可运行可能至少需要释放存储,因此可以恢复此任务状态的另一个内核也将看到该任务已完成的所有用户空间存储。

    不过,我听说过在没有足够障碍的情况下在 CPU 之间迁移来破坏单线程进程的可能性。 对于操作系统,这是需要考虑的事情。它根本不是 Java 特定的。它是关于如何不破坏运行任意机器代码的单个线程。


    只有寄存器是真正私有的,是的,编译器会将变量保存在寄存器中。我不喜欢“缓存”这个词。在 asm 中,寄存器与内存是分开的。编译器可以在循环期间将当前有效的变量副本保存在寄存器中,然后再将其存储回来。

    每个任务都有自己的寄存器状态;这称为“架构状态”,是由上下文切换保存/恢复的上下文。

    在另一个内核上重新开始执行线程意味着从内存中恢复其保存的寄存器状态,以恢复其程序计数器结束。即跳转到它停止的指令,将程序计数器恢复到体系结构程序计数器寄存器中。例如在 x86-64 上进行 RIP。 (“指令指针”寄存器的 64 位版本)

    请注意,寄存器和(虚拟)内存内容是用户空间进程的整个状态(以及打开的文件描述符和其他与之相关的内核内容)。但缓存状态不是。寄存器不是缓存。缓存对软件是透明的(内存重新排序是因为存储缓冲区和 CPU 内存并行性来隐藏缓存未命中,而不是因为缓存,在大多数 ISA 上)。寄存器是局部变量的 asm 等价物。


    编译器术语:“缓存”

    从循环中提升负载并将值保存在寄存器中有时被描述为“缓存”寄存器中的值,但这只是让您感到困惑的偶然术语。编译器-开发者的术语是变量的“注册”。

    或者只是从循环中“提升”负载或“下沉”商店;通常,您需要先将一个值加载到寄存器中,然后才能将其用于其他用途(至少在没有内存源 ALU 指令的 RISC 上)。通过提升循环不变值的负载,您只需在循环之前加载一次,然后多次重新读取寄存器。

    商店也一样;如果您知道不允许其他线程查看变量的内存位置,则只需要使用存储指令实际存储最终值。如果没有任何东西可以读取它们,那么任何其他值的其他存储都将是“死的”,并且我们知道有一个后来的存储。因此,您在循环期间将变量保存在寄存器中,然后在最后存储一次。这被称为“沉没”商店,与消除死店有关。

    【讨论】:

    • 或者简而言之:如果多核 CPU 的引入破坏了最初为单核机器编写的所有现有软件,那么多核 CPU 就永远不会起飞。
    【解决方案2】:

    Peter Cordes 从实现层面回答你,但值得一提的是规范层面:

    如果 x 和 y 是同一个线程的动作,并且 x 在程序顺序中位于 y 之前,则为 hb(x, y)。"

    这几乎说明了一切。 Java 语言规范保证如果 x 在您的程序的源代码级别出现在 y 之前,那么为了确定内存可见性,x “发生在”y 之前。

    Peter Cordes 所说的所有内容都保证了hb(x, y)。如果任何 JVM 未能完成所有这些工作,那么它就不是 Java 的有效实现。

    长话短说:如果您的代码只在单个线程中运行,那么您将永远不必担心内存可见性。

    【讨论】:

    • 刚刚扩展了我的答案。实际上,JVM 只依赖于操作系统来不破坏单线程代码。如果您将整个 OS + java 进程视为实现 Java 虚拟机,那么您的说法是准确的。但是如果 JVM = java 用户空间进程,那么在这方面并不特殊,不能在这方面失败;乱序执行(和操作系统)的基本规则是保持单个线程按程序顺序运行的错觉。所以 CPU 迁移对 JVM 来说是绝对透明的。
    • @PeterCordes,同意。我希望任何设计为在典型的多处理器桌面/服务器/移动平台上运行的 JVM 都严重依赖操作系统来提供对线程和内存模型的支持。对于纯 Java 程序的行为方式的答案是否应该包括有关在 JRE 源代码中实现什么以及在 OS 源代码中实现什么的详细信息,我没有强烈的意见。但有时我认为足够从 Java 程序员的角度来说,这一切都发生在“JVM 中”。
    • 线程是,在多个内核上启动线程。但内存型号没有;为了正确实现锁或volatile(如具有默认顺序一致性语义的C++ std::atomic),JVM 本身必须JIT 内存屏障指令如x86 mfence 或ARM dsb ish 来处理多线程的情况在多个内核上同时运行。不破坏单个线程主要取决于硬件,但锁定和无锁原子在很大程度上依赖于用户空间执行正确的指令来实现任何必要的内存排序语义。
    • 没错,但是当您谈论 volatile 时,您就偏离了 OP 的问题,因为在仅在单个线程中运行的代码中不需要 volatile
    • 无论如何,是的,说JVM实现Java内存模型就足够了,但是这个SO问题中唯一明确的问题是明确询问这是如何实现的。由于不了解硬件和上下文切换,问题的前面部分只是导致明显矛盾或需要内存屏障的事实。
    猜你喜欢
    • 1970-01-01
    • 2017-03-07
    • 1970-01-01
    • 1970-01-01
    • 2014-02-07
    • 2011-08-03
    • 1970-01-01
    • 2016-05-08
    • 1970-01-01
    相关资源
    最近更新 更多