机组学习笔记之存储器——volatile一词解读CPU“缓存模型”

    技术2024-12-19  25

    这里先给出volatile的两大作用:

    1.实现线程之间的可见性2.防止编译时期的指令重排

    这里我们会终点讲解volatile的第一个作用“可见性”,对于对于第二个作用大家可以参考这个连接

    JMM 与计算机缓存模型

    因为java的内存模型JMM与计算机中的缓存模型是极其相似的,这里我们根据一段代码直观的感受一下“可见性”;

    public class VolatileTest { private static volatile int COUNTER = 0; // private static /*volatile*/ int COUNTER = 0; public static void main(String[] args) { new ChangeListener().start(); new ChangeMaker().start(); } /** * 监听COUNTER变量,只要改变就打印出改变的值 */ static class ChangeListener extends Thread { @Override public void run() { int threadValue = COUNTER; while (threadValue < 5) { if (threadValue != COUNTER) { System.out.println("Get change for COUNTER: " + COUNTER); threadValue = COUNTER; } } } } static class ChangeMaker extends Thread { @Override public void run() { int threadValue = COUNTER; while (threadValue < 5) { System.out.println("Increment COUNTER to " + (threadValue + 1)); COUNTER = ++threadValue; try { Thread.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } } } } }

    简单解读下两个类,ChangeMaker 每隔5毫秒更新下COUNTER的值,ChangeListener 用来监控COUNTER的值,只要有更新就打印出来;第一次我们将COUNTER用volatile关键字,打印结果:

    Increment COUNTER to 1 Get change for COUNTER: 1 Increment COUNTER to 2 Get change for COUNTER: 2 Increment COUNTER to 3 Get change for COUNTER: 3 Increment COUNTER to 4 Get change for COUNTER: 4 Increment COUNTER to 5 Get change for COUNTER: 5

    我们发现ChangeMaker 每更新一次,ChangeListener就会监视到并进行打印 第二次我们将关键字volatile注释掉,结果如下:

    Increment COUNTER to 1 Get change for COUNTER: 1 Increment COUNTER to 2 Increment COUNTER to 3 Increment COUNTER to 4 Increment COUNTER to 5

    我们发现ChangeListener不是总是监视到COUNTER的变化,并且程序会一直卡在ChangeListener中 上面代码演示的通俗解释来说就是,我们在ChangeMaker线程中修改了COUNTER得值,volatile能够保证及时刷新会主内存中,同时在ChangeListener也会从主内存中读取。既保证变量在不同线程之间的可见性;

    java中volatile的可见性取决于JVM的支持,可见性的保证是基于CPU的内存屏障指令,被JSR-133抽象为happens-before原则。 计算机内部的缓存写会策略包括写直达和写会

    写直达与写会

    对于程序中操作的数据,我们不仅要读取,并且还要进行写入操作。不同线程之间很多时候操作的是Cache保留的内存副本。那么我们在执行写入操作时是写入到Cache中还是内存中呢,上图展示了两种写入数据的策略:

    1、写直达;写入数据无论是否命中Cache,最终都会同步到主内存中去,这种和volatile的方式比较相像。2、写回;我们在Cache Line中加入了标记位 “脏”,“脏”表示当前Cache中的数据和主内存中不一致。在进行数据写入时,如果缓存命中,会直接写入到Cache line中同时标记为“脏”。如果缓存不命中,会通过直接映射的方式查找数据在内存中的地址对应的Cache line,如果对应的Cache line为“脏”,会将数据写回到主内存中,否则会将内存中地址对应的数据加载到Cache line,然后将要写入的数据 写入到Cache line同时标记为“脏”;

    缓存一致性

    上面我们讲到,计算机为了考虑性能,当我们更新数据时,会优先访问Cache,如果没有命中的情况下会访问内存。上一篇博文性能与优化时我们讲到提升性能可以从“提升主频”和“增加并行计算提升吞吐率”方面来入手,在提升主频非常首先的情况下,当前我们计算机从多核的角度出发提升性能。此时如果更新数据还是优先Cache的策略时就会出现缓存不一致的现象,下面举一个实际的案例来理解; 这里我们使用一个双核的CPU来作演示,主内中存储着IPhone手机的价格8000RMB,过了一段时间后IPhone手机降价到6000RMB,此时我们将更新后的价格通过CPU核心1写入,这里我们采用“写回”策略,缓存命中存储到Cache line中同时标记位“脏”,等到下次数据交换时才会将写入到主内存中。对于单核的CPU来说,这种方式是没有问题的。但是多核的情况下,就会出现问题,如果此时CPU核2访问IPhone的价格,就会读取到之间缓存的价格8000RMB。

    缓存一致性需要解决的问题

    上面我们我们说到多核CPU存在的缓存不一致的问题,解决这个问题必须要做到以下两点才是合理的:

    1、写广播,我们在一个CPU核中的数据更新Cache操作,必须传播到其他CPU核的Cache line中。2、事务的串行化,是说我们在一个CPU核中的更新数据操作,在其他CPU核中顺序必须是相同的。

    对于第一点“写广播”我们比较容易理解,如何理解事务的串行化呢?这里还是使用IPhone手机降价的案例,不过这次我们用四核的CPU进行数据的更新; CPU核心1更新IPhone手机价格为6000RMB,几乎同一时间CPU核心2更新IPhone手机价格为5000RMB。这两个数据更新操作传播到CPU核心3和CPU核心4,但是两者的更新顺序不一致,核心3是先更新为5000RMB后更新为6000RMB,核心4是先更新为6000RMB后更新为5000RMB。最后的结果是CPU核心3中的价格是6000RMB,CPU核心4中的价格是5000RMB。 这里为了保证事务的串行化我们使用的是“锁”的概念,只有拥有对应Cache line锁的CPU核才可以进行数据更新操作;

    MESI协议实现缓存一致性

    MESI是一种基于写失效的协议,同一时间只有一个CPU核来更新数据,其他CPU核来同步数据。写入数据的CPU和会广播一个“失效”请求,其他CPU核收到后会查看本地是否有相应的Cache line,如果有则标记位“失效”; 对于的也有写广播的协议,与“写失效”协议相比,不仅接受广播请求,同时还会更新对应的Cache line数据;这样会占用更多的总线带宽。

    MESI 协议的由来呢,来自于我们对 Cache Line 的四个不同的标记,分别是:

    M:代表已修改(Modified)E:代表独占(Exclusive)S:代表共享(Shared)I:代表已失效(Invalidated)

    这里我们给出四种状态的有限状态机: 参考连接: MESI协议:如何让多核CPU的高速缓存保持一致? 维基百科

    博文中出现的代码,详情点击github地址

    创作不易,喜欢的话记得关注和收藏哦!持续更新…

    Processed: 0.010, SQL: 9