Java易失性和缓存一致性.我是不是错过了什么



在许多文章、YouTube视频等中,我看到Java的volatile关键字被解释为缓存内存问题,其中声明变量volatile可以确保读/写被强制到主内存,而不是缓存内存。

我一直认为,在现代CPU中,高速缓存实现了一致性协议,确保所有处理器、内核、硬件线程等在高速缓存体系结构的各个级别都能平等地看到读/写。我错了吗?

jls-8.3.1.4简单说明

字段可以声明为volatile,在这种情况下,Java内存模型确保所有线程都能看到变量的一致值(§17.4)

它没有提到缓存。只要缓存一致性有效,volatile变量只需写入内存地址,而不是本地存储在CPU寄存器中。可能还需要避免其他优化,以保证volatile变量的契约和线程可见性。

我只是惊讶于有这么多人暗示CPU没有实现缓存一致性,以至于我不得不使用StackOverflow,因为我怀疑自己的理智。这些人付出了巨大的努力,用图表、动画图表等暗示缓存内存是不连贯的。

jls-8.3.1.4确实是需要说的全部,但如果人们要更深入地解释事情,那么谈论CPU寄存器(和其他优化)不是比指责CPU缓存更有意义吗?

CPU速度非常快。那个记忆在物理上只有几厘米远。比方说15厘米。

不管怎样,光速是每秒30万公里。这是每秒300000000000厘米。光在介质中的速度没有在真空中那么快,但它很接近,所以我们忽略这一部分。这意味着从CPU向存储器发送单个信号,即使CPU和存储器都可以立即处理所有信号,已经将你限制在1000000000或1Ghz(你需要覆盖30厘米才能从核心到内存再返回,所以你每秒可以做到1000000000。如果你能做得更快,那你就是在时间上倒退。或者诸如此类。如果你想办法做到这一点,你就会获得诺贝尔奖)。

处理器的速度差不多!这些天,我们以Ghz测量核心速度,就像在中一样,在信号传播所需的时间内,CPU的时钟已经滴答作响。当然,在实践中,内存控制器也不是即时的,CPU流水线系统也不是。

因此:

我一直认为,在现代CPU中,高速缓存实现了一致性协议,确保所有处理器、内核、硬件线程等在高速缓存体系结构的所有级别上都能平等地看到读/写。我错了吗?

是的,你错了。QED。

我不知道你为什么这么想,也不知道你在哪里读到的。你记错了,或者你误解了所写的,或者所写的都是非常非常错误的。

事实上,对"主内存"的实际更新大约需要1000个周期!一个CPU只是坐在那里,无所事事,在一个时间窗口里,它可以滚动通过一千条,在一些内核上,数千条指令,内存太慢了。糖蜜液位缓慢。

修复不是寄存器,你错过了大约20年的CPU改进。没有2层(寄存器,然后是主存储器),没有。有5层:寄存器,在多个层次级别中的片上缓存,最后是主存储器。为了让这一切变得非常非常快,这些东西非常非常接近核心。事实上,如此接近,以至于每个内核都有自己的内核,而且,这里的鼓点-现代CPU不能读取主内存。完全他们完全没有能力。

相反,CPU会看到你对主内存进行写入或读取,并通过计算出要读取/写入的内存的"页面"(例如,64k内存的每个块都是一个页面;实际页面大小取决于硬件)来转换,因为它实际上无法做到这一切。然后,CPU检查加载在其片上缓存中的任何页面是否为该页面。如果是的话,那就太好了,一切都映射到了这一点。这意味着,如果两个核心都加载了该页面,那么它们都有自己的副本,显然一个核心对其副本所做的任何事情对另一个核心来说都是完全不可见的。

如果CPU在自己的片上缓存中找不到这个页面,就会出现所谓的缓存未命中,然后CPU会检查哪个加载的页面使用最少,并清除这个页面。如果CPU没有修改它,清除是"自由的",但如果该页是"脏的",它将首先向内存控制器发送一个ping,然后将64k字节的整个序列爆破到其中(因为发送突发比等待信号来回反弹或试图找出64k块的哪一部分脏要快得多),内存控制器会处理它。然后,同一个CPU ping控制器,将正确的页面发送到它,并覆盖刚刚清除的空间。现在CPU"重试"指令,这一次它确实起作用了,因为该页现在在"内存"中,从某种意义上说,将内存位置转换为cachepage+偏移量的CPU部分现在不再抛出CacheMiss。

当所有这些都在进行的时候,成千上万的周期可以过去,因为这一切都非常非常缓慢。缓存未命中很糟糕。

这解释了很多事情:

  • 它解释了为什么volatile较慢,而synchronized较慢。狗慢慢来。一般来说,如果你想要大的速度,你想要独立运行[A]的进程(不需要在内核之间共享内存,除了在最开始和最结束的时候,也许是为了加载操作所需的数据,并发送复杂操作的结果),并且[B]适合在64k左右执行计算所需的所有内存,这取决于CPU高速缓存的大小以及它有多少页L1高速缓存。

  • 它解释了为什么一个线程可以观察到一个值为a的字段,而另一个线程在DAYS中观察到同一个值不同的字段,如果你运气不好的话。如果核心没有做那么多,而检查这些字段值的线程经常这样做,那么该页面永远不会被清除,两个核心会在几天内愉快地使用它们的本地核心值。CPU不会为了好玩而同步页面。只有当该页面是"失败者"并被清除时,它才会执行此操作。

  • 这就解释了《幽灵党》发生的原因。

  • 它解释了为什么LinkedList比ArrayList慢,即使在基础信息学认为它应该更快的情况下(big-O表示法,分析计算复杂性)。因为只要arraylist的东西被限制在一个页面上,你就可以或多或少地认为这一切都是即时的——在整个片上缓存页面上飞行所需的数量级与同一CPU等待单个缓存未命中所需的量级大致相同。LinkedList在这方面很糟糕:它上面的每个.add都会创建一个跟踪器对象(LinkedList必须在某个地方存储"next"one_answers"prev"指针!)所以对于链表中的每个项,你必须读取2个对象(跟踪器和实际对象),而不仅仅是一个(因为arraylist的数组在连续内存中,所以在最坏的情况下,该页面在die上读取一次,并在整个循环中保持活动),而且很容易导致跟踪器对象和实际对象位于不同的页面上。

  • 它解释了Java内存模型规则:任何一行代码都可以或不可以观察任何其他代码行对任何字段值的影响。除非您已经使用JMM中列出的许多规则中的任何一个来建立一个发生前/发生后关系。这是为了让JVM可以自由地运行,不会比必要的慢1000倍,因为只有在每次读取时刷新内存才能保证一致的读/写,而这比不这样做慢1000倍。

注意:我把事情过于简单化了。我没有能力在一个简单的SO回答中完全解释20年来CPU的改进。然而,它应该解释一些事情,当你试图分析当多个java线程试图对同一字段进行写/读时会发生什么,并且你没有特意确保相关行之间存在HB/HA关系时,请记住这是一件了不起的事情。如果你现在害怕,那就好。您不应该经常尝试在两个线程之间进行通信,甚至不应该通过字段进行通信,除非您真的非常清楚自己在做什么。通过消息总线将其抛出,使用数据流绑定到整个线程进程的开始和结束的设计(创建一个作业,用正确的数据初始化作业,将其放入ExecutorPool队列,设置完成后会收到通知,读取结果,永远不要与运行它的实际线程共享任何内容),或者通过数据库相互交谈。

最新更新