关于 java 中的 volatile 关键字的问题,csharp。为什么大多数来源都说对主内存而不是共享缓存进行易失性更新?

Question about volatile keyword in java, csharp. Why do most sources say volatile updates to main memory rather than the shared cache?

提问人:omar santaella 提问时间:4/17/2023 最后编辑:Charliefaceomar santaella 更新时间:4/18/2023 访问量:85

问:

根据消息来源的不同,我听到了不同的事情。有人说易失性更新到共享缓存,但大多数人说它更新到 ram(主内存)并从 ram 读取。显然,如果您正在从 ram 编写和读取,这将严重破坏性能。鉴于现代处理器具有共享的多核缓存,写入变量是否只更新本地缓存,而不更新共享缓存?voltile 是否在缓存级别纠正了这个问题,或者正如大多数人所说的那样,它是否依赖于主内存?

如果变量未在共享缓存级别更新,是否有技术原因导致这种情况发生?从共享缓存中更新和读取似乎比从主内存中更新和读取具有更高的性能。

我正在尝试理解更好的多线程代码和性能影响。没有遇到任何问题。

多线程 缓存 与语言无关

评论

0赞 Farrukh Nabiyev 4/17/2023
为了理解 voltile 关键字,请参阅它在 C 语言中的用法。
0赞 orhtej2 4/17/2023
有关保证的内容,请查看 C# 文档Java 内存模型。请注意,您假定某些实现细节比这两种语言实际保证的要强。一个有趣的观点是,Java 代码运行在 Java 虚拟机上,这是对底层硬件的某种抽象。您的假设适用于物理硬件。volatile
0赞 pveentjer 4/18/2023
@FarrukhNabiyev C 中的 volatile 与 Java 中的 volatile 完全不同。C 语言中的 volatile 向编译器发出信号,表明它应该按原样发出加载/存储,而不是尝试优化它。Java volatile 比这强得多。
0赞 pveentjer 4/18/2023
现代处理器上的缓存始终是连贯的。主内存只是一个溢出桶,用于存放缓存中不适合的任何内容,而主内存根本不需要与缓存保持同步。这将是极其低效的。例如,如果单个内核将更新一个未争用的易失性变量,则包含该变量的缓存行将始终处于正确的状态,并且不需要离开内核。因此,更新可以非常快地完成,而无需写入主内存。易失性不会导致缓存刷新到主内存;这是一个非常普遍的误解。

答:

0赞 Charlieface 4/17/2023 #1

现代 CPU 具有完全一致的缓存

volatile主要是用于编译 ASM 的语言功能。它所做的几乎只是强制写入内存地址而不是寄存器,它不决定是否缓存该内存地址或缓存在何处。实际写入可能是全局或本地缓存行,也可能是主内存。

它还可能引入写入屏障,以防止指令重新排序。它还强制写入是原子的。

执行任何涉及缓存行的操作。CPU 内核管理其本地缓存线,并确保它们始终与其他本地缓存线、全局缓存线和主内存保持一致。这里一致并不意味着它们都是一样的。这意味着在任何时候,内核都会知道特定位置是否有效,可以读取或写入,因此特定内存地址的真实来源在哪里。

评论

0赞 pveentjer 4/18/2023
易失性不仅仅是硬件层面的围栏。它还确保 (1) 编译器不会优化加载或存储(可见性),(2) 编译器/CPU 将保证某些顺序,以及 (3) 加载/存储是原子的;所以没有读写撕裂。因此,即使您将在顺序一致的假设处理器上运行,仍然需要易失性来控制编译器。
0赞 pveentjer 4/18/2023
写入将始终对本地缓存行执行,因为在执行写入之前,缓存行需要放在该内核本地缓存中的正确位置(如果是 MESI,则为 M/E)。内存根本不需要与缓存一致;那将是非常缓慢的。缓存是事实的来源。缓存一致性协议将确保内核之间的一致性,但不能确保主内存的一致性。
0赞 pveentjer 4/18/2023
保证对缓存行的写入不会丢失。因此,如果存在脏缓存行并且需要从缓存中逐出缓存行,则该缓存行最终将进入主内存。一个有趣的边缘案例是MESI协议;假设缓存行在 1 个内核中处于修改 (M) 状态,而另一个内核想要读取该缓存行,则缓存行将被写入主内存,因为 MESI 不支持脏共享。MOESI解决了这个问题。
0赞 Charlieface 4/18/2023
1. 我在第一行提到了这一点。2. 我在第二行提到了这一点。诚然,它也确实确保了原子性。我在最后提到了我所说的一般一致性的含义:CPU 总是知道要使用哪个位置(有时是它自己的本地内存,有时是主内存,有时是强制另一个内核的缓存刷新)。是的,本地缓存行不能被另一个 CPU 使用(毕竟它是本地的)。我并不打算把它写成一篇关于MESI的巨著,欢迎你写下你自己的答案。
0赞 pveentjer 4/18/2023
看来你已经编辑了你的答案。仍然有一些信息是不正确的,或者至少是有问题的。“volatile 所做的只是强制写入内存,而不是寄存器。我不确定如何解释这一点,但如果您指的是主内存,那么这是不正确的。易失性不会强制将缓存行“刷新”到主内存。在 X86 上可能发生的情况是,易失性插入一个内存围栏,该内存围栏会停止加载的执行,直到存储缓冲区被耗尽到一致的缓存中,以确保较旧的存储不会因较新的负载而重新排序。
1赞 rzwitserloot 4/17/2023 #2

这不是java语言的工作方式。Java-the-Language 有一个官方规范,该规范不是针对特定处理器编写的。

控制文档是 Java 内存模型

JMM在保证方面起作用。通常,JMM的工作原理如下:

  • 如果您执行某些操作,则 JVM 会保证这些可靠性。
  • 否则。。。任何事情都可能发生。该规范根本没有解释或做出任何承诺。

这些东西被故意保持得非常抽象。因此,JVM 实现只需要确保第一个项目符号是有保证的,并且可以做任何它想做的事情。因此,像这样的东西只是保证不再可能从一个线程而不是另一个线程观察更新,以及 JVM 如何确保这种保证取决于 JVM。volatile

这就是 java 可以高效的原因

下面是一个简单的示例:

JMM保证以下几点:

有一个“发生之前”原因的枚举列表,每个条目都高度规范。任何 2 行(从技术上讲,任何 2 字节码指令)要么具有 HB 关系,要么没有 HB 关系,您可以确定,因为它是指定的。假设我们有 Bytecode1 和 Bytecode2,并且 bc1 与 bc2 具有 Happens-Before 关系。

JVM 保证在该上下文中,当 BC2 执行时,不可能观察到 BC1 完成执行之前的状态。

这就是 JVM 所保证的全部内容。

例如,指定为相对于该线程方法中的第一行的 HB。因此,鉴于:someThread.start()run()

class Test {
  static long v = 10;
  
  void test() {
    v = -1;
    new Thread(() -> {
      System.out.println(v);
    }).start();
  }
}

上面的代码必须打印 -1。如果它打印 20,则该 JVM 有问题;如果你提交了关于它的错误,它将被接受。这是因为 是相对于 之前发生的,因此,能够观察到以前的状态是 JVM 规范保证不会发生的事情。v = -1;.start()v = -1;

相比之下:

class Test {
  static long v = 10;

  void test() {
    new Thread(() -> {
      Thread.sleep(1000);
      System.out.println(v);
    }).start();
    v = -1;
  }
}

这里的 JVM 规范说,打印“10”的 JVM 是合法的。打印“-1”的 JVM 也是合法的。抛硬币来确定这一点的 JVM 是合法的。一个 JVM,它决定每天在今天的每个单元测试中打印 -1,即使你运行它 10000 次,然后当你向那个重要客户演示时,它开始返回 10...那是合法的。

事实上,JVM 规范甚至说进行拆分读取是合法的(前 32 位都是 1 位,因为都是 1 位,而后 32 位仍然是 20,导致一些大的负数)。这是一个有趣的案例:你今天可能运行的 JVM 几乎不会被强制去实际生成拆分读取,因为谁仍然有完整的 32 位支出,即便如此,你通常也无法捕捉到“介于两者之间”的另一个线程。-1

但是,如果您编写的代码在发生拆分读取条件时会中断,则该代码有问题。如果一棵树倒在森林里......——如果你编写了一个不可能再触发的错误,这是一个错误吗?我不知道,问一个哲学家:)

整天提交关于这个的错误,它只会被拒绝:JVM工作正常,因为它遵守规范。

即使你把休眠时间延长到“14天”,让JVM运行那么长时间,它也可以打印出“10”,尽管这看起来很疯狂(14天前它被设置为-1!),但有些JVM确实可以做到这一点。

volatile在确切的保证方面有点复杂,它也适合这个 Happens-Before 框架(本质上,任何与易失性字段交互的 2 字节码指令,其中至少一个是写入指令,建立 HB,但要知道哪一个是 Happens-Before 可能有点棘手)。

JVM 是如何实现这一点的?这取决于 JVM。如果有一种有效的方法来提供这种保证,它就会做有效的事情。

这正是 JVM 被如此抽象地指定的原因。想象一下,规范从字面上阐明:确保对字段的任何写入都将立即刷新到主 RAM。volatile

然后,如果有一种更快的方法来确保线程间的一致性,例如,仅将页面从一个内核直接刷新到另一个内核(想象一下存在一个处理器可以做这样的事情),或者所有内核共享一个缓存级别,而您只能刷新到那里 - 那么 JVM 无法使用这些功能,因为一些善意的白痴决定对 Java 语言规范中的某些显式 CPU 过于具体公文。

有关大多数 JVM 当前如何实现在使用时保证某些行为的要求的细分,请参阅 @Charlieface 的回答。但要意识到,这个答案实际上并不是 java lang 规范以任何方式保证的;JLS 甚至没有提到“缓存行”这个词。volatile

评论

1赞 omar santaella 4/17/2023
有趣的是,如果是这样的话,关于挥发性的解释也应该是抽象的。如果是这样的话,说它进入主内存似乎不一定是正确的,它可能会发生,但并非在所有情况下都会发生。
0赞 rzwitserloot 4/17/2023
事实上,这是一个过于简化的解释,关键词已经结束了——试图以简洁的方式解释什么太过分了,这样做现在实际上是完全不正确的。volatile
0赞 omar santaella 4/17/2023
我读过,当使用同步时,可能不需要使用易失性。这让我想知道同步是否也会使同步代码块中的变量在后台不稳定。
0赞 rzwitserloot 4/17/2023
这让我想知道同步是否也会使同步代码块中的变量在后台不稳定。- 这是荒谬的。 意味着“关于可观察性的一些抽象保证”,它与 CPU 设计没有任何联系。因此,你的沉思毫无意义。volatile
0赞 rzwitserloot 4/17/2023
这就是这里的中心点:“JVM规范告诉我我可以依赖什么”,这就是你作为java程序员应该担心的。真的没有“......这就是它在 CPU 上运行的方式“,因为这是无用的:下一个 JVM 版本可能不再如此,在不同的 CPU 架构上可能不再如此,并且 java 在许多平台上运行。
0赞 Solomon Slow 4/17/2023 #3

Java 语言规范 (JLS) 几乎没有提到“主内存”或“缓存”。它严格地描述了变量的行为,即它如何限制不同线程中事件的顺序。 JLS的描述有点枯燥和学术。(在第 17 章中出现的任何位置都搜索“挥发性”。但大多数情况下,它告诉你的是;无论线程 A 在写入某个字段之前执行什么操作,都必须在线程 B 读取同一字段并获取 A 写入的值时对另一个线程 B 可见volatilevolatileff

评论

0赞 pveentjer 4/18/2023
这不仅仅是订购。可见性和原子性也是关键的保证。并且要小心“时间”,因为它可能会导致错误的印象。JMM 以顺序一致性 (SC) 表示,不依赖于物理时间。在 SC 下,只要不违反程序顺序,读取和写入就很好。线性化能力 = SC + 保留物理时间。