标签:控制器 发送 严格 读取数据 它的 运用 艺术 线程 实战
总线锁、缓存锁、MESI缓存一致性协议、CPU 层面的内存屏障
Java Memory Model(java内存模型)是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。(可能在术语上与Java运行时内存分布有歧义,后者指堆、方法区、线程栈等内存区域)。
即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执。
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
即程序执行的顺序按照代码的先后顺序执行。
计算机核心组件:CPU、内存、I/O设备,三者在处理速度上存在巨大差异,CPU速度最快>内存>I/O设备(磁盘)。
为了提升计算性能,CPU从单核提升到了多核,甚至用到了超线程技术最大化提高CPU处理性能,如果后两者处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者之间的速度差异,最大化的利用CPU提升性能,从硬件、操作系统、编译器等方面,做出了很多优化:
线程是 CPU 调度的最小单元,线程设计的目是更充分的利用计算机处理的效能,但是大部分运算任务不能只依靠CPU“计算”就能完成,CPU还需要与Memory交互,比如读取运算数据、存储运算结果,这个 I/O 操 作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存 和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从CPU缓存同步到内存之中。
高速缓存很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为
它引入了一个新的缓存一致性问题。
每个 CPU 的处理过程是, 先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU 进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中 的数据同步到主内存。 由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内, 并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。 为了解决缓存不一致的问题,在 CPU 层面做了很多事情, 主要提供了两种解决办法:
在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK# 信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,总线锁定的开销比较大,这种机制显然是不合适的。
总线锁的力度太大了,最好的方法就是控制锁的保护粒度,只需要保证对于被多个 CPU 缓存的同一份数据是一致的就可以了。所以引入了缓存锁。
相比总线锁,缓存锁即降低了锁的力度。核心机制是基于缓存一致性协议来实现的。
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有 MSI、MESI、MOSI 等。最常见的就是 MESI 协议:MESI 表示缓存行的四种状态,分别是:
在MESI协议中,每个缓存的缓存控制器不仅知道自己的 读写操作,而且也监听(snoop)其它 Cache 的读写操作。
对于MESI协议,从 CPU 读写角度来说会遵循以下原则:
为了避免阻塞带来的资源浪费。在 cpu 中引入 了 Store Bufferes(存储缓存)
和 Invalidate Queue(无效队列)
。
CPU0 写入共享数据时,直接把数据写入到 store bufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。
当收到其他所有 CPU 发送了 invalidate ACK消息时,再将 store bufferes 中的数据数据存储至 cache 中。最后再从本地Cache同步到主内存。
但是 cpu 中引入 Store Bufferes 优化存在两个问题:
定义:内存屏障就是将 Store Bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
eg:StoreBufferesQuestion运行结果可能是 0 or 8 。
public class StoreBufferesQuestion {
private int numone = 0;
private Boolean flag = false;
public void update() {
numone = 8;
flag = true;
}
public void test() {
while (flag) {
// numone 是多少?
System.out.println(numone);
}
}
}
通过内存屏障后StoreBufferesQuestion改成如下:
public class StoreBufferesQuestion {
private int numone = 0;
private Boolean flag = false;
public void update() {
numone = 8;
Store Memory Barrier指令;刷新Store Bufferes
flag = true;
}
public void test() {
while (flag) {
// numone 是多少?
Load Memory Barrier指令;执行失效消息
System.out.println(numone);
}
}
}
并发编程导致可见性问题的根本原因是缓存及重排序。 而JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。这些方法大家都很熟悉: volatile、synchronized、final;
编译器重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序。
1、为什么会有内存屏障?
- CPU的高速缓存会缓存主存中的数据,缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。
- 用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障。
2、内存屏障是什么?
硬件层的内存屏障分为两种:Load Barrier (读屏障) 和 Store Barrier(写屏障)及 Full Barrier(全屏障) 是读屏障和写屏障的合集。
内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 写屏障:强制把写缓冲区/高速缓存中的脏数据等写回主内存,读屏障:将缓冲区/高速缓存中相应的数据失效。
3、java内存屏障?
- java的内存屏障通常所谓的四种即LoadLoad(LL),StoreStore(SS),LoadStore(LS),StoreLoad(SL)实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
- LoadLoad(LL)屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore(SS)屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore(LS)屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad(SL)屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
4、volatile语义中的内存屏障?
- volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
- 在每个volatile写操作前插入StoreStore(SS)屏障,在写操作后插入StoreLoad屏障;
- 在每个volatile读操作前插入LoadLoad(LL)屏障,在读操作后插入LoadStore屏障;
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了轻量锁的特性。
5、final语义中的内存屏障?
对于final域,编译器和CPU会遵循两个排序规则:
- 1、新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;
- 2、初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(意思就是先赋值引用,再调用final值)
总之上面规则的意思可以这样理解:必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:
1、写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。
2、读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。
3、X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。
HappenBefore解决的是可见性问题
定义:前一个操作的结果对于后续操作是可见的。在 JMM 中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在 happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程。
1、as-if-serial 规则(程序顺序执行):单个线程中的代码顺序不管怎么重排序,对于结果来说是不变的。
2、volatile 变量规则,对于 volatile 修饰的变量的写的操作, 一定 happen-before 后续对于 volatile 变量的读操作;
3、监视器锁规则(monitor lock rule):对一个监视器的解锁,happens-before于随后对这个监视器的加锁。
4、传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C
5、start 规则:如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作。
6、join 规则:如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。
这几条规则单独看上去没有什么厉害的地方,这些规则从来都不是单独出现的。。。综合运用效果?
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a; //4
...
}
}
}
假设线程A执行writer()方法之后,线程B执行reader()方法,那么线程B执行4的时候一定能看到线程A写入的值吗?注意,a不是volatile变量。
答案是肯定的。因为根据happens-before规则,我们可以得到如下关系:
根据程序顺序规则,1 happens-before 2;3 happens-before 4。
根据volatile规则,2 happens-before 3。
根据传递性规则,1 happens-before 4。
因此,综合运用程序顺序规则、volatile规则及传递性规则,我们可以得到1 happens-before 4,即线程B在执行4的时候一定能看到A写入的值。
全称Java Memory Model(java内存模型)是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否可以重排序等问题的无关具体平台的统一的保证。(可能在术语上与Java运行时内存分布有歧义,后者指堆、方法区、线程栈等内存区域)。
计算机核心组件:CPU、内存、I/O设备,三者在处理速度上存在巨大差异,CPU速度最快>内存>I/O设备(磁盘)。
为了提升计算性能,CPU从单核提升到了多核,甚至用到了超线程技术最大化提高CPU处理性能,如果后两者处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者之间的速度差异,最大化的利用CPU提升性能,从硬件、操作系统、编译器等方面,做出了很多优化:
线程是 CPU 调度的最小单元,线程设计的目是更充分的利用计算机处理的效能,但是大部分运算任务不能只依靠CPU“计算”就能完成,CPU还需要与Memory交互,比如读取运算数据、存储运算结果,这个 I/O 操 作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存 和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从CPU缓存同步到内存之中。
高速缓存很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为
它引入了一个新的缓存一致性问题。
每个 CPU 的处理过程是, 先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU 进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中 的数据同步到主内存。
由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内, 并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。
为了解决缓存不一致的问题,在 CPU 层面做了很多事情, 主要提供了两种解决办法:
总线锁的力度太大了,最好的方法就是控制锁的保护粒度,只需要保证对于被多个 CPU 缓存的同一份数据是一致的就可以了。所以引入了缓存锁。
2.2.2. 缓存锁
相比总线锁,缓存锁即降低了锁的力度。核心机制是基于缓存一致性协议来实现的。
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的 读写操作,而且也监听(snoop)其它 Cache 的读写操作。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
CPU 缓存行的状态是通过消息传递来进行的,如果 CPU0 要对一个在缓存中共享的变量进行写入,首先发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。
Store Bufferes(存储缓存)
和 Invalidate Queue(无效队列)
。
但是 cpu 中引入 Store Bufferes 优化存在两个问题:
定义:内存屏障就是将 Store Bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
eg:StoreBufferesQuestion运行结果可能是 0 or 8 。
public class StoreBufferesQuestion {
private int numone = 0;
private Boolean flag = false;
public void update() {
numone = 8;
flag = true;
}
public void test() {
while (flag) {
// numone 是多少?
System.out.println(numone);
}
}
}
通过内存屏障后StoreBufferesQuestion改成如下:
public class StoreBufferesQuestion {
private int numone = 0;
private Boolean flag = false;
public void update() {
numone = 8;
Store Memory Barrier指令;刷新Store Bufferes
flag = true;
}
public void test() {
while (flag) {
// numone 是多少?
Load Memory Barrier指令;执行失效消息
System.out.println(numone);
}
}
}
并发编程导致可见性问题的根本原因是缓存及重排序。 而JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
JMM 提供了一些禁用缓存以及进制重排序的方法,来解决可见性和有序性问题。这些方法大家都很熟悉: volatile、synchronized、final;
编译器重排序,JMM 提供了禁止特定类型的编译器重排序。
处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏障来禁止处理器重排序。
1、为什么会有内存屏障?
- CPU的高速缓存会缓存主存中的数据,缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。
- 用volatile关键字修饰变量可以解决上述问题,那么volatile是如何做到这一点的呢?那就是内存屏障。
2、内存屏障是什么?
硬件层的内存屏障分为两种:Load Barrier (读屏障) 和 Store Barrier(写屏障)及 Full Barrier(全屏障) 是读屏障和写屏障的合集。
内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 写屏障:强制把写缓冲区/高速缓存中的脏数据等写回主内存,读屏障:将缓冲区/高速缓存中相应的数据失效。
3、java内存屏障?
- java的内存屏障通常所谓的四种即LoadLoad(LL),StoreStore(SS),LoadStore(LS),StoreLoad(SL)实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
- LoadLoad(LL)屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore(SS)屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore(LS)屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad(SL)屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
4、volatile语义中的内存屏障?
- volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
- 在每个volatile写操作前插入StoreStore(SS)屏障,在写操作后插入StoreLoad屏障;
- 在每个volatile读操作前插入LoadLoad(LL)屏障,在读操作后插入LoadStore屏障;
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了轻量锁的特性。
5、final语义中的内存屏障?
对于final域,编译器和CPU会遵循两个排序规则:
- 1、新建对象过程中,构造体中对final域的初始化写入和这个对象赋值给其他引用变量,这两个操作不能重排序;
- 2、初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序;(意思就是先赋值引用,再调用final值)
总之上面规则的意思可以这样理解:必需保证一个对象的所有final域被写入完毕后才能引用和读取。这也是内存屏障的起的作用:
1、写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。
2、读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。
3、X86处理器中,由于CPU不会对写-写操作进行重排序,所以StoreStore屏障会被省略;而X86也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。
HappenBefore解决的是可见性问题
定义:前一个操作的结果对于后续操作是可见的。在 JMM 中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在 happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程。
这几条规则单独看上去没有什么厉害的地方,这些规则从来都不是单独出现的。。。综合运用效果?
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a; //4
...
}
}
}
假设线程A执行writer()方法之后,线程B执行reader()方法,那么线程B执行4的时候一定能看到线程A写入的值吗?注意,a不是volatile变量。
答案是肯定的。因为根据happens-before规则,我们可以得到如下关系:
根据程序顺序规则,1 happens-before 2;3 happens-before 4。
根据volatile规则,2 happens-before 3。
根据传递性规则,1 happens-before 4。
因此,综合运用程序顺序规则、volatile规则及传递性规则,我们可以得到1 happens-before 4,即线程B在执行4的时候一定能看到A写入的值。
参考资料:
《深入理解Java虚拟机-2nd》
《Java 并发编程实战》
《Java 并发编程的艺术》
标签:控制器 发送 严格 读取数据 它的 运用 艺术 线程 实战
原文地址:https://www.cnblogs.com/liboware/p/11908015.html