概述
线程不安全的本质出在哪里? 原子性,有序性与可见性(java内存模型)
其实java内存模型还不够本质,比如为什么会有指令重排?
指令重排不能用jvm为了提升执行效率而产生指令重排,比如下面这个例子
int a=1
int b=2
//指令重排这两个语句,可以获得什么性能提升呢?
这篇总结下线程安全的原理
两个预备知识
高速缓存
高速缓存是如今计算机演进的一个阶段性的成就,很多设计都是从高速缓存思想来的
高速缓存的思想很简单:越靠近计算的地方读取越开
比如Linux的页缓存,比如这里要说到的CPU高速缓存
引入CPU缓存的目的,与引入内存的目的是完全一样的.简单来说就是,内存的读写其实远远跟不上CPU计算的速度的(CPU技术迭代非常快,而内存技术相对进展就慢的多了)
每个CPU都装了一块专属的小内存,作为CPU缓存(CPU缓存还可以分多级,这里就说一级)
相应的常规理解中的内存称之为主存
局部性原理
局部性原理也非常简单
- 时间局部性
如果一个数据被用到,那么之后再次被使用的几率会大增(参考缓存) - 空间局部性
如果一个数据被用到,那么它的前后位置被使用的几率会大增(参考每个程序中都会大量用到的循环)
内存设计
因为高速缓存与局部性两个思想,计算机中的内存是这样设计的
缓存行
缓存行是局部性原理的体现
即内存中,是按缓存行为单位进行读取的(绝大部分是64字节)
这也是为什么说数组并链表更加适应遍历的原因.因为每次读取至少读一个缓存行
- 数组本身是一个连续内存空间,也就意味着数组的一次读取可以拉取到N个元素(免费的内存缓存加载)
- 链表是一个离散内存空间,它的每一个元素读取都可能必须读一个缓存行,而事实上这个缓存行中只有很少的部分才是链表元素
MESI协议
工作内存(CPU内存)是专属的,这个专属有两层含义
- CPU只允许写自己的工作内存
- 不同工作内存CPU不得访问
所以为了让CPU更好的工作,制定了MESI(缓存一致性协议)这个工作协议(缓存行中的两个Bit) - M M变量表示已经被CPU修改,但还未同步到主存
- E E变量由某个CPU私有,即只出现在主存和某一个工作内存中
- S S变量是共享的,即出现在主存和多个工作内存中
- I 宣布某个变量失效
工作过程
M,E变量是没有问题的,因为这始终是某个工作内存之内的事情
问题出在S变量上,变更S变量的步骤是
- 修改自己工作内存变量为M
- 同步M变量到主存
- 广播I请求,要求将某个变量标记无效
- 其它工作内存接受到I通知后,重新从主存中读取变量
这其中有个巨大的问题,那就是通知阻塞
一个CPU修改后广播失效指令,如果必须等待其他所有CPU的失效ACK确认的话,就会引起阻塞
这个阻塞对性能影响是很大的,虽然它非常短(CPU指令级别),但是频次太高(想想程序中会有多少变量变更,每一个都会产生一个阻塞)
为了解决这个阻塞问题,还有两个机制
- StoreBuffer
- 失效队列
StoreBuffer
StoreBuffer是在修改动作CPU中
它是指修改动作CPU,在变更变量后会先将修改后变量存入自己的StoreBuffer中
这样CPU无需等待同步主存,在广播失效指令后无需ACK直接往后运行
对于变量变更而言,它优先读取自己StoreBuffer中的数据(本地线程自己的操作读取永远是正确的)
失效队列
失效队列是针对其他CPU而言的
它是指CPU收到一个失效指令后会立即发出ACK,但不会立即处理它,而是放到失效队列中在将来某一个合适的时候才会处理
也就是说,对于变量变更而言,其他CPU真正应用这个变更是一个完全异步的行为
再议伪共享,可见性,有序性
到这里 可见性与有序性就很容易理解了
- 可见性
对于变量变更而言,其他CPU(其他线程)真正能应用这个变更是两个完全异步的行为
首先需要在修改动作CPU在某个合适的时候应用到主存,还要再自己的CPU在某个合适的时候应用到自己的工作内存
而在此这两个异步彻底完成前,自己的CPU是完全感知不到变量变更了.
也就是对变量变更不可见了 - 有序性
也是因为这两个异步的关系,变量变更的应用是一种几乎不可预知的行为.
在A-CPU而言,可能它应用的早,而在B-CPU而言,可能它应用的晚,这样在不同的CPU之间就会体现出时早时晚的无序现象
指令重排不是一种优化行为,而是因为不同CPU通知异步带来的现象 - 伪共享
伪共享是发生在volatile上的一种隐式性能损耗
因为volatile修饰下,是有写在读前保证立即应用变更的,但是如果几个volatile处于同一缓存行内就会出现A,B两个线程明明变更两个独立的变量,却不得不出现相互失效的情况
(解决办法是补位,下文详述)
内存栅栏
因为工作内存隔离,StoreBuffer和失效队列的关系,不同工作内存之间的几乎处于一种不可预知的状态
解决的办法就是内存栅栏
内存栅栏,其实是一种CPU自己给自己设定的一种规则
内存栅栏分为读栅栏和写栅栏
- 读栅栏 是针对其它CPU的失效队列而言的
它的意思是,如果收到这个指令,立即应用如今失效队列中的所有指令,在失效队列完成前不得再继续执行 - 写栅栏,是针对修改操作CPU的StoreBuffer而言的
它的意思是,如果收到这个指令,立即将StoreBuffer中的数据同步到主存中并发出失效指令,在同步到主存完成前不得再继续执行
所谓写在读前,连起来的意思是
修改操作CPU,在修改自己的工作内存变量后,必须立即同步到主存并发出失效指令,同步完成前不得继续执行.其它所以CPU在收到失效指令后必须先应用失效指令,从主存中重新读取后,才能继续往下执行
异步Await转同步,有没有类似?
所以使用写在读前栅栏,就可以保证变更可以立即应用
但同时写在读前是有一定的性能损耗(CPU交互之间的短暂阻塞换取)所以,不是没有代价的