一、现代计算机内存模型
计算机系统中的不同存储层次(如寄存器、缓存、主内存、辅助存储)之间存在显著的速度差异。CPU 的速度远远快于主内存的速度
。缓存作为 CPU 和主内存之间的中介,能够提供更快的数据访问速度,从而减少 CPU 等待数据的时间。 此外,随着应用程序和数据集的规模不断增长,内存带宽成为一个重要的瓶颈。通过使用缓存,可以减少对主内存的访问次数,从而减轻内存带宽的压力。这对于高性能计算和大规模数据处理尤为重要。
下图简单表示了这个内存模型
1. 缓存一致性协议
虽然高速缓存(Cache)在提高计算机系统性能方面发挥了重要作用,但它也带来了一些问题和挑战。 在多核或多处理器系统中,每个处理器可能有自己的缓存。当多个处理器访问共享数据时,可能会出现缓存一致性问题。一个处理器对数据的修改可能不会立即反映在其他处理器的缓存中,导致不同处理器看到的数据不一致
。为了解决这个问题,系统需要实现缓存一致性协议
(如 MESI、MOESI 等),这会增加系统的复杂性和开销。
MESI
MESI 是一种用于多处理器系统中的缓存一致性协议,旨在确保多个处理器的缓存中存储的数据保持一致。MESI 是四个状态的首字母缩写,分别是
- M (Modified) :表示缓存中的数据已被修改,并且与主内存中的数据不一致。此时,缓存中的数据是最新的,且该缓存行是唯一的拥有者。若其他处理器需要访问该数据,必须首先从拥有该数据的缓存中获取。
- E (Exclusive) :表示缓存中的数据与主内存中的数据一致,并且该缓存行是唯一的拥有者。此时,数据未被修改,且其他处理器的缓存中没有该数据的副本。如果该缓存行被修改,则状态将转变为 Modified。
- S (Shared) :表示缓存中的数据与主内存中的数据一致,但可能有多个处理器的缓存中存在该数据的副本。此时,数据是共享的,任何处理器都可以读取该数据,但如果某个处理器需要修改该数据,必须先将其状态转变为 Modified。
- I (Invalid) :表示缓存中的数据无效。此时,缓存中的数据可能已经被其他处理器修改,或者该缓存行从未被加载过。处理器在访问该缓存行时需要从主内存中重新加载数据。
MESI工作原理 MESI 协议通过以下方式确保缓存一致性:
- 状态转换:每个缓存行都有一个状态(M、E、S、I),并根据处理器对数据的操作(如读取、写入)进行状态转换。例如,当一个处理器写入数据时,如果该数据在缓存中是 Shared 状态,则会将其状态转变为 Modified,并通知其他处理器使其缓存中的该行无效。
- 总线协议:MESI 协议通常依赖于一个共享的系统总线,处理器通过总线发送消息以通知其他处理器缓存行的状态变化。例如,当一个处理器将缓存行的状态从 Shared 转变为 Modified 时,它会在总线上广播这一信息,其他处理器接收到后会将该行状态设置为 Invalid。
- 避免数据冲突:通过使用 MESI 协议,系统能够有效地避免多个处理器同时修改同一数据而导致的数据冲突和不一致性。
该协议高效并且实现相对简单,不过缺点是频繁的数据更新和广播带来了总线消耗,极端情况下处理其需要等待其他处理器完成更新才能获取最新数据,这带来了延迟
2. 缓存失效的判定
上述提到MESI机制中,判定缓存失效主要是通过通知机制实现的,通知的方式存在一个潜在问题是,如果当前cpu没有收到失效通知,误以为自身是最新数据进行运算,实际上因为缓存已经失效,只是通知还没到,这就造成了潜在的资源浪费和cas延迟。
嗅探(Snooping)
在嗅探机制中,每个处理器的缓存会监视(或"嗅探")总线上的所有通信。当一个处理器发出对某个缓存行的读或写请求时,其他处理器会检查该请求是否涉及到它们自己的缓存行。如果是,它们会根据协议的规则决定是否使自己的缓存行失效
因此在实际应用中,许多现代多核处理器采用了嗅探机制,因为它能够提供更好的实时性和一致性,尽管它可能会增加带宽的消耗。不同的系统架构和应用场景可能会选择不同的机制,或者结合使用这两种方法以达到最佳效果。
二、Volatile
1.指令重拍
指令重排(Instruction Reordering)是现代处理器和编译器为了提高程序执行效率而采用的一种优化技术。它允许编译器或处理器在不改变程序最终结果的前提下,重新安排指令的执行顺序。指令重排可以发生在编译阶段(编译器优化)和运行阶段(处理器优化)。
1)指令重排的影响
指令重排在单线程环境中通常不会引起问题,因为程序的逻辑顺序仍然保持一致。然而,在多线程环境中,指令重排可能导致可见性和顺序性的问题,进而引发竞态条件和数据不一致
考虑以下简单的 Java 代码示例
java
class Example {
private int a = 0;
private boolean flag = false;
public void writer() {
a = 1; // 操作 A
flag = true; // 操作 B
}
public void reader() {
if (flag) { // 操作 C
System.out.println(a); // 操作 D
}
}
}
在这个例子中,writer
方法先将 a
设置为 1
,然后将 flag
设置为 true
。reader
方法检查 flag
的值,如果为 true
,则打印 a
的值。
在没有适当的同步机制的情况下,编译器或处理器可能会重排指令,导致以下情况:
flag
被设置为true
(操作 B)在a
被设置为1
(操作 A)之前执行。reader
方法可能会在flag
为true
时读取a
,但此时a
可能仍然是0
,因为writer
方法的操作 A 还没有执行。
1)volatile禁止指令重排
java编译器会在生成指令系列时在适当的位置会插入内存屏障
指令来禁止特定类型的处理器重排序。
volatile写是在前面和后面分别插入内存屏障 ,而volatile读操作是在后面插入两个内存屏障 。
- 写的时候最重要保证写入正确,因此前边的写必须全部完成,否则其他地方依赖volatile来判定状态会失效
- 读的时候可能要依赖volatile变量状态,因此必须等待volatile读完后再去读
2.可见性
在多线程编程中,可见性问题 指的是一个线程对共享变量的修改,另一个线程可能无法立即看到这个修改。可见性是并发编程中的一个关键概念,确保一个线程对共享数据的修改能够被其他线程及时看到。在前面背景描述中我们知道,现代cpu都是多核的,因此对于单例这种共享变量
是必须保证可见性的,解决可见性的手段有哪些
1)锁解决可见性
加锁解决可见性问题的原理
内存屏障:
- 当一个线程获取锁时,它会清空自己的工作内存中的缓存,确保从主内存中读取最新的变量值
- 当一个线程释放锁时,它会将工作内存中的变量值刷新到主内存中,确保其他线程能够看到这个最新的值
可见性保证:
由于锁的获取和释放都涉及到主内存的读写操作,因此通过加锁,线程之间的可见性得到了保证。一个线程在释放锁时,所有对共享变量的修改都会被刷新到主内存中,而其他线程在获取锁时,会从主内存中读取最新的值。
2)volatile解决可见性
volatile解决可见性问题的原理
禁止缓存 当一个变量被声明为 volatile
时,Java 内存模型(Java Memory Model, JMM)确保: 禁止线程对该变量的值进行缓存 :这意味着每次访问 volatile
变量时,都会直接从主内存中读取,而不是从线程的本地缓存中读取。这样,任何线程对 volatile
变量的修改都会立即反映到主内存中,其他线程在读取该变量时能够看到最新的值。
以上这是表象描述,这里底层实现可能会根据缓存一致性协议来确保,比如会结合通知、嗅探等机制来提高效率,避免每次的直接主存访问,不过这关系到处理器的技术实现了
三、Java中单例创建时的应用
了解了上述背景之后,我们再来看一下Volatile
关键字的作用,我们先来看一个应用,java中最经典的单例创建方案(DoubleCheck+Volatile)
java
public class MyInstance {
private static volatile MyInstance instance = null; // volidate 可见性、防止指令重拍
public static DokiFeedCardView getInstance() {
if (instance == null) { // 第一个if,如果存在就无需再进入sync同步块,避免了获取锁的开销
synchronized(MyInstance.class) { // 如果实在不存在,才进入
if (instance == null) { // 线程安全的状况下:查询是否已经创建好了,没创建就进行创建
// ①new obj, ②init obj, ③instance = obj, volidate防止指令重拍,以防②排到③之后
instance = new MyInstance();
}
}
}
return instance;
}
}
半成品对象
instance = new MyInstance();
拆解
java
① 创建obj对象 new MyInstance();
② 初始化 obj.init;
③ obj赋值给instance; (obj是volatile对象可以防止②重拍到③之后)
③和②本身没有逻辑依赖关系,如果发生重拍,变成了①③②,这样当a线程执行完成②时,对象其实还没有初始化,是一个半成品对象。如果此时b线程访问发现instance非空,直接拿到一个半成品对象去使用,这会造成不可预期的问题
这里的技术点拆解
- 本质上只需要synchronized块的代码就足够了,外层if语句是为了减少获取锁的性能开销
- 内层if语句是为了保证instance对象只有一个
- 声明为volatile保证第一个if语句处,其他线程能够准确判断instance对象是否创建完毕(可见性)
- 声明为volatile保证
instance = new MyInstance();
不被指令重拍,其他线程拿到半成品对象