励志做架构师的人,一直在路上行走着。
真心的,volatile真的不错,很有东西!!!!
1、JMM如何解决三大问题
1.1、原子性问题
除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。(volatile不能保证原子性)
private static int counter = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
for (int j = 0; j < 1000; j++) {
counter++;//分三步- 读,自加,写回
}
});
thread.start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter);
}
运行结果(偶尔不等于1000):
9770
用户synchronized解决原子性:
static Object object = new Object();
private static int counter = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
for (int j = 0; j < 1000; j++) {
synchronized (object){
counter++;//分三步- 读,自加,写回
}
}
});
thread.start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter);
}
运行结果一直是:
10000
1.2、可见性问题
volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
问题来了,既然它可以保证修改的值立即能更新到主存,其他线程也会捕捉到被修改后的值,那么为什么不能保证原子性呢?
答:首先需要了解的是,Java中只有对基本类型变量的赋值和读取是原子操作,如i = 1的赋值操作,但是像j = i或者i++这样的操作都不是原子操作,因为他们都进行了多次原子操作,比如先读取i的值,再将i的值赋值给j,两个原子操作加起来就不是原子操作了。所以,如果一个变量被volatile修饰了,那么肯定可以保证每次读取这个变量值的时候得到的值是最新的,但是一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性了。
private static boolean initFlag = false;
private static int counter = 0;
public static void refresh(){
log.info("refresh data.......");
initFlag = true;
log.info("refresh data success.......");
}
public static void main(String[] args){
Thread threadA = new Thread(()->{
while (!initFlag){
//System.out.println("runing");
counter++;
}
log.info("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变");
},"threadA");
threadA.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread threadB = new Thread(()->{
refresh();
},"threadB");
threadB.start();
}
运行结果(还在一直循环):
23:33:30.799 [threadB] INFO com.ceshi.CodeVisibility - refresh data.......
23:33:30.801 [threadB] INFO com.ceshi.CodeVisibility - refresh data success.......
用volatile实现可见性:
private static boolean initFlag = false;
private static volatile int counter = 0;
public static void refresh(){
log.info("refresh data.......");
initFlag = true;
log.info("refresh data success.......");
}
public static void main(String[] args){
Thread threadA = new Thread(()->{
while (!initFlag){
//System.out.println("runing");
counter++;
}
log.info("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变");
},"threadA");
threadA.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread threadB = new Thread(()->{
refresh();
},"threadB");
threadB.start();
}
运行结果:
23:37:13.262 [threadB] INFO com.ceshi.CodeVisibility - refresh data.......
23:37:13.265 [threadB] INFO com.ceshi.CodeVisibility - refresh data success.......
23:37:13.265 [threadA] INFO com.ceshi.CodeVisibility - 线程:threadA当前线程嗅探到initFlag的状态的改变
1.3、有序性问题
在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
/**
* 查看汇编指令
* -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp
*/
private volatile static Singleton myinstance;
/**
* 双重锁机制保证单例安全
* @return
*/
public static Singleton getInstance() {
if (myinstance == null) {
synchronized (Singleton.class) {
if (myinstance == null) {
myinstance = new Singleton();
}
}
}
return myinstance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
Java内存模型: 每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
指令重排序: java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
下图为从源码到最终执行的指令序列示意图:
as-if-serial语义:
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
public static void main(String[] args) {
/**
* as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)
* 程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
*
* 以下例子当中1、2步存在指令重排行为,但是1、2不能与第三步指令重排
* 也就是第3步不可能先于1、2步执行,否则将改变程序的执行结果
*/
double p = 3.14; //1
double r = 1.0; //2
double area = p * r * r; //3计算面积
}
happens-before 原则:
只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下
- 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
- 锁规则,解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
- volatile规则,volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
- 线程启动规则,线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
- 传递性,A先于B ,B先于C 那么A必然先于C
- 线程终止规则,线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
- 线程中断规则,对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
- 对象终结规则,对象的构造函数执行,结束先于finalize()方法
public static volatile int r = 3;
public static int g=6;
public static volatile double pai = 3.14;
public static volatile double area;
public static void caculate(){
int a = r;
int b = g;
area = a * b * pai;
}
public static void main(String[] args) {
caculate();
}
2、volatile
volatile是Java虚拟机提供的轻量级的同步机制。
- volatile关键字有如下两个作用保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
- 禁止指令重排序优化。
2.1、volatile如何保证可见性
volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。
内存屏障,又称内存栅栏,是一个 CPU 指令。 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止+ 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
volatile 变量的内存可见性是该变量上添加lock前缀指令:
lock 前缀的指令在多核处理器下会引发两件事情:
1. 将当前处理器缓存行的数据写回到系统内存。
2. 写回内存的操作会使在其他 CPU 里缓存了该内存地址的额数据无效。
private static volatile boolean initFlag = false;
private static volatile int counter = 0;
public static void refresh(){
log.info("refresh data.......");
initFlag = true;
log.info("refresh data success.......");
}
public static void main(String[] args){
Thread threadA = new Thread(()->{
while (!initFlag){
//System.out.println("runing");
counter++;
}
log.info("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变");
},"threadA");
threadA.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread threadB = new Thread(()->{
refresh();
},"threadB");
threadB.start();
}
通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。 如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。 volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
2.2、volatile如何保证有序性(东西有点多)
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如 何实现禁止指令重排优化的,先了解一个概念,内存屏障(Memory Barrier)。
硬件层的内存屏障 Intel硬件提供了一系列的内存屏障,主要有:
- lfence,是一种Load Barrier 读屏障
- sfence, 是一种Store Barrier 写屏障
- mfence, 是一种全能型的屏障,具备ifence和sfence的能力
- Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对 CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由 JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执 行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于 编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉 编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插 入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用 是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。 总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。 下面看一个非常典型的禁止重排优化的例子DCL,如下:
/**
* 查看汇编指令
* -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp
*/
private static Singleton myinstance;
/**
* 双重锁机制保证单例安全
* @return
*/
public static Singleton getInstance() {
if (myinstance == null) {
synchronized (Singleton.class) {
if (myinstance == null) {
myinstance = new Singleton();
}
}
}
return myinstance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)
memory = allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance!=null
由于步骤1和步骤2间可能会重排序,如下:
memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);//2.初始化对象
由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果 在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。
//禁止指令重排优化
private volatile static DoubleCheckLock instance;
前面提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。
下图是JMM针对编译器制定的volatile重排序规则表。
举例来说,第二行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
从上图可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读或写时,不能重排序。
int a;
public volatile int m1 = 1;
public volatile int m2 = 2;
public void readAndWrite() {
int i = m1; // 第一个volatile读
int j = m2; // 第二个volatile读
a = i + j; // 普通写
m1 = i + 1; // 第一个volatile写
m2 = j * 2; // 第二个 volatile写
}
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图:
上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图
上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例
代码进行说明:
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}
}
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化。
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障。
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,除最后的StoreLoad屏障外,其他的屏障都会被省略。
前面保守策略下的volatile读和写,在X86处理器平台可以优化成如下图所示。前文提到过,X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需 在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在 X86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
3、CPU缓存一致性协议MESI
3.1、CPU为什么要使用高速缓存
CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。
在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。
时间局部性(Temporal Locality): 如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归、方法的反复调用等。
空间局部性(Spatial Locality): 如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。比如顺序执行的代码、连续创建的两个对象、数组等。
带有高速缓存的CPU执行计算的流程
- 程序以及数据被加载到主内存
- 指令和数据被加载到CPU的高速缓存
- CPU执行指令,把结果写到高速缓存
- 高速缓存中的数据写回主内存
目前流行的多级缓存结构
由于CPU的运算速度超越了1级缓存的数据I\O能力,CPU厂商又引入了多级的缓存结构。
多级缓存结构
3.2、多核CPU多级缓存一致性协议MESI
多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。
MESI协议缓存状态
MESI 是指4中状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
缓存行(Cache line):缓存存储数据的单元。
注意:
对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
理解该图的前置说明:
1.触发事件
2.cache分类
前提:所有的cache共同缓存了主内存中的某一条数据。
本地cache:指当前cpu的cache。
触发cache:触发读写事件的cache。
其他cache:指既除了以上两种之外的cache。
注意:本地的事件触发 本地cache和触发cache为相同。
上图的切换解释:
下图示意了,当一个cache line的调整的状态的时候,另外一个cache line 需要调整的状态。
举个栗子来说:
假设cache 1 中有一个变量x = 0的cache line 处于S状态(共享)。那么其他拥有x变量的cache 2、cache 3等x的cache line调整为S状态(共享)或者调整为 I 状态(无效)
多核缓存协同操作
假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。
单核读取
那么执行流程是:
- CPU A发出了一条指令,从主内存中读取x。
- 从主内存通过bus读取到缓存中(远端读取Remote read),这是该Cache line修改为E状态(独享).
双核读取
那么执行流程是:
- CPU A发出了一条指令,从主内存中读取x。
- CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态。
- CPU B发出了一条指令,从主内存中读取x。
- CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。
修改数据
那么执行流程是:
- CPU A 计算完成后发指令需要修改x.
- CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)
- CPU A 对x进行赋值。
同步数据
那么执行流程是:
- CPU B 发出了要读取x的指令。
- CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改为E(独享)
- CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。
缓存行伪共享
- 什么是伪共享?
CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache 的 Cache Line 大小都是64Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。
举个例子: 现在有2个long 型变量 a 、b,如果有t1在访问a,t2在访问b,而a与b刚好在同一个cache line中,此时t1先修改a,将导致b被刷新! - 怎么解决伪共享?
Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置 -XX:-RestrictContended 才会生效。
@sun.misc.Contended
public final static class TulingVolatileLong {
public volatile long value = 0L;
//public long p1, p2, p3, p4, p5, p6;
}
3.3、MESI优化
缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。
CPU切换状态阻塞解决-存储缓存(Store Bufferes)
比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。应为这个等待远远比一个指令的执行时间长的多。
Store Bufferes
为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。这么做有两个风险.
Store Bufferes的风险
第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。
第二、保存什么时候会完成,这个并没有任何保证。
value = 3;
void exeToCPUA(){
value = 10;
isFinsh = true;
}
void exeToCPUB(){
if(isFinsh){
//value一定等于10?!
assert value == 10;
}
}
试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10。
即isFinsh的赋值在value赋值之前,这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。
它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。
顺便提一下NIO的设计和Store Bufferes的设计是非常相像的。
硬件内存模型
执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:
- 对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送
- Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
- 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。
即便是这样处理器已然不知道什么时候优化是允许的,而什么时候并不允许。
干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(Memory Barriers)。
写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。
读屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。
void executedOnCpu0() {
value = 10;
//在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
storeMemoryBarrier();
finished = true;
}
void executedOnCpu1() {
while(!finished);
//在读取之前将所有失效队列中关于该数据的指令执行完毕。
loadMemoryBarrier();
assert value == 10;
}
十天的艰苦奋斗,换来的是革新