了解 Java 可变可见性

Understanding Java volatile visibility

我正在阅读有关 Java volatile 关键字的信息,但对其 'visibility' 感到困惑。

volatile关键字的典型用法是:

volatile boolean ready = false;
int value = 0;

void publisher() {
    value = 5;
    ready = true;
}

void subscriber() {
    while (!ready) {}
    System.out.println(value);
}

正如大多数教程所解释的那样,对 ready 使用 volatile 可确保:

我理解第二个,因为volatile变量通过使用内存屏障来防止内存重新排序,所以在volatile write之前的写入不能在它之后重新排序,而在volatile read之后的读取不能在它之前重新排序。这就是 ready 在上面的演示中阻止打印 value = 0 的方式。

但我对第一个保证感到困惑,即 volatile 变量本身的可见性。这对我来说是一个非常模糊的定义。

换句话说,我的困惑只在于单个变量的可见性,而不是多个变量的重新排序之类的。让我们简化上面的例子:

volatile boolean ready = false;

void publisher() {
    ready = true;
}

void subscriber() {
    while (!ready) {}
}

如果ready没有定义volatile,订阅者是否有可能无限地卡在while循环中?为什么?

想请教几个问题:

   Time : ---------------------------------------------------------->

 writer : --------- | write | -----------------------
reader1 : ------------- | read | -------------------- can I see the change?
reader2 : --------------------| read | -------------- can I see the change?

希望我清楚地解释了我的问题。

语言规范的相关位:

volatile 关键字:https://docs.oracle.com/javase/specs/jls/se16/html/jls-8.html#jls-8.3.1.4

内存模型:https://docs.oracle.com/javase/specs/jls/se16/html/jls-17.html#jls-17.4

正如您所说,CPU 缓存不是这里的一个因素。

这更多是关于优化。如果 ready 不是 volatile,编译器可以自由解释

// this
while (!ready) {}

// as this
if (!ready) while(true) {}

这当然是一种优化,它必须评估条件的次数更少。该值在循环中没有改变,可以“重用”。在单线程语义上它是等价的,但它不会做你想要的。

这并不是说这种情况总会发生。编译器可以自由地这样做,他们不必这样做。

您的 3 个问题的答案:

  1. 易失性写入的更改不需要'immediately'对易失性负载可见。一个正确同步的 Java 程序将表现得好像它是顺序一致的,并且为了顺序一致,loads/stores 的实时顺序是不相关的。因此,只要不违反程序顺序(或者只要没有人可以观察到),读写就可以倾斜。线性化=顺序一致性+尊重实时顺序。有关详细信息,请参阅此 answer

  2. 我仍然需要深入研究可见的确切含义,但据我所知,这主要是编译器级别的问题,因为硬件会无限期地阻止缓冲 loads/stores。

  3. 关于文章的错误,你是完全正确的。写了很多废话,'flushing volatile writes to main memory instead of using the cache' 是我看到的最常见的误解。我认为我所有 SO 评论中有 50% 是关于告知人们缓存始终是连贯的。关于该主题的一本好书是 'A primer on memory consistency and cache coherence 2e',可用于 free

Java 内存模型的非正式语义包含 3 个部分:

  • 原子性
  • 可见度
  • 订购

原子性是关于确保 read/write/rmw 在全局内存顺序中以原子方式发生。所以没有人可以观察到一些介于两者之间的状态。这处理访问原子性,如撕裂 read/write、单词撕裂和正确对齐。它还处理像 rmw 这样的操作原子性。

恕我直言,它还应该处理存储原子性;所以要确保有一个时间点,所有核心都可以看到商店。例如,如果您有 X86,那么由于负载缓冲,存储可以比其他内核更早地对发布内核可见,并且您违反了原子性。但是我还没有看到它在JMM中被提及。

可见性:这主要是为了防止编译器优化,因为硬件会防止无限期地延迟加载和缓冲存储。在某些文献中,他们还会在可见性下抛出周围 loads/stores 的排序;但我不认为这是正确的。

排序:这是内存模型的基础。它将确保单个处理器发出的 loads/stores 不会被重新排序。在第一个示例中,您可以看到对此类行为的需求。这是编译器障碍和 cpu 内存障碍的领域。

有关详细信息,请参阅: https://download.oracle.com/otndocs/jcp/memory_model-1.0-pfd-spec-oth-JSpec/

Visibility, for modern CPUs is guaranteed by cache coherence protocol (e.g. MESI) anyway, so what can volatile help here?

这对你没有帮助。您不是在为现代 CPU 编写代码,您是在为 Java 虚拟机编写代码,该虚拟机允许拥有一个虚拟机 CPU,其虚拟 [=34] =]缓存不一致。

Some articles say volatile variable uses memory directly instead of CPU cache, which guarantees visibility between threads. That doesn't sound a correct explain.

没错。但是请理解,这是针对您正在为其编码的 虚拟 机器。它的内存很可能在您的 CPU 物理缓存中实现。这可能允许您的机器使用缓存并仍然具有 Java 规范所需的内存可见性。

使用volatile 可以确保写入直接进入虚拟机的内存而不是虚拟机的虚拟CPU 缓存。虚拟机的 CPU 缓存不需要提供线程之间的可见性,因为 Java 规范不要求它。

您不能假设特定物理硬件的特性必然提供 Java 代码可以直接使用的好处。相反,JVM 会牺牲这些好处来提高性能。但这意味着您的 Java 代码无法获得这些好处。

同样,您不是在为您的物理 CPU 编写代码,您是在为您的 JVM 提供的虚拟 CPU 编写代码。您的 CPU 具有一致的缓存允许 JVM 进行各种优化以提高代码的性能,但是 JVM 不需要 将这些一致的缓存传递给您的代码而真正的 JVM 则不会。这样做将意味着消除大量非常有价值的优化。

If ready is not defined volatile, is it possible that subscriber get stuck infinitely in the while loop?

是的。

Why?

因为订阅者可能永远看不到发布者写入的结果。

因为...JLS 不要求 将变量的值写入内存...除非满足指定 可见性限制。

What does 'immediately visible' mean? Write operation takes some time, so after how long can other threads see volatile's change? Can a read in another thread that happens very shortly after the write starts but before the write finishes see the change?

(我认为)JMM 指定或假设在物理上不可能同时读取和写入相同的概念性内存单元。因此,对存储单元的操作是按时间顺序进行的。 Immediately visible 表示在写入后的下一个可能的读取机会中可见。

Visibility, for modern CPUs is guaranteed by cache coherence protocol (e.g. MESI) anyway, so what can volatile help here?

  1. 编译器通常生成将变量保存在寄存器中的代码,并且只在必要时将值写入内存。将变量声明为 volatile 意味着值 必须 写入内存。如果考虑到这一点,就不能仅依靠 缓存实现的(假设的或实际的)行为来指定 volatile 的含义。

  2. 虽然当前一代的现代 CPU / 缓存架构以这种方式运行,但不能保证所有未来的计算机都会以这种方式运行。

Some articles say volatile variable uses memory directly instead of CPU cache, which guarantees visibility between threads.

有人说这是不正确的...对于实现缓存一致性协议的 CPUs。然而,这不是重点,因为如上所述,变量的 current 值可能尚未写入缓存。事实上,它可能永远不会被写入缓存。

   Time : ---------------------------------------------------------->

 writer : --------- | write | -----------------------
reader1 : ------------- | read | -------------------- can I see the change?
reader2 : --------------------| read | -------------- can I see the change?

因此,假设您的图表显示物理时间并表示不同物理内核上的线程 运行,通过各自的缓存读取和写入缓存一致的内存单元。

物理层会发生什么取决于缓存一致性的实现方式。

我会 期望 Reader 1 查看单元格的先前状态(如果它可从其缓存中获得)或新状态(如果不是) . Reader 2 会看到新状态。但这也取决于写入线程的缓存失效传播到其他线程的缓存需要多长时间。以及其他各种难以解释的东西。

简而言之,我们真的不知道在物理层面会发生什么。

但另一方面,上图中的作者和读者无论如何也无法观察物理时间。程序员也不行。

程序/程序员看到的是读写不重叠。当必要的 关系出现之前发生时,将保证一个线程的内存写入对另一个线程的后续 1 读取的可见性。这适用于 volatile 变量,以及其他各种事物。

这个保证是如何实现的,不是你的问题。如果您确实了解它在硬件级别发生了什么,那真的没有帮助,因为您实际上并不知道 JIT 编译器将发出什么代码(今天!)。


1 - 也就是说,根据 同步顺序 后续......您 可以 将其视为逻辑时间。 JLS 内存模型实际上根本不讨论时间。

我只谈这部分:

change to ready on publisher thread is immediately visible to other threads

那是不正确的,文章是错误的。文档做了一个非常明确的声明 here:

A write to a volatile field happens-before every subsequent read of that field.

这里比较复杂的部分是后续。用简单的英语来说,这意味着当有人将 ready 视为 true 时,它也会将 value 视为 5。这自动意味着您需要 观察 该值为 true,并且您可能会观察到不同的东西。所以这不是“立即”。

人们将其混淆的是 volatile 提供 顺序一致性 ,这意味着如果 某人 观察到 ready == true,然后 每个人 也会(例如,不同于 release/acquire)。