当核心缓存同步是在硬件层面上完成的,为什么我们需要 volatile 关键字?

pet*_*ulb 2 java concurrency

所以我目前正在参加这个演讲。在 28:50 分钟,做出以下声明:“在硬件上,它可能位于主内存中、多个三级缓存中、四个二级缓存中 [...] 这不是你的问题。这就是硬件设计人员的问题。”

然而,在 Java 中,我们必须将停止线程的布尔值声明为 volatile,因为当另一个线程调用 stop 方法时,不能保证正在运行的线程会知道此更改。

为什么会出现这种情况,硬件级别应该负责用正确的值更新每个缓存?

我确定我在这里遗漏了一些东西。

有问题的代码:

public class App {
    public static void main(String[] args) {
        Worker worker = new Worker();
        worker.start();
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        worker.signalStop();
        System.out.println(worker.isShouldStop());
        System.out.println(worker.getVal());
        System.out.println(worker.getVal());
    }

    static class Worker extends Thread {
        private /*volatile*/ boolean shouldStop = false;
        private long val = 0;

        @Override
        public void run() {
            while (!shouldStop) {
                val++;
            }
            System.out.println("Stopped");
        }

        public void signalStop() {
            this.shouldStop = true;
        }

        public long getVal() {
            return val;
        }

        public boolean isShouldStop() {
            return shouldStop;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

pve*_*jer 5

您假设以下情况:

  • 编译器不会对指令重新排序
  • CPU 按照程序指定的顺序执行加载和存储

那么你的推理是有道理的,这个一致性模型被称为顺序一致性(SC):加载/存储有一个总顺序,并且与每个线程的程序顺序一致。简单来说:只是加载/存储的一些交错。SC 的要求稍微严格一些,但这抓住了本质。

如果 Java 和CPU是 SC,那么将没有任何目的使某些东西变得易变。

问题是你会得到糟糕的表现。许多编译器优化依赖于将指令重写为更有效的东西,这可能导致加载和存储的重新排序。它甚至可以决定优化加载或存储,以便它不会发生。只要只涉及一个线程,这一切都很好,因为该线程将无法观察这些加载/存储的重新排序。

除了编译器,CPU 还喜欢重新排序加载/存储。想象一下,CPU 需要进行写入,而缓存行因为那个写不是在正确的状态。因此 CPU 会阻塞,这将非常低效。由于无论如何都要进行存储,最好将存储在缓冲区中排队,以便 CPU 可以继续,一旦缓存行以正确的状态返回,存储就会写入缓存行然后提交到缓存。存储缓冲是许多处理器(例如 ARM/X86)使用的技术。它的一个问题是,它可能导致较早的存储到某个地址的位置被重新排序,而较新的加载到不同的地址。因此,您不会像 SC 那样对所有负载和商店进行总订单,而是只能获得所有商店的总订单。此模型称为 TSO(总商店订单),您可以在x86SPARC v8/v9上找到它. 这种方法假设存储缓冲区中的存储将按程序顺序写入缓存;但也有一种可能的放松方式,即可以以任何顺序将存储缓冲区中的存储到不同的缓存行提交给缓存;这称为 PSO(部分商店订单),您可以在 SPARC v8/v9 上找到它。

SC/TSO/PSO 是强内存模型,因为每次加载和存储都是一个同步操作;所以他们订购周围的负载/商店。这可能非常昂贵,因为对于大多数指令,只要保留数据依赖顺序,任何排序都可以,因为:

  • 大多数内存不在不同的 CPU 之间共享。
  • 如果内存是共享的,通常会有一些外部同步,比如互斥锁的解锁/锁定或负责同步的释放存储/获取加载。所以同步可能会延迟。

具有弱内存模型的 CPU,如 ARM、Itanium 就利用了这一点。它们在普通加载和存储之间进行分离,并同步加载/存储。对于普通负载和存储,任何订购都可以。现代处理器以任何方式乱序执行指令;单个 CPU 内部有很多并行性。

现代处理器确实实现了缓存一致性。唯一不需要实现缓存一致性的现代处理器是 GPU。缓存一致性可以通过两种方式实现

  • 对于小型系统,缓存可以嗅探总线流量。这是您看到 MESI 协议的地方。这种技术被称为嗅探(或窥探)。
  • 对于较大的系统,您可以拥有一个目录,该目录知道每个缓存行的状态以及哪些 CPU 共享缓存行以及哪个 CPU 拥有缓存行(这里有一些类似 MESI 的协议)。并且所有对缓存行的请求都通过该目录。

缓存一致性协议确保在不同的 CPU 可以写入缓存行之前,CPU 上的缓存行无效。缓存一致性将为您提供单个地址上加载/存储的总顺序,但不会提供不同地址之间加载/存储的任何顺序。

回到 volatile:

所以 volatile 的作用是:

  • 防止编译器和 CPU 重新排序加载和存储。
  • 确保加载/存储变得可见;所以编译器会优化加载或存储。
  • 加载/存储是原子的;所以你不会遇到读/写撕裂之类的问题。这包括编译器行为,如字段的自然对齐。

我已经给你一些关于幕后发生的事情的技术信息。但是要正确理解 volatile,您需要了解Java 内存模型。它是一个抽象模型,不关心上述任何实现细节。如果您在示例中不应用 volatile,则会发生数据竞争,因为在并发冲突访问之间缺少发生之前的边缘。

关于这个主题的本好书是 A Primer on Memory Consistency and Cache Coherence,第二版。您可以免费下载。

我不能向您推荐任何关于 Java 内存模型的书,因为它的解释方式很糟糕。在深入研究 JMM 之前,最好先大致了解内存模型。最好的来源可能是Jeremy MansonAleksey Shipilëv: One Stop Page 的这篇博士论文

PS:

在某些情况下,您不关心任何订购保证,例如

  • 线程的停止标志
  • 进度指标
  • 用于微基准测试的黑洞。

这是 VarHandle.getOpaque/setOpaque 有用的地方。它提供可见性和原子性,但不提供任何与其他变量相关的排序保证。这主要是编译器的问题。大多数工程师永远不需要这种级别的控制。