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,这就是所谓的缓存一致性问题。那么要解决这个问题我们需要做到以下两点:
- 写传播:当前Cache中修改的值需要通知其他CPU
- 事务串行化:某个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是什么。
- M:Modified(已修改):处于M状态的缓存行有效,表示数据已经被修改,且修改后的数据只在当前CPU缓存中存在,而这个数据在其他CPU缓存中不存在。
- E:Exclusive(独占):处于E状态的缓存行有效,表示当前CPU将数据加载到自己的缓存中并且未修改,而其他CPU缓存中并没有这个数据。
- S:Shared(共享):处于S状态的缓存行有效,说明数据存在于CPU的多个高速缓存中,且每个缓存中的数据与主内存中的数据一致。
- I:Invalid(已失效):处于I状态的缓存行无效,如果缓存行处于S状态,当其中一个CPU修改了缓存行的数据,则会通知其他CPU这个数据被修改了,其他CPU中对应的该缓存行的状态将被标为I。
那么介绍完这几个状态,我们不妨用一个例子来加深印象:
- 首先CPUA要加载变量i,发现变量i不在cache中,于是去内存中加载数据,此时通过总线发消息通知其他CPU核心,其他CPU核心中没有缓存该数据,于是当前CPU的缓存行状态标记为E。
- 当CPUB也加载了这个数据时,会发送消息通知其他CPU核心,会发现CPUA也有这个数据并且状态为E,那么CPUA中的缓存行的状态会由E变为S,CPUB中的缓存行的状态为S。
- 此时CPUA要修改了变量i的值,发消息通知其他CPU核心,其他CPU核心收到消息后将缓存行的状态变为I,CPUA修改完数据后将缓存行的状态设置为M。
- 如果CPUA继续修改变量i的值,当它看到缓存行的状态为M,说明它当前缓存行中的值是最新的数据,那么它就不会发送消息通知其他CPU核心,直接更新即可。
- 当CPUB要读取主内存中的数据时,发出读取数据的指令;CPUA感知到CPUB要读取数据就会将修改后的变量i同步到主内存中,并将缓存行的状态修改为E,以便CPUB可以读取到最新的数据。当CPUB读取到数据后,将缓存行的状态由I修改为S,CPUA将缓存行的状态由E修改为S。
下图即是 MESI 协议的状态图:
volatile关键字
基于JMM了解volatile
注意,这里JMM 是语言级的内存模型,并非我们平时所认为的内存模型,它的出现是向程序员提供抽象模型,简化和屏蔽硬件底层的细节。我们都知道volatile可以保证变量的可见性,那么从JMM模型上来看,它保证可见性方式很简单。 如下图所示
- 由于是volatile变量,JMM会把线程1本地缓存中修改后的共享变量立即刷新到主内存
- 线程2读取,同样因为volatile修饰的原因,JMM 会把该线程对应的本地内存置为无效,直接从主存中获取,从而保证缓存一致性。
volatile保证可见性代码示例
下面这段代码,读者可以删除变量num的volatile关键字进行验证,有无volatile的区别。
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上执行。
- 线程t1获取到共享变量a的值,此时并没有其他核心CPU获取到,状态为E。
- 后续线程t2启动获取到a的值,并且总线嗅探到另一个CPU也持有该共享变量,所以两个CPU缓存行都设置为S。
- 线程t2修改变量a的值,通过总线嗅探机制发起通知,线程t1的线程收到消息后,将缓存行变量设置为I。
- 线程t1下次获取变量会从主内存中获取,因此结束死循环。
不加volatile,线程t1无法感知到变量值的改变,会一直读取CPU缓存行中的值,导致死循环。
volatile禁止指令重排序
volatile能够禁止指令重排序,从而能够避免在高并发环境中多个线程之间出现乱序执行的情况。volatile禁止指令重排序是通过内存屏障实现的。我们不妨看一段双重锁校验的单例模式代码,代码如下:
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个动作:
- 为SINGLETONLANHAN 引用分配内存空间
- 初始化SINGLETONLANHAN
- SINGLETONLANHAN 指向分配的内存空间
上诉代码正常会按照1、2、3的顺序执行,可是如果没有volatile
禁止指令重排序的话,可能会出现1、3、2的顺序,这样会导致其中一个线程创建了一个为初始化且不为空的对象,当另一个线程操作判断不为空后返回出去,就会产生异常。
volatile的局限性
volatile,虽然能够保证数据的可见性和有序性,但是无法保证数据的原子性,我们来看下经典案例
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
Lock lock = new ReentrantLock();
private void lockIncrease() {
lock.lock();
try {
num++;
} finally {
lock.unlock();
}
}
2、synchronize
private synchronize void increase() {
num++;
}
3、原子类
private static AtomicInteger atomicInteger = new AtomicInteger();
private void atomicIncrease() {
atomicInteger.getAndIncrement();
}