当涉及的线程之一是 main() 线程时,为什么线程间可见性不需要 volatile 关键字?

Why is volatile keyword not needed for inter-thread visibility when one of the threads involved is the main() thread?

考虑以下程序:

import java.util.concurrent.TimeUnit;
public class StopThread {
    public static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Runnable task = new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (!stopRequested) {
                    i++;
                    System.out.println(i);
                }
                System.out.println("Stopping the thread!!");
            }
        };
        Thread backgroundThread = new Thread(task);

        backgroundThread.start();
        TimeUnit.SECONDS.sleep(5);
        stopRequested = true;
    }
}

main() 线程对共享变量 stopRequested 的所有更新是否对其他线程可见?即使不使用 volatile 关键字?

您还没有完全理解 Java 内存模型 (JMM) 的概念。

JMM 在 so-called Happens-Before/Happens-After 关系的基础上工作。它是这样工作的:

  • 每当任何线程读取一个字段时,我们称之为 'observing'。^1
  • 如果 X行(发生在之前)和Y行(发生在之后)之间存在happens-before/happens-after关系,那么JVM gua运行tees您无法从 Y 观察到 X 运行 之前的字段值。这是整个 JMM 提供给您的唯一保证。它以任何其他方式使 gua运行tees 为零:它没有说明 Y 的写入对 X 做了什么(特别是,X 也可能看到 Y 的写入,这很奇怪,因为 Y 运行之后? - 而且,当也没有 HB/HA 时,它不会生成 gua运行tees:然后 Y 可能会看到 X 之前或 X 之后的状态,任何一个都可能发生!)
  • HB/HA和实际时间完全无关。如果您使用时钟确定行 B 发生在行 A 之后,则不能保证 运行 这两者之间存在 HB/HA 关系,因此,任何由 A 引起的字段写入在 B 中不一定是可观察的。类似地,如果行 B 和 A 确实具有 HB/HA 关系,则可以保证运行您无法观察到处于 A [=106 之前的状态的任何字段=] 来自 B,但是,你实际上并没有得到任何 gua运行Tee B 将在 A 之后物理上(如,根据时钟)运行s。通常它必须按顺序能够让 B 观察 A 所做的更改,但如果 B 实际上没有检查 A 写的任何内容,那么 JVM 和 CPU 就没有必要小心,这 2 个语句可以 运行 并行,或者 B 甚至可以在 A 之前 运行,HB/HA 关系该死。
  • gua运行tee 不是two-way街!是的,如果B'happens after'A,那么你得到gua运行tee 你无法观察到A之前的场的状态。但反过来就不是这样了!如果 A 和 B 根本没有 HB/HA 关系,你就得不到 gua运行tees。你得到的是我喜欢称之为 evil coin.
  • 的东西

每当没有 HB/HA 关系并且 JVM 为您读取一个字段时,JVM 就会从它的厄运袋中取出邪恶的硬币并翻转它。尾巴,你得到了 A 写入之前的状态(例如,你得到了一个本地缓存副本)。头,你得到的是同步版本。

这枚硬币是邪恶的,因为它不会以任意 50/50 的随机概率掉落 heads/tails。不,不。它会着陆,以便您的代码在今天每次 运行 以及整个星期在 CI 服务器上每次测试套件 运行 时都能正常工作。然后在它到达生产服务器后,它仍然每次都以您想要的方式着陆。但是 2 周后,就在您向巨大的潜在新客户进行演示时?

然后它决定可靠地翻转你的另一条路。

JMM 为 JVM 提供了这种能力。这看起来非常疯狂,但这样做是为了让 JVM 尽可能多地进行优化。任何进一步的 gua运行tees 都会显着降低 JVM 的实际运行速度。

因此,您一定不能让 JVM 掷硬币。每当你通过字段写入的方式在 2 个线程之间通信时建立 HB/HA(几乎所有的东西最终都是字段写入,记住这一点!)

如何建立HB/HA?很多很多方式——你可以搜索它。最明显的:

  • 自然HB/HA:在同一线程中的任何'below'另一行和运行s 平凡地建立HB/HA。 IE。 x = 5; y = x;,单线程里就这样?那x读了明明会看到你写的
  • 线程开始。 thread.start(); 该线程内第一行之前的 HBs。
  • synchronized。在另一个线程的同步块中的第一行代码进入一个块(在同一个 x !)
  • volatile 访问类似地建立 HB/HA 尽管很难弄清楚哪一行实际上是第一行(它基本上说 em HBs 中的一个在另一个之前但是是哪一个?),所以保持记住这一点。

您的代码根本没有 HB/HA,所以 JVM 正在抛硬币。你不能在这里得出任何结论。那个stopRequested可能马上更新,可能下周更新,可能永远不更新,也可能5秒后更新。在决定执行这 4 个选项中的哪一个之前检查月相的 JVM 是 100% 有效的 java 实现。唯一的解决办法是不要让 VM 掷硬币,所以,用一些东西建立 HB/HA。

[1] 计算机尝试并行化并应用各种极其奇怪的优化。这会影响 timing(事情需要多长时间),并且没有(简单的)方法来给你计时 gua运行tees,所以 JMM 只是 不要这样做。在其他窝ds,如果你开始超时 and/or 尝试注册 CPU 计时器(System.nanoTime)并尝试记录事情的顺序,你可以 'see' 各种奇怪的事情,但如果您真的尝试了,请注意几乎所有记录事件的方法都会导致同步,从而使您的测试完全无效。关键是,如果你做对了,你可以并行地观察事物 运行ning 甚至完全无序。 gua运行tees 与这些无关,gua运行tees 仅与阅读领域有关。

Java 语言规范不保证此结果。

在没有同步操作(例如 volatile 写入和后续读取)的情况下,写入不会 发生在 读取之前,并且是因此不能保证可见。

即read可能看到旧值也可能看到新值; Java 内存模型允许任何一种结果。

要查看间隙有多窄,请尝试从循环中删除打印:

                while (!stopRequested) {
                    i++;
                }

执行于

openjdk version "14" 2020-03-17
OpenJDK Runtime Environment (build 14+36-1461)
OpenJDK 64-Bit Server VM (build 14+36-1461, mixed mode, sharing)

此代码不会终止。显着的区别是循环体变得不那么复杂,导致 JIT 应用额外的优化:-)

如您所见,不正确同步的程序的行为是不可预测的,并且可以在最轻微的刺激下发生变化。如果您想编写健壮的多线程代码,那么您应该根据规范证明您的代码是正确的,而不是依赖于测试。