JMM 内存模型
JMM 8大原子指令
什么是可见性问题?
当前线程对共享变量的操作会存在读不到,或者不能立即读到另一个线程对此变量的写操作
JAVA保证可见性的两种方式
- 内存屏障
volatile 基于jvm的storeload内存屏障 - 上下文切换
Thread.yield(); 上下文切换时会释放时间片,当下一次获取时间片时会从程序计数器取当前指令,并从主内存重新加载数据
volatile可以保证可见性和有序性,但不能保证原子性。在32位处理器中,对于long、double这种64位的写操作,会分为高32位和低32位处理。(在java规范文档第17章有描述)
DCL(Double Check Lock双重检查锁)为什么要使用volatile?
public class DoubleCheckLock {
private static DoubleCheckLock instance;
private DoubleCheckLock(){}
private static DoubleCheckLock getInstance() {
// 第一次检查
if (instance == null) {
synchronized (DoubleCheckLock.class) {
if (instance == null) {
//多线程环境下可能出问题
instance = new DoubleCheckLock();
}
}
}
return instance;
}
}
instance = new DoubleCheckLock();这个步骤实际分为三步:
- 开辟内存空间
- 对象初始化
- instance 指向内存空间地址
因为JMM不能保证多线程在临界区内的重排序。如果重排序让步骤3先于步骤2执行,对于当前Thread-1来说没问题,但是对于后面来的Thread-2,进行第一次检查时发现instance != null,就会继续往下执行。但是instance此时只有内存地址,这片空间里是空的。Thread-2会抛出NullPointException。
CPU组成部分:
- ALU: cpu 的算术逻辑单元
- Register: 寄存器。将ALU计算得出的值存到寄存器中,最后回写到内存,因为其他线程拿这个值得去内存中获取
- PC:程序计数器,存储线程当前执行的指令
- cache: 高速缓存
执行流程:计算 x=3, y=x+5,在CPU中执行的顺序
PC先取指令,然后从寄存器中找x的值,不存在则去cache中获取,cache中也不存在则去内存中获取。从内存将x=3加载到缓存,再加载到寄存器,再去PC取指令add,然后去ALU计算
CPU高速缓存cache : 容量比寄存器大,但比内存小,
作用:
- 减少cpu等待时间
- 提升cpu计算能力(局部性原理:时间局部性和空间局部性,取数据时将一片连续的内存空间加载到缓存中,因为底层认为连续区域的数据会立刻用到,比如mysql引擎加载数据时一次取4K的整数倍,缓存命中的概率大)
CPU有三级缓存,一级比一级大 L1 < L2 < L3
速度是L1 > L2 > L3 > 内存,L3是多核心共享的缓存
一个cpu核中,L1有两个,一个缓存指令,一个缓存数据
这些缓存优化都是为了减少cpu等待时间,提升性能
内存的速度远远跟不上cpu的主频的速度的。
如果将cpu存取一个指令作为一个时钟周期,比如指令x=3,执行一个load指令, 从内存中加载到寄存器需要167个时钟周期,而从L1缓存加载只需要等待4个时钟周期
内存屏障
作用:禁止指令重排序、刷新处理器缓存
分类:JMM层面、JVM层面、硬件层面
硬件层面:fence族指令
- lfence: load barrier 读屏障
- sfence: store barrier 写屏障
- mfence: 全能型屏障,具备lfence和sfence的能力
- Lock前缀指令: Lock不是一种内存屏障,但它能完成类似内存屏障的功能,Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
JVM层面:JSR规范中定义了四种内存屏障
- LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- LoadStore屏障:(指令Load1; LoadStore; Store2),在store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:(指令Store1; StoreStore; Store2),在store2及后续写入操作执行前,保证store1的写入对所有处理器可见。
- StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见,它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其他三种内存屏障的功能。
由于x86只有storeload可能会重排序,所以只有JSR的StoreLoad屏障对应它的mfence或lock前缀指令,其他屏障对应空操作。
JMM层面:
缓存一致性协议
MESI
M: modify 修改
E: Exclusive 独占
S: Share 共享
I: invalid 失效
- 总线锁定:在早期的处理器中,解决缓存不一致用的是总线锁定,即CPU串行执行,锁定整个内存。会使导致性能严重下降
使用的是Lock # 和Lock前缀指令结合来实现的 - 缓存锁定:在现代的处理器中,使用缓存锁定,将一个64字节的缓存行锁定。当一个处理器loads或stores一个内存地址x时,它会在bus总线上广播该请求,
其他的处理器和主内存都会监听总线(也称为snooping)让缓存立即刷回主内存,减少其他核的等待时间
执行流程:
- CPU1从内存中将变量x加载到缓存中,并将变量x的状态改为E(独享),并通过总线嗅探机制对内存中变量x的操作进行嗅探
- 此时,CPU2读取变量x,总线嗅探机制会将CPU1中的变量x的状态置为S(共享),并将变量x加载到CPU2的缓存中,状态为S
- CPU1对变量x进行修改操作,此时CPU1中的变量x会被置为M(修改)状态,而CPU2中的变量x会被通知,改为I(无效)状态,
此时CPU2中的变量x做的任何修改都不会被写回内存中(高并发情况下可能出现两个CPU同时修改变量a,并同时向总线发出
将各自的缓存行更改为M状态的情况,此时总线会采用相应的裁决机制进行裁决,将其中一个置为M状态,另一个置为I状态,且I状态的缓存行修改无效) - CPU1将修改后的数据写回内存,并将变量x置为E(独占)状态
- 此时,CPU2通过总线嗅探机制得知变量xa已被修改,会重新去内存中加载变量x,同时CPU1和CPU2中的变量x都改为S状态