内存可见性问题分析
内存可见性问题
在主线程对变量的修改对于线程读取该变量是不可见的,线程读取的是本地内存缓存的变量值。
如何解决共享变量可见性的问题
使用volatile变量,可以解决共享数据在多线程环境下可见性的问题。
使用volatile关键字修饰变量后,在生成汇编指令的时候,会生成一个lock指令。
思考lock汇编指令来保证可见性问题?
lock指令在多核处理器下会引发两件时间:
- 会将当前处理器缓存行的数据写回到系统内存
- 这个会写内存的操作会使其他CPU里缓存了该地址内存的数据无效。
什么是可见性?
在多线程环境下,读和写发生在不同的线程中,可能会出现:读线程不能及时的读取到其他线程写入的最新的值,这就是所谓的可见性问题。
硬件层面
CPU/内存/IO设备之间存在读取速度的差距和存储大小的差异
为了减小各类型设备之间的读取效率差异,增加CPU的处理效率
- CPU层面增加了高速缓存
- 操作系统,进程-》线程 | 利用CPU时间片来切换
- 编译器的优化,更合理的利用CPU的高速缓存。
CPU层面的高速缓存
因为高速缓存的存在,会导致一个缓存一致性问题。
总线锁和缓存锁
总线锁
总线锁:在多核CPU下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他CPU无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,在锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁的开销比较大,这种机制显然是不合理的。
缓存锁
优化方法:最好的办法是控制控制锁的保护粒度,我们只需要保证对于被多个CPU缓存的同一份数据是一致的就行。在P6架构的CPU后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要写回到主内存中的,就可以采用缓存锁来解决问题。
所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。
X86架构下:引入了缓存锁。由高速缓存写入主内存的时候,不会在总线上加锁。只有数据写入高速缓存的时候,才会加上缓存锁。在数据写入高速缓存的时候,才可以使用缓存锁,缓存锁具有一定的机制(MESI),保证多个高速缓存之间数据的一致性。
总线锁和缓存锁怎么选择,取决于很多因素,比如CPU是否支持、以及存在无法缓存的数据时(比较大或者快约多个缓存行的数据),必然还是会使用总线锁。
缓存一致性协议
MSI MESI MOSI
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。
MESI表示缓存行的四种状态,分别是
1.M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
2.E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
3.S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
4.I(Invalid) 表示缓存已经失效
写线程是如何让其他CPU缓存失效的?
CPU0在写入本地高速缓存时,会向其他cpu发出一个失效通信,告知该缓存失效,而其他cpu会向cpu0发送一个回执通信ACK,cpu0在收到该ack时,才会继续向下执行其他指令,此处为一个强一致性,优秀的工程师为了解决这一问题,又引入了store buffers.
而引入store buffers之后,导致了指令重排序问题。
工程师考虑如何提高CPU的利用率,但是业务场景中如何使用是开发人员的事情。无法从硬件层面解决业务上的问题,继而提供了内存屏障。
MESI的一个优化
Store Bufferes(写缓冲区)
Store Bufferes是一个写的缓冲,对于上述描述的情况,CPU0可以先把写入的操作先存储到StoreBufferes中,Store Bufferes中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到Acknowledgement,继续往下执行其他指令,直到收到CPU0收到Acknowledgement再更新到缓存,再从缓存同步到主内存。
失效队列:什么时候失效,cpu空闲的时候。
Store Bufferes 失效队列会导致不一致性,需要控制及时的更新和失效。
指令重排序
通过内存屏障禁止了指令重排序
X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)
- Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(storebufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
- Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
- Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
软件层面
使用volatile提供对内存屏障指令的调用。
JMM(JavaMemoryModel)
JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性.
需要注意的是,JMM并没有主动限制执行引擎使用处理器的寄存器和高速缓存来提升指令执行速度,也没主动限制编译器对于指令的重排序,也就是说在JMM这个模型之上,仍然会存在缓存一致性问题和指令重排序问题。JMM是一个抽象模型,它是建立在不同的操作系统和硬件层面之上对问题进行了统一的抽象,然后再Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性问题。
JMM是如何解决可见性和有序性问题的
导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?
分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。
volatile的原理
Happens-Before模型
除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。
从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。
程序顺序规则(as-if-serial语义)
- 不能改变程序的执行结果(在单线程环境下,执行的结果不变。)
- 依赖问题,如果两个指令存在依赖关系,是不允许重排序
int a = 0;
int b = 0;
void test(){
int a = 1; a
int b = 1; b
//int b = 1;
//int a = 1;
int c=a*b; c
}
a happens -before b ; b happens before c
传递性规则
a happens-before b , b happens- before c, a happens-before c
volatile变量规则
volatile 修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作.
内存屏障机制来防止指令重排.
监视器锁规则
int x = 10;
synchronized(this){
//后序线程读取到的x的值一定是12
if(x<12){
x= 12;
}
}
x=12;
start规则
public class StartDemo(){
int x = 0;
Thread t1 = new Thread(()->{
//读取x的值,一定是20
if(x==20){
}
});
}
x = 20;
t1.start();
join规则
public class Test{
int x = 0;
Thread t1 = new Thread(()->{
x = 200;
})
t1.start();
t1.join();//保证结果的可见性。
System.out.println(x);//在此处读到的x的值一定是200。
}