目录
1. 背景
2. 线程安全问题
2.1 缓存不一致问题
2.2 CPU优化
2.3 内存屏障
3 Java内存模型
1. 背景
首先从为什么要有Java内存模型开始讲起。
- 现在计算机往往都是多核的,每个核心下会有高速缓存。高速缓存的诞生是由于CPU和内存(主存)的速度存在差异,L1和L2缓存一般是每个核心独占一份的。
- 为了让CPU提高运算效率,处理器可能会对输入的代码进行乱序执行,也就是指令重排序。
- 一次对数值的修改操作往往是非原子性的(比如i++实际上在计算机执行时会分成多个指令)。
总的来说,单线程下上面说的可见性、有序性、原子性都没问题,因为单线程意味着无并发,并且单线程下,编译器/runtime/处理器都必须遵守as-if-serial予以,意味着他们不会对数据依赖关系的操作做重排序。
CPU为了效率,有了高速缓存、指令重排序等,整个架构变得复杂,我们写程序的时候也需要充分利用CPU资源,所以不得不使用多线程。
2. 线程安全问题
多线程意味着并发,也就意味着需要考虑线程安全问题。
- 缓存数据不一致:多个线程同时修改共享变量,CPU核心下的高速缓存是不同享的,那么cache与内存之间的数据同步怎么做?
- CPU指令重排序在多线程下导致代码在非预期下执行,最终会导致结果存在错误的情况。
2.1 缓存不一致问题
1) 使用总线锁:某个核心在修改数据的过程中,其他核心均无法修改内存中的数据。(类似于独占内存的概念,只要有CPU在修改,那别的CPU就得等待当前CPU释放)
2) 缓存一致性协议(MESI):Modified(修改状态)、Exclusive(独占状态)、Share(共享状态)、Invalid(无效状态),根据对象状态做不同的策略,相较于总线锁,MESI的锁粒度更小了,所以性能会更高。
缓存一致性协议我认为可以理解为缓存锁,它针对的是缓存行进行加锁,所谓缓存行其实就是高速缓存存储的最小单位。
MESI协议的原理大概就是,当每个CPU读取共享变量之前,会先识别数据的对象状态(是修改、共享还是独占或者无效)。
- 如果是独占,说明当前CPU将要得到的变量数据是最新的,没有被其他CPU所同时读取。
- 如果是共享,说明当前CPU将要得到的变量数据还是最新的,有其他的CPU在同时读取,但没有被修改。
- 如果是修改,说明CPU正在修改该变量的值,同时会向其他CPU发送该数据状态为Invalid的通知,得到其他CPU响应后(其他CPU将数据状态从共享变成Invalid),当前CPU将高速缓存的数据写到主存,并把自己的状态从从Modified变成Exclusive。
- 如果是无效,说明当前数据是修改过的,需要从主存重新读取最新的数据。
2.2 CPU优化
MESI的关键在于某个CPU修改了数据时,需要同步通知其他CPU数据被修改了。
同步,意味着等待,所以CPU需要进行优化,也就是从同步变成异步。
在修改时同步告诉其他CPU,而现在则把最新修改的值写到Store Buffer中,并通知其他CPU要修改状态,随后CPU就直接返回干其他事情了,等接收到其他CPU的响应消息再将数据更新到高速缓存中。其他CPU接收到Invalid通知时,也会把接收到的消息放入Invalid queue就会直接返回告诉修改数据的CPU已经将状态修改为Invalid了。
1) 同核心读写共享变量问题。
异步会带来新的问题,那我现在CPU修改完A值也写到Store Buffer了,CPU就可以干其他事情了,那如果CPU又接受指令需要修改A值但上一次修改的值还在Store Buffer中尚未修改至高速缓存呢?
所以CPU在读取时,需要去Store Buffer看看是否存在,存在则直接取,不存在才读主存的数据。
2) 不同核心读写共享变量问题。
CPU1修改了A值,已经把修改后的值写到了Store Buffer并通知CPU2对该值进行Invalid操作,而CPU2可能还没收到Invalid通知就去做其他操作导致CPU2的还是旧值。
即便CPU2收到了Invalid通知,但CPU1的值还没写到主存所以CPU2再次想主存读取的时候也还是旧值。
总而言之,由于CPU对缓存一致性协议进行异步优化(Store Buffer、Invalid queue),很可能导致后面的指令查不到前面指令的执行结果(各个指令的执行顺序非代码执行顺序)。
2.3 内存屏障
为了解决乱序问题(可见性问题,也就是修改完没有及时同步到其他CPU),引出了内存屏障的概念。内存屏障其实就是把异步优化给禁用,分为三中类型:写屏障、读屏障、全能屏障。
简单来说,屏障可以理解为在操作数据的时候往数据插入一条特殊指令,只要遇到这条指令,那前面的操作都得完成。
1) 写屏障。
当CPU发现写屏障时,会把该指令之前存在于Store Buffer所有写指令刷入高速缓存,通过这种方式可以让CPU修改的数据可以立刻暴露给其他CPU,达到可见性的目的。
2) 读屏障。
当CPU发现读屏障时,会把该指令之前存在于Invalid queue的所有指令都处理掉,通过这种方式确保当前CPU的缓存状态是正确的,于是读操作一定是读取最新的数据。
3) 全能屏障。
包含了读屏障和写屏障,相当于读写屏障。
3 Java内存模型
由于不同CPU架构的缓存体系、缓存一致性协议、重排序策略不一样,提供的内存屏障指令也有差异,所以为了简化Java开发工作,Java封装了一套规范,也就是Java内存模型。
Java内存模型希望屏蔽各种硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问都能得到一致效果,目的是解决多线程存在的原子性、可见性和有序性问题。