众所周知,我们写的进程跑在CPU上,进程下线程作为CPU调度的基本单位,而当今CPU基本都是多核CPU也就是说可以实现线程并行。
由于CPU计算极快,快到从主内存加载一次变量都要经过很多个时钟周期,为了提高CPU的利用率,在CPU内部引入了快速缓存。
那么线程跑的时候局部变量跑没有问题,但线程共享变量就会出现并发问题,因为他们拿到的都是从主内存中读到的共享变量的副本。
假设变量X = 0共享,两个线程同时执行X++
最后的结果可能就不是设计的2而是1。
因为X++需要先从主存中读出变量,再对变量进行更改,最后写回主存。
两个线程同时进行的话就会出现都加载变量X=0进入缓存内,各自将其加一后写回主内存。
最后的结果就是运行结束后X=1
为了解决缓存变量不一致设计上有多种方案,比如总线锁与MESI协议。
总线锁就是在一个线程访问共享变量的时候其他CPU停止工作,使其独占变量。
MESI拆开英文是(Modified (修改状态)、Exclusive (独占状态)、Share(共享状态)、Invalid(无效状态)),他给变量设置有状态位。当每个CPU读取共享变量之前,会先识别数据的「对象状态」(是修改、还是共享、还是独占、还是无效)。
现在如果通知其他CPU的话是同步操作,得等其他CPU确认后再继续。
为了更高的性能,CPU又引入了修改缓冲,现在把最新修改的值写到「store buffer」中,并通知其他CPU记得要改状态,随后CPU就直接返回干其他事了。等到收到其它CPU发过来的响应消息,再将数据更新到高速缓存中。
其他CPU接收到invalid(无效)通知时,也会把接收到的消息放入「invalid queue」中,只要写到「invalid queue」就会直接返回告诉修改数据的CPU已经将状态置为「invalid」
CPU在读取的时候,需要去「store buffer」看看存不存在,存在则直接取,不存在才读主存的数据。
CPU1修改了A值,已把修改后值写到「store buffer」并通知CPU2对该值进行invalid(无效)操作,而CPU2可能还没收到invalid(无效)通知,就去做了其他的操作,导致CPU2读到的还是旧值。即便CPU2收到了invalid(无效)通知,但CPU1的值还没写到主存,那CPU2再次向主存读取的时候,还是旧值。
CPU对「缓存一致性协议」进行的异步优化「store buffer」「invalid queue」,很可能导致后面的指令很可能查不到前面指令的执行结果(各个指令的执行顺序非代码执行顺序),这种现象很多时候被称作「CPU乱序执行」
为了解决乱序问题(可见性),又引出了「内存屏障」的概念。
内存屏障 为了解决 异步优化 导致 CPU乱序执行或者称 缓存不及时可见 的问题,把异步优化给禁用掉
内存屏障可以分为三种类型:写屏障,读屏障以及全能屏障(包含了读写屏障)
屏障可以简单理解为:在操作数据的时候,往数据插入一条”特殊的指令”。只要遇到这条指令,那前面的操作都得「完成」。
写屏障就可以这样理解:CPU当发现写屏障的指令时,会把该指令「之前」存在于「store Buffer」所有写指令刷入高速缓存。
让CPU修改的数据可以马上暴露给其他CPU,达到「写操作」可见性的效果。
读屏障也是类似的:CPU当发现读屏障的指令时,会把该指令「之前」存在于「invalid queue」所有的指令都处理掉,这种方式就可以确保当前CPU的缓存状态是准确的,达到「读操作」一定是读取最新的效果。
由于不同CPU架构的缓存体系不一样、缓存一致性协议不一样、重排序的策略不一样、所提供的内存屏障指令也有差异,为了简化Java开发人员的工作。Java封装了一套规范,这套规范就是「Java内存模型」。
「Java内存模型」希望 屏蔽各种硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问都能得到一致效果。目的是解决多线程存在的原子性、可见性(缓存一致性)以及有序性问题。
线程之间的「共享变量」存储在「主内存」中,每个线程都有自己私有的「本地内存」,「本地内存」存储了该线程以读/写共享变量的副本。
Java内存模型规定了:线程对变量的所有操作都必须在「本地内存」进行,「不能直接读写主内存」的变量
Java内存模型定义了8个原子操作read/load/use/assign/store/write/lock/unlock 实现 变量如何从主内存到本地内存,以及变量如何从本地内存到主内存。
可见性和有序性(禁止重排序)
Java内存模型为了实现volatile有序性和可见性,定义了4种内存屏障的「规范」,分别是LoadLoad/LoadStore/StoreLoad/StoreStore。
LoadLoad Barriers
示例:Load1; LoadLoad; Load2
该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore Barriers
示例:Store1; StoreStore; Store2
该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore Barriers
示例:Load1; LoadStore; Store2
确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad Barriers
示例:Store1; StoreLoad; Load2
该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令。
就是在volatile「前后」加上「内存屏障」,使得编译器和CPU无法进行重排序,致使有序,并且写volatile变量对其他线程可见。
Hotspot虚拟机的实现,在「汇编」层面上实际是通过Lock前缀指令来实现的。
lock指令能保证:禁止CPU和编译器的重排序(保证了有序性)、保证CPU写核心的指令可以立即生效且其他核心的缓存数据失效(保证了可见性)。
一个操作 “时间上的先发生” 不代表这个操作会是 “先行发生(Happens-before)”
参考
70-大厂面试火箭计划-Java并发/50-JMM最最最核心的概念-Happens-before原则.md · 小牛肉/CS-Wiki - Gitee.com
深入浅出Java内存模型 | 对线面试官
为什么需要Java内存模型 | 对线面试官
https://www.zhihu.com/question/325469611/answer/1650954047