从CPU缓存一致性到Volatile

CPU缓存一致性

​首先聊一聊什么是CPU缓存一致性,CPU Cache通常分为三级缓存:L1 Cache、L2 Cache 、L3 Cache,那么在CPU的多级缓存结构中,每个CPU的逻辑核心都有自己的L1 Cache,共享L2 Cache和L3 Cache;每个CPU的物理核心都有自己的L2 Cache,共享L3 Cache。所有CPU核心共享L3 Cache。所有CPU共享主内存。

如图所示,这些缓存的数据都是从内存中读取,并且每次都会加载一次Cache Line。我们可以从下图看出Cache Line是油各种标志(Tag)和+数据库(block)组成的。

CPU Cache与内存一致性问题

写直达(Write Through)

在这个策略里,每次数据都需要写入到主内存里面。举个例子,有个变量s,cpu操作变量s时,先检查 Cache Line里面有没有变量s,如果有则先更新Cache Line中的值,然后再写入到主内存;如果没有则直接写入到主内存。

写回策略(Write Back)

相对于写直达不管有没有在Cache Line中都要更新到内存而影响性能,在写回策略中,当CPU要操作变量s时,如果发现变量s就在Cache Line里面,那么只需要更新Cache Line里面的数据,并将这个Block标记为脏(Dirty)的,意味着这个Block跟内存是不一致的,这种情况是不会写入到内存里的;

那么如果写入的数据所对应的 Cache Block 里,放的是别的内存地址的数据,那么我们要看看那个Cache Block有没有被标记为脏,如果为脏则需要先将Cache Block里面的数据写入到内存中,然后再将当前数据写入Cache Block并标记为脏;如果没有那个Cache Block没有被标记为脏,则直接写入要Cache Line里,然后把Cache Block标记为脏即可。

何为缓存一致性问题

​举个例子,当一台计算机是由多核CPU构成时,为了性能根据上面所说的写回策略,CPU-01从内存中加载了变量X,并将变量X改为9527(初始值为0)写入L1/L2 Cache中,并没有写入到主内存中,这时CPU-02从内存中读取变量X的值,则会读到错误的值,因为CPU-01没有更新变量X到内存中,此时内存中的值仍旧为0,这就是所谓的缓存一致性问题。那么要解决这个问题我们需要做到以下两点:

  1. 写传播:当前Cache中修改的值需要通知其他CPU
  2. 事务串行化:某个CPU核心里对数据的操作顺序,在其他核心看起来是一样的,例:CPU-01先将变量X改为9527再+1,CPU-02根据当前变量X的值再进行+2,那么必须要保证变量X先经历了加9527再加1再加2。

总线嗅探

总线嗅探就是实现写传播最常用的方式,就拿上面的变量X解释一下总线嗅探的工作机制,当CPU-01中L1 Cache修改了变量X中的值,它就会通过总线广播通知其他核心,当其他核心例如CPU-02发现自己的L1 Cache中也有这个值时就会将CPU-01中L1 Cache的值更新自己L1 Cache中。但是这种方式缺点很明显,需要其他核心无时无刻的监听总线上的一切活动,而且还不一定跟自己有关,这不是给总线增加了压力嘛。且不能保证事务串行化。

MESI协议

MESI是总线嗅探的改良版,他能很好的解决了总线的带宽压力,以及很好的解决了数据一致性问题。 那么在介绍MESI之前,我们必须了解以下MESI是什么。

  1. M:Modified(已修改):处于M状态的缓存行有效,表示数据已经被修改,且修改后的数据只在当前CPU缓存中存在,而这个数据在其他CPU缓存中不存在。
  2. E:Exclusive(独占):处于E状态的缓存行有效,表示当前CPU将数据加载到自己的缓存中并且未修改,而其他CPU缓存中并没有这个数据。
  3. S:Shared(共享):处于S状态的缓存行有效,说明数据存在于CPU的多个高速缓存中,且每个缓存中的数据与主内存中的数据一致。
  4. I:Invalid(已失效):处于I状态的缓存行无效,如果缓存行处于S状态,当其中一个CPU修改了缓存行的数据,则会通知其他CPU这个数据被修改了,其他CPU中对应的该缓存行的状态将被标为I。

那么介绍完这几个状态,我们不妨用一个例子来加深印象:

  1. 首先CPUA要加载变量i,发现变量i不在cache中,于是去内存中加载数据,此时通过总线发消息通知其他CPU核心,其他CPU核心中没有缓存该数据,于是当前CPU的缓存行状态标记为E。
  2. 当CPUB也加载了这个数据时,会发送消息通知其他CPU核心,会发现CPUA也有这个数据并且状态为E,那么CPUA中的缓存行的状态会由E变为S,CPUB中的缓存行的状态为S。
  3. 此时CPUA要修改了变量i的值,发消息通知其他CPU核心,其他CPU核心收到消息后将缓存行的状态变为I,CPUA修改完数据后将缓存行的状态设置为M。
  4. 如果CPUA继续修改变量i的值,当它看到缓存行的状态为M,说明它当前缓存行中的值是最新的数据,那么它就不会发送消息通知其他CPU核心,直接更新即可。
  5. 当CPUB要读取主内存中的数据时,发出读取数据的指令;CPUA感知到CPUB要读取数据就会将修改后的变量i同步到主内存中,并将缓存行的状态修改为E,以便CPUB可以读取到最新的数据。当CPUB读取到数据后,将缓存行的状态由I修改为S,CPUA将缓存行的状态由E修改为S。

下图即是 MESI 协议的状态图:

volatile关键字

基于JMM了解volatile

注意,这里JMM 是语言级的内存模型,并非我们平时所认为的内存模型,它的出现是向程序员提供抽象模型,简化和屏蔽硬件底层的细节。我们都知道volatile可以保证变量的可见性,那么从JMM模型上来看,它保证可见性方式很简单。 如下图所示

  1. 由于是volatile变量,JMM会把线程1本地缓存中修改后的共享变量立即刷新到主内存
  2. 线程2读取,同样因为volatile修饰的原因,JMM 会把该线程对应的本地内存置为无效,直接从主存中获取,从而保证缓存一致性。

volatile保证可见性代码示例

下面这段代码,读者可以删除变量num的volatile关键字进行验证,有无volatile的区别。

java 复制代码
public class VolatileDemo {

    private volatile static int a= 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (a== 0) {
            }
            System.out.println("t2得知a已被修改为:1" );
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(2000);
                a=1;
          		System.out.println("t2修改a为1" );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t1.start();
        t2.start();
    }
}

这边笔者先揭秘下结论,我们先聊聊加上volatile的情况,假设这两个线程在不同CPU上执行。

  1. 线程t1获取到共享变量a的值,此时并没有其他核心CPU获取到,状态为E。
  2. 后续线程t2启动获取到a的值,并且总线嗅探到另一个CPU也持有该共享变量,所以两个CPU缓存行都设置为S。
  3. 线程t2修改变量a的值,通过总线嗅探机制发起通知,线程t1的线程收到消息后,将缓存行变量设置为I。
  4. 线程t1下次获取变量会从主内存中获取,因此结束死循环。

不加volatile,线程t1无法感知到变量值的改变,会一直读取CPU缓存行中的值,导致死循环。

volatile禁止指令重排序

volatile能够禁止指令重排序,从而能够避免在高并发环境中多个线程之间出现乱序执行的情况。volatile禁止指令重排序是通过内存屏障实现的。我们不妨看一段双重锁校验的单例模式代码,代码如下:

java 复制代码
public class SingletonLanHan {

    private SingletonLanHan() {
    }
    /**
     * 单例模式懒汉式双重校验锁[推荐用]
     * 懒汉式变种,属于懒汉式的最好写法,保证了:延迟加载和线程安全
     */
    private static volatile SingletonLanHan SINGLETONLANHAN =null;

    public static SingletonLanHan getSingLetonLanHan() {
    	//先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (SINGLETONLANHAN == null) {
       	   //类对象加锁
            synchronized (SingletonLanHan.class) {
                if (SINGLETONLANHAN == null) {
                    SINGLETONLANHAN = new SingletonLanHan();
                }
            }
        }
        return SINGLETONLANHAN;
    }
}

这一操作看似保证原子性,实际编译后再执行的机器码会将其分为3个动作:

  1. 为SINGLETONLANHAN 引用分配内存空间
  2. 初始化SINGLETONLANHAN
  3. SINGLETONLANHAN 指向分配的内存空间

上诉代码正常会按照1、2、3的顺序执行,可是如果没有volatile禁止指令重排序的话,可能会出现1、3、2的顺序,这样会导致其中一个线程创建了一个为初始化且不为空的对象,当另一个线程操作判断不为空后返回出去,就会产生异常。

volatile的局限性

volatile,虽然能够保证数据的可见性和有序性,但是无法保证数据的原子性,我们来看下经典案例

java 复制代码
public class VolatileAdd {

    private static int num = 0;

    private void increase() {
        num++;
    }
    

    public static void main(String[] args) {
        int size = 1000;
        CountDownLatch countDownLatch = new CountDownLatch(1);
        ExecutorService executorService = Executors.newFixedThreadPool(size);
        VolatileAdd volatileAdd = new VolatileAdd();
        for (int i = 0; i < size; i++) {
            executorService.submit(() -> {
                try {
                    countDownLatch.await();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                volatileAdd.increase();
            });
        }
        countDownLatch.countDown();
        executorService.shutdown();
        while (!executorService.isTerminated()) {

        }
        System.out.println(VolatileAdd.num);
    }
}

在运行上诉代码时,在绝大部分情况下输出的count结果小于1000,说明volatile不能保证数据的原子性。

保证原子性的解决方案

1、lock

java 复制代码
Lock lock = new ReentrantLock();
private void lockIncrease() {
        lock.lock();
        try {
            num++;
        } finally {
            lock.unlock();
        }
    }

2、synchronize

java 复制代码
    private synchronize void increase() {
        num++;
    }

3、原子类

java 复制代码
 private static AtomicInteger atomicInteger = new AtomicInteger();
 
 private void atomicIncrease() {
        atomicInteger.getAndIncrement();
 }

参考文献

CPU 缓存一致性

深入理解高并发编程

吃透Java并发:volatile是怎么保证可见性的

volatile 三部曲之可见性

相关推荐
qq_441996051 小时前
Mybatis官方生成器使用示例
java·mybatis
巨大八爪鱼1 小时前
XP系统下用mod_jk 1.2.40整合apache2.2.16和tomcat 6.0.29,让apache可以同时访问php和jsp页面
java·tomcat·apache·mod_jk
码上一元3 小时前
SpringBoot自动装配原理解析
java·spring boot·后端
计算机-秋大田3 小时前
基于微信小程序的养老院管理系统的设计与实现,LW+源码+讲解
java·spring boot·微信小程序·小程序·vue
魔道不误砍柴功5 小时前
简单叙述 Spring Boot 启动过程
java·数据库·spring boot
失落的香蕉5 小时前
C语言串讲-2之指针和结构体
java·c语言·开发语言
枫叶_v5 小时前
【SpringBoot】22 Txt、Csv文件的读取和写入
java·spring boot·后端
wclass-zhengge5 小时前
SpringCloud篇(配置中心 - Nacos)
java·spring·spring cloud
路在脚下@5 小时前
Springboot 的Servlet Web 应用、响应式 Web 应用(Reactive)以及非 Web 应用(None)的特点和适用场景
java·spring boot·servlet
黑马师兄5 小时前
SpringBoot
java·spring