java—当核心缓存同步在硬件级别上完成时,为什么我们需要volatile关键字?

i2loujxw  于 2021-08-25  发布在  Java
关注(0)|答案(2)|浏览(319)

所以我现在列出了这次演讲的内容。在第28:50分钟,做出以下声明:“硬件上,它可能位于主存、多个3级缓存、四个2级缓存[…]中,这不是您的问题。这是硬件设计师面临的问题。”
然而,在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;
        }
    }
}
ekqde3dh

ekqde3dh1#

您假设:
编译器不会对指令重新排序
cpu按照程序指定的顺序执行加载和存储
然后,您的推理是有意义的,这个一致性模型称为顺序一致性(sc):在加载/存储上有一个总的顺序,并且与每个线程的程序顺序一致。简单来说:只是一些加载/存储的交错。sc的要求更为严格,但这抓住了本质。
如果java和cpu是sc,那么就没有任何目的使某些东西变得易变。
问题是你会得到糟糕的表现。许多编译器优化都依赖于将指令重写为更有效的指令,这可能导致加载和存储的重新排序。它甚至可以决定优化负载或存储,这样就不会发生这种情况。只要只涉及一个线程,这一切都很好,因为该线程将无法观察这些加载/存储的重新排序。
除了编译器,cpu还喜欢对加载/存储进行重新排序。假设cpu需要进行写操作,而该写操作的缓存线未处于正确的状态。因此cpu会阻塞,这将是非常低效的。由于无论如何都要创建存储,因此最好将存储排队到缓冲区中,这样cpu就可以继续,一旦缓存线返回到正确的状态,存储就会写入缓存线,然后提交到缓存。存储缓冲是许多处理器(如arm/x86)使用的一种技术。它的一个问题是,它可能导致某个地址的较早存储被重新排序,而较新的加载被重新排序到另一个地址。因此,与sc等所有装载和存储的总订单不同,您只能获得所有存储的总订单。此型号称为tso(total store order),您可以在x86和sparc v8/v9上找到它。这种方法假设存储缓冲区中的存储将按程序顺序写入缓存;但也有一种可能的放松,使得存储缓冲区中存储到不同缓存线的存储可以以任何顺序提交到缓存;这称为pso(部分存储订单),您可以在sparc v8/v9上找到它。
sc/tso/pso是强记忆模型,因为每个加载和存储都是一个同步动作;因此,他们订购周围的货物/仓库。这可能非常昂贵,因为对于大多数指令,只要保留数据依赖顺序,任何顺序都是可以的,因为:
大多数内存不在不同的CPU之间共享。
如果内存是共享的,通常会有一些外部同步,比如互斥锁的解锁/锁定,或者释放存储/获取负载来处理同步。因此,同步可以延迟。
arm、安腾等内存较弱的cpu利用了这一点。它们在普通加载和存储以及同步加载/存储之间进行分离。对于普通装载和存储,任何订购都可以。现代处理器以任何方式无序执行指令;在单个cpu中有很多并行性。
现代处理器确实实现了缓存一致性。唯一不需要实现缓存一致性的现代处理器是gpu。缓存一致性可以通过两种方式实现
对于小型系统,缓存可以嗅探总线流量。这就是你们看到mesi协议的地方。这种技术称为嗅探(或窥探)。
对于较大的系统,您可以有一个目录,该目录知道每个缓存线的状态,哪些cpu共享缓存线,哪些cpu拥有缓存线(这里有一些类似mesi的协议)。所有对缓存线的请求都会通过该目录。
缓存一致性协议确保cpu上的缓存线失效,然后其他cpu才能写入缓存线。缓存一致性将为您提供单个地址上加载/存储的总顺序,但不会提供不同地址之间加载/存储的任何顺序。
回到volatile:
所以volatile的作用是:
防止编译器和cpu对加载和存储进行重新排序。
确保装载/存储变得可见;因此,它将阻止编译器优化加载或存储。
加载/存储是原子的;因此,您不会遇到读/写中断之类的问题。这包括编译器行为,如字段的自然对齐。
我已经给你们提供了一些关于幕后发生的事情的技术信息。但要正确理解volatile,您需要理解java内存模型。它是一个抽象模型,不关心上述任何实现细节。如果您不在示例中应用volatile,那么您将面临数据竞争,因为在并发冲突访问之间缺少边缘之前会发生冲突。
关于这个主题的一本好书是关于内存一致性和缓存一致性的入门,第二版。你可以免费下载。
我不能向您推荐任何关于java内存模型的书,因为它的解释方式都很糟糕。在深入jmm之前,最好先了解一下内存模型。也许最好的资料来源是杰里米·曼森的博士论文和亚历克斯·希皮耶夫的一站式页面。
附言:
有些情况下,您不关心任何订购保证,例如。
线程的停止标志
进展指标
微基准的黑洞。
这就是varhandle.getopaque/setopaque的有用之处。它提供了可视性和原子性,但不提供关于其他变量的任何排序保证。这主要是编译器关心的问题。大多数工程师永远不需要这种级别的控制。

093gszye

093gszye2#

你的意思是,硬件设计师只是为你制造世界上所有的小马和彩虹。
他们无法做到这一点——您想要的是让核心缓存的概念完全不可能实现。一个cpu核心怎么可能知道一个给定的内存位置在进一步访问它之前需要与另一个核心同步,而不仅仅是保持整个缓存的永久同步,从而使核心缓存的整个概念完全失效?
如果这篇演讲强烈暗示,作为一名软件工程师,你可以责怪硬件工程师没有让你的生活变得轻松,那么这是一篇可怕而愚蠢的演讲。我打赌它带来了比这更细微的变化。
无论如何,你从中吸取了错误的教训。
这是一条双向的街道。硬件工程团队与jvm团队有效地合作,建立一个一致的模型,该模型在“有这些约束”和对软件工程师的有限保证之间保持良好的平衡,硬件团队可以进行可靠和显著的性能改进”,并且“软件工程师可以用这种模型构建多核软件,而不会把他们的头发扯掉”。
java中的这种令人愉快的平衡是jmm(java内存模型),它主要归结为:所有字段访问都可能有本地线程缓存,您不知道,并且无法测试它是否有。从本质上讲,jvm有一个邪恶的硬币,每次你读一个字段时,它都会把它抛出去。尾部,你得到本地副本。头,它先同步。这枚硬币是邪恶的,因为它不公平,每次都会在开发、测试和第一周到达,即使你掷了一百万次。然后,重要的潜在客户演示了您的软件,您开始了解详细信息。
解决方案是使jvm永远不会翻转它,这意味着您需要在代码中任何地方出现一个线程写入字段而另一个线程读取字段的情况时,建立“发生之前/发生之后”关系。 volatile 这是一种方法。
换句话说,为了给硬件工程师一些可以合作的东西,你,软件工程师,有效地做出了承诺,如果你关心线程之间的同步,你将建立hb/ha。这就是你在“交易”中的角色。他们的部分交易是,硬件保证的行为,如果你坚持你的交易结束,硬件是非常非常快的。

相关问题