mirror of
https://github.com/youthlql/JavaYouth.git
synced 2026-04-27 14:36:46 +00:00
清空
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,482 +0,0 @@
|
|||||||
# 可见性设计的硬件
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0016.png">
|
|
||||||
|
|
||||||
从硬件的级别来考虑一下可见性的问题
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**1、第一个可见性的场景:**每个处理器都有自己的寄存器(register),所以多个处理器各自运行一个线程的时候,可能导致某个变量给放到寄存器里去,接着就会导致各个线程没法看到其他处理器寄存器里的变量的值修改了,就有可能在寄存器的级别,导致变量副本的更新,无法让其他处理器看到。
|
|
||||||
|
|
||||||
**2、第二个可见性的场景:**然后一个处理器运行的线程对变量的写操作都是针对写缓冲来的(store buffer)并不是直接更新主内存,所以很可能导致一个线程更新了变量,但是仅仅是在写缓冲区里罢了,没有更新到主内存里去。这个时候,其他处理器的线程是没法读到他的写缓冲区的变量值的,所以此时就是会有可见性的问题。
|
|
||||||
|
|
||||||
**3、第三个可见性的场景:**然后即使这个时候一个处理器的线程更新了写缓冲区之后,将更新同步到了自己的高速缓存里(cache,或者是主内存),然后还把这个更新通知给了其他的处理器,但是其他处理器可能就是把这个更新放到无效队列里去,没有更新他的高速缓存。此时其他处理器的线程从高速缓存里读数据的时候,读到的还是过时的旧值。【处理器是优先从自己的高速缓存里取读取变量副本】
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
可见性发生的问题
|
|
||||||
|
|
||||||
如果要实现可见性的话,其中一个方法就是通过MESI协议,这个MESI协议实际上有很多种不同的时间,因为他不过就是一个协议罢了,具体的实现机制要靠具体底层的系统如何实现。
|
|
||||||
|
|
||||||
根据具体底层硬件的不同,MESI协议的实现是有区别的。比如说MESI协议有一种实现,就是一个处理器将另外一个处理器的高速缓存中的更新后的数据拿到自己的高速缓存中来更新一下,这样大家的缓存不就实现同步了,然后各个处理器的线程看到的数据就一样了。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# MESI-缓存一致性协议(简介)
|
|
||||||
|
|
||||||
1、为了实现MESI协议,有两个配套的专业机制要给大家说一下:flush处理器缓存、refresh处理器缓存。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- flush处理器缓存,他的意思就是把自己更新的值刷新到高速缓存里去(或者是主内存),因为必须要刷到高速缓存(或者是主内存)里,才有可能在后续通过一些特殊的机制让其他的处理器从自己的高速缓存(或者是主内存)里读取到更新的值
|
|
||||||
|
|
||||||
- 除了flush以外,他还会发送一个消息到总线(bus),通知其他处理器,某个变量的值被他给修改了
|
|
||||||
|
|
||||||
- refresh处理器缓存,他的意思就是说,处理器中的线程在读取一个变量的值的时候,如果发现其他处理器的线程更新了变量的值,必须从其他处理器的高速缓存(或者是主内存)里,读取这个最新的值,更新到自己的高速缓存中
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2、所以说,为了保证可见性,在底层是通过MESI协议、flush处理器缓存和refresh处理器缓存,这一整套机制来保障的
|
|
||||||
|
|
||||||
3、要记住,flush和refresh,这两个操作,flush是强制刷新数据到高速缓存(主内存),不要仅仅停留在写缓冲器里面;refresh,是从总线嗅探发现某个变量被修改,必须强制从其他处理器的高速缓存(或者主内存)加载变量的最新值到自己的高速缓存里去。【不同的硬件,实现可能略有不同】
|
|
||||||
|
|
||||||
4、内存屏障的使用,在底层硬件级别的原理,其实就是在执行flush和refresh,MESI协议是如何与内存屏障搭配使用的(flush、refresh)
|
|
||||||
|
|
||||||
```java
|
|
||||||
volatile boolean isRunning = true;
|
|
||||||
|
|
||||||
|
|
||||||
isRunning = false; => 写volatile变量,就会通过执行一个内存屏障,在底层会触发flush处理器缓存的操作;while(isRunning) {},读volatile变量,也会通过执行一个内存屏障,在底层触发refresh操作
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 内存屏障的相关讲解
|
|
||||||
|
|
||||||
> 上面的文章可能已经把读者搞混了,其实可见性和有序性最主要的就是内存屏障,下面来介绍下内存屏障帮读者梳理一下。
|
|
||||||
|
|
||||||
内存屏障是被插入两个CPU指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将写缓冲器的值写入高速缓存,清空无效队列,实现可见性。
|
|
||||||
|
|
||||||
举例:将写缓冲器数据写入高速缓存,能够避免不同处理器之间不能访问写缓冲器而导致的可见性问题,以及有效地避免了存储转发问题;清空无效队列保证该处理器上高速缓存中不存在旧的副本,进而拿到最新数据
|
|
||||||
|
|
||||||
## 基本内存屏障
|
|
||||||
|
|
||||||
- LoadLoad屏障: 对于这样的语句 Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
|
|
||||||
|
|
||||||
- StoreStore屏障:对于这样的语句 Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
|
|
||||||
|
|
||||||
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被执行前,保证Load1要读取的数据被读取完毕。
|
|
||||||
|
|
||||||
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
|
|
||||||
|
|
||||||
以上的四种屏障主要依据不同处理器支持的重排序(读写,读读,写写,写读)来确定的,比如某些处理器只支持写读重排序,因此只需要StoreLoad屏障
|
|
||||||
|
|
||||||
下面对上述的基本屏障进行利用,以针对不同的目的用相应的屏障。
|
|
||||||
|
|
||||||
## 可见性保障
|
|
||||||
|
|
||||||
主要分为加载屏障(Load Barrier)和存储屏障(Store Barrier)
|
|
||||||
|
|
||||||
- 加载屏障:StoreLoad屏障作为万能屏障,作用是冲刷写缓冲器,清空无效化队列,这样处理器在读取共享变量时,因为本高速缓存中的数据是无效的,因此先从主内存或其他处理器的高速缓存中读取相应变量,更新到自己的缓存中
|
|
||||||
|
|
||||||
- 存储屏障:同样使用StoreLoad屏障,作用是将写缓冲器内容写入高速缓存中,使处理器对共享变量的更新写入高速缓存或者主内存中 ,同时解决存储转发问题,使得写缓冲器中的数据不存在旧值
|
|
||||||
|
|
||||||
以上两种屏障解决可见性问题。
|
|
||||||
|
|
||||||
## 有序性保障
|
|
||||||
|
|
||||||
主要分为获取屏障(Acquire Barrier)和释放屏障(Release Barrier)
|
|
||||||
|
|
||||||
- 获取屏障:相当于LoadLoad屏障和LoadStore屏障的组合,它能禁止该屏障之前的任何读操作与该屏障之后的任何读、写操作之间进行重排序;
|
|
||||||
- 释放屏障:相当于StoreLoad屏障与StoreStore屏障的组合,它能够禁止该屏障之前的任何读、写操作与该屏障之后的任何写操作之间进行重排序
|
|
||||||
|
|
||||||
对于其实大家记住volatile修饰的字段和普通修饰的字段同样不可以重排序,因此只要存在读写、写写、写读、读读等操作,包含了volatile关键字,都会在操作指令之间插入屏障的,具体插入什么屏障可以根据对应的操作插入。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## synchronized
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
结论:
|
|
||||||
(1)原子性:加锁和释放锁,ObjectMonitor
|
|
||||||
|
|
||||||
(2)可见性:加了Load屏障和Store屏障,释放锁flush数据,加锁会refresh数据
|
|
||||||
|
|
||||||
(3)有序性:Acquire屏障和Release屏障,保证同步代码块内部的指令可以重排,但是同步代码块内部的指令和外面的指令是不能重排的
|
|
||||||
```
|
|
||||||
|
|
||||||
举个例子说明加屏障的顺序:
|
|
||||||
|
|
||||||
```java
|
|
||||||
int b = 0;
|
|
||||||
|
|
||||||
int c = 0;
|
|
||||||
|
|
||||||
synchronized(this) { -> monitorenter
|
|
||||||
|
|
||||||
//Load内存屏障
|
|
||||||
|
|
||||||
//Acquire内存屏障
|
|
||||||
|
|
||||||
int a = b;
|
|
||||||
|
|
||||||
c = 1; // synchronized代码块里面还是可能会发生指令重排
|
|
||||||
|
|
||||||
//Release内存屏障
|
|
||||||
|
|
||||||
} -> monitorexit
|
|
||||||
|
|
||||||
//Store内存屏障
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
1、java的并发技术底层很多都对应了内存屏障的使用,包括synchronized,他底层也是依托于各种不同的内存屏障来保证可见性和有序性的
|
|
||||||
|
|
||||||
2、按照可见性来划分的话,内存屏障可以分为Load屏障和Store屏障。
|
|
||||||
|
|
||||||
- Load屏障的作用是执行refresh处理器缓存的操作,说白了就是对别的处理器更新过的变量,从其他处理器的高速缓存(或者主内存)加载数据到自己的高速缓存来,确保自己看到的是最新的数据。
|
|
||||||
- Store屏障的作用是执行flush处理器缓存的操作,说白了就是把自己当前处理器更新的变量的值,都刷新到高速缓存(或者主内存)里去
|
|
||||||
|
|
||||||
+ 在monitorexit指令之后,会有一个Store屏障,让线程把自己在同步代码块里修改的变量的值都执行flush处理器缓存的操作,刷到高速缓存(或者主内存)里去;然后在monitorenter指令之后会加一个Load屏障,执行refresh处理器缓存的操作,把别的处理器修改过的最新值加载到自己高速缓存里来
|
|
||||||
+ 所以说通过Load屏障和Store屏障,就可以让synchronized保证可见性。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
3、按照有序性保障来划分的话,还可以分为Acquire屏障和Release屏障。
|
|
||||||
|
|
||||||
- 在monitorenter指令之后,Load屏障之后,会加一个Acquire屏障,这个屏障的作用是禁止读操作和读写操作之间发生指令重排序。在monitorexit指令之前,会加一个Release屏障,这个屏障的作用是禁止写操作和读写操作之间发生重排序。
|
|
||||||
- 所以说,通过 Acquire屏障和Release屏障,就可以让synchronzied保证有序性,只有synchronized内部的指令可以重排序,但是绝对不会跟外部的指令发生重排序。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## volatile
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
前面讲过,lock前缀指令相当于一个内存屏障,lock前缀指令同时保证可见性和有序性
|
|
||||||
|
|
||||||
(1)可见性:加了Load屏障和Store屏障,释放锁flush数据,加锁会refresh数据
|
|
||||||
|
|
||||||
(2)有序性:Acquire屏障和Release屏障,保证同步代码块内部的指令可以重排,但是同步代码块内部的指令和外面的指令是不能重排的
|
|
||||||
|
|
||||||
(3)不保证原子性
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
1、volatile对原子性的保证真的是非常的有限,其实主要就是32位jvm中的long/double类型变量的赋值操作是不具备原子性的,加上volatile就可以保证原子性了。但是总体上就说不保证原子性。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2、
|
|
||||||
|
|
||||||
```java
|
|
||||||
volatile boolean isRunning = true;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
线程1:
|
|
||||||
|
|
||||||
Release屏障
|
|
||||||
|
|
||||||
isRunning = false;
|
|
||||||
|
|
||||||
Store屏障
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
线程2:
|
|
||||||
|
|
||||||
Load屏障
|
|
||||||
|
|
||||||
while(isRunning) {
|
|
||||||
|
|
||||||
Acquire屏障
|
|
||||||
|
|
||||||
// 代码逻辑
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 在volatile变量写操作的前面会加入一个Release屏障,然后在之后会加入一个Store屏障,这样就可以保证volatile写跟Release屏障之前的任何读写操作都不会指令重排,然后Store屏障保证了,写完数据之后,立马会执行flush处理器缓存的操作
|
|
||||||
- 在volatile变量读操作的前面会加入一个Load屏障,这样就可以保证对这个变量的读取时,如果被别的处理器修改过了,必须得从其他处理器的高速缓存(或者主内存)中加载到自己本地高速缓存里,保证读到的是最新数据;在之后会加入一个Acquire屏障,禁止volatile读操作之后的任何读写操作会跟volatile读指令重排序
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
跟之前讲解的volatie读写内存屏障的知识对比一下,其实你看一下是类似的意思的。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 强调
|
|
||||||
|
|
||||||
- 其实不要对内存屏障这个东西太较真,因为说句实话,不同版本的JVM,不同的底层硬件,都可能会导致加的内存屏障有一些区别,所以这个本来就没完全一致的。你只要知道内存屏障是如何保证volatile的可见性和有序性的就可以了
|
|
||||||
- 看各种并发相关的书和文章,对内存屏障到底是加的什么屏障,莫衷一是,没有任何一个官方权威的说法,因为这个内存屏障太底层了,底层到了涉及到了硬件,硬件不同对内存屏障的实现是不一样的
|
|
||||||
- 内存屏障这个东西,大概来说,其实就是大概的给你说一下这个意思,尤其是Release屏障,Store屏障和Load屏障还好理解一些,比较简单,Acqurie屏障,莫衷一是,我也没法给你一个官方的定论
|
|
||||||
|
|
||||||
- 如果你一定 要了解清除,到底加的准确的屏障是什么?到底是如何跟上下的指令避免重排的,你自己去研究吧。【我也看过很多的资料,做过很多的研究,硬件对这个东西的实现和承诺,莫衷一是,没有标准和官方定论。-----这句话是某BAT大佬说的】
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
内存屏障对应的底层的一些基本的硬件级别的原理,也都讲清楚了
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# MESI-缓存一致性协议(进阶)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## MESI-初步
|
|
||||||
|
|
||||||
1、处理器高速缓存的底层数据结构实际是一个拉链散列表的结构,就是有很多个bucket,每个bucket挂了很多的cache entry,每个cache entry由三个部分组成:tag、cache line和flag,其中的cache line【缓存行】就是缓存的数据。tag指向了这个缓存数据在主内存中的数据的地址,flag标识了缓存行的状态,另外要注意的一点是,cache line中可以包含多个变量的值
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2、处理器会操作一些变量,怎么在高速缓存里定位到这个变量呢?
|
|
||||||
|
|
||||||
- 那么处理器在读写高速缓存的时候,实际上会根据变量名执行一个内存地址解码的操作,解析出来3个东西,index、tag和offset。index用于定位到拉链散列表中的某个bucket,tag是用于定位cache entry,offset是用于定位一个变量在cache line中的位置
|
|
||||||
- 如果说可以成功定位到一个高速缓存中的数据,而且flag还标志着有效,则缓存命中;否则不满足上述条件,就是缓存未命中。如果是读数据未命中的话,会从主内存重新加载数据到高速缓存中,现在处理器一般都有三级高速缓存,L1、L2、L3,越靠前面的缓存读写速度越快
|
|
||||||
|
|
||||||
3、因为有高速缓存的存在,所以就导致各个处理器可能对一个变量会在自己的高速缓存里有自己的副本,这样一个处理器修改了变量值,别的处理器是看不到的,所以就是为了这个问题引入了缓存一致性协议(MESI协议)
|
|
||||||
|
|
||||||
4、MESI协议规定:对一个共享变量的读操作可以是多个处理器并发执行的,但是如果是对一个共享变量的写操作,只有一个处理器可以执行,其实也会通过排他锁的机制保证就一个处理器能写
|
|
||||||
|
|
||||||
之前说过那个cache entry的flag代表了缓存数据的状态,MESI协议中划分为:
|
|
||||||
|
|
||||||
- invalid:无效的,标记为I,这个意思就是当前cache entry无效,里面的数据不能使用
|
|
||||||
- shared:共享的,标记为S,这个意思是当前cache entry有效,而且里面的数据在各个处理器中都有各自的副本,但是这些副本的值跟主内存的值是一样的,各个处理器就是并发的在读而已
|
|
||||||
- exclusive:独占的,标记为E,这个意思就是当前处理器对这个数据独占了,只有他可以有这个副本,其他的处理器都不能包含这个副本
|
|
||||||
- modified:修改过的,标记为M,只能有一个处理器对共享数据更新,所以只有更新数据的处理器的cache entry,才是exclusive状态,表明当前线程更新了这个数据,这个副本的数据跟主内存是不一样的
|
|
||||||
|
|
||||||
MESI协议规定了一组消息,就说各个处理器在操作内存数据的时候,都会往总线发送消息,而且各个处理器还会不停的从总线嗅探最新的消息,通过这个总线的消息传递来保证各个处理器的协作
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**下面来详细的图解MESI协议的工作原理:**
|
|
||||||
|
|
||||||
1、处理器0读取某个变量的数据时,首先会根据index、tag和offset从高速缓存的拉链散列表读取数据,如果发现状态为I,也就是无效的,此时就会发送read消息到总线
|
|
||||||
|
|
||||||
2、接着主内存会返回对应的数据给处理器0,处理器0就会把数据放到高速缓存里,同时cache entry的flag状态是S
|
|
||||||
|
|
||||||
3、在处理器0对一个数据进行更新的时候,如果数据状态是S,则此时就需要发送一个invalidate消息到总线,尝试让其他的处理器的高速缓存的cache entry全部变为I,以获得数据的独占锁。
|
|
||||||
|
|
||||||
4、其他的处理器1会从总线嗅探到invalidate消息,此时就会把自己的cache entry设置为I,也就是过期掉自己本地的缓存,然后就是返回invalidate ack消息到总线,传递回处理器0,处理器0必须收到所有处理器返回的ack消息
|
|
||||||
|
|
||||||
5、接着处理器0就会将cache entry先设置为E,独占这条数据,在独占期间,别的处理器就不能修改数据了,因为别的处理器此时发出invalidate消息,这个处理器0是不会返回invalidate ack消息的,除非他先修改完再说
|
|
||||||
|
|
||||||
6、接着处理器0就是修改这条数据,接着将数据设置为M,也有可能是把数据此时强制写回到主内存中,具体看底层硬件实现
|
|
||||||
|
|
||||||
7、然后其他处理器此时这条数据的状态都是I了,那如果要读的话,全部都需要重新发送read消息,从主内存(或者是其他处理器)来加载,这个具体怎么实现要看底层的硬件了,都有可能的
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0017.jpg">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## MESI-优化
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0018.png">
|
|
||||||
|
|
||||||
MESI协议如果每次写数据的时候都要发送invalidate消息等待所有处理器返回ack,然后获取独占锁后才能写数据,那可能就会导致性能很差了,因为这个对共享变量的写操作,实际上在硬件级别变成串行的了。所以为了解决这个问题,硬件层面引入了写缓冲器和无效队列
|
|
||||||
|
|
||||||
1、
|
|
||||||
|
|
||||||
写缓冲器的作用是,一个处理器写数据的时候,直接把数据写入缓冲器,同时发送invalidate消息,然后就认为写操作完成了,接着就干别的事儿了,不会阻塞在这里。接着这个处理器如果之后收到其他处理器的ack消息之后才会把写缓冲器中的写结果拿出来,通过对cache entry设置为E加独占锁,同时修改数据,然后设置为M。
|
|
||||||
|
|
||||||
其实写缓冲器的作用,就是处理器写数据的时候直接写入缓冲器,不需要同步阻塞等待其他处理器的invalidate ack返回,这就大大提升了硬件层面的执行效率了
|
|
||||||
|
|
||||||
包括查询数据的时候,会先从写缓冲器里查,因为有可能刚修改的值在这里,然后才会从高速缓存里查,这个就是存储转发
|
|
||||||
|
|
||||||
2、
|
|
||||||
|
|
||||||
引入无效队列,就是说其他处理器在接收到了invalidate消息之后,不需要立马过期本地缓存,直接把消息放入无效队列,就返回ack给那个写处理器了,这就进一步加速了性能,然后之后从无效队列里取出来消息,过期本地缓存即可
|
|
||||||
|
|
||||||
通过引入写缓冲器和无效队列,一个处理器要写数据的话,这个性能其实很高的,他直接写数据到写缓冲器,发送一个validate消息出去,就立马返回,执行别的操作了;其他处理器收到invalidate消息之后直接放入无效队列,立马就返回invalidate ack
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 硬件层面的MESI协议为何会引发有序性和可见性的问题?
|
|
||||||
|
|
||||||
MESI协议在硬件层面的原理其实大家都已经了解的很清晰了。
|
|
||||||
|
|
||||||
讲了这么多,再来看一下MESI-协议为何会引发可见性和有序性的问题
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 可见性:写缓冲器和无效队列导致的,写数据不一定立马写入自己的高速缓存(或者主内存),是因为可能写入了写缓冲器;读数据不一定立马从别人的高速缓存(或者主内存)刷新最新的值过来,invalidate消息在无效队列里面
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 有序性:
|
|
||||||
|
|
||||||
简单的举两个例子
|
|
||||||
|
|
||||||
(1)StoreLoad重排序
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```java
|
|
||||||
int a = 0;
|
|
||||||
|
|
||||||
int c = 1;
|
|
||||||
|
|
||||||
线程1:
|
|
||||||
|
|
||||||
a = 1; //Store操作
|
|
||||||
|
|
||||||
int b = c; //因为要读C的值,所以这个是load操作
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
这个很简单吧,第一个是Store,第二个是Load。但是可能处理器对store操作先写入了写缓冲器,此时这个写操作相当于没执行。然后就执行了第二行代码,第二行代码的b是局部变量,那这个操作等于是读取c的值,是load操作。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 第一个store操作写到写缓冲器里去了,导致其他的线程是读不到的,看不到的,好像是第一个写操作没执行一样;第二个load操作成功的执行了
|
|
||||||
|
|
||||||
- 这就导致好像第二行代码的load先执行了,第一行代码的store后执行
|
|
||||||
|
|
||||||
StoreLoad重排,明明Store先执行,Load后执行;看起来好像Load先执行,Store后执行
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(2)StoreStore重排序
|
|
||||||
|
|
||||||
```java
|
|
||||||
resource = loadResource();
|
|
||||||
|
|
||||||
loaded = true;
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- 两个写操作,但是可能第一个写操作写入了写缓冲器,然后第二个写操作是直接修改的高速缓存【可能此时第二个数据的状态是m】,这个时候不就导致了两个写操作顺序颠倒了?诸如此类的重排序,都可能会因为MESI的机制发生
|
|
||||||
- 可见性问题也是一样的,写入写缓冲器之后,没刷入高速缓存,导致别人读不到;读数据的时候,可能invalidate消息在无效队列里,导致没法立马感知到过期的缓存,立马加载最新的数据
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 内存屏障在硬件层面的实现原理
|
|
||||||
|
|
||||||
1、可见性问题:
|
|
||||||
|
|
||||||
> Store屏障 + Load屏障
|
|
||||||
|
|
||||||
如果加了Store屏障之后,就会强制性要求你对一个写操作必须阻塞等待到其他的处理器返回invalidate ack之后,对数据加锁,然后修改数据到高速缓存中,必须在写数据之后,强制执行flush操作。
|
|
||||||
|
|
||||||
他的效果,要求一个写操作必须刷到高速缓存(或者主内存),不能停留在写缓冲里
|
|
||||||
|
|
||||||
如果加了Load屏障之后,在从高速缓存中读取数据的时候,如果发现无效队列里有一个invalidate消息,此时会立马强制根据那个invalidate消息把自己本地高速缓存的数据,设置为I(过期),然后就可以强制从其他处理器的高速缓存中加载最新的值了。这就是refresh操作
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2、有序性问题
|
|
||||||
|
|
||||||
> 内存屏障,Acquire屏障,Release屏障,但是都是由基础的StoreStore屏障,StoreLoad屏障,可以避免指令重排序的效果
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
StoreStore屏障,会强制让写数据的操作全部按照顺序写入写缓冲器里,他不会让你第一个写到写缓冲器里去,第二个写直接修改高速缓存了。
|
|
||||||
|
|
||||||
```java
|
|
||||||
resource = loadResource();
|
|
||||||
|
|
||||||
StoreStore屏障
|
|
||||||
|
|
||||||
loaded = true;
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
StoreLoad屏障,他会强制先将写缓冲器里的数据写入高速缓存中,接着读数据的时候强制清空无效队列,对里面的validate消息全部过期掉高速缓存中的条目,然后强制从主内存里重新加载数据
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
a = 1; // 强制要求必须直接写入高速缓存,不能停留在写缓冲器里,清空写缓冲器里的这条数据 store
|
|
||||||
|
|
||||||
int b = c; //load
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> java内存模型是对底层的硬件模型,cpu缓存模型,做了大幅度的简化,提供一个抽象和统一的模型给java程序员易于理解,很多时候如果要理解一些技术的本质,还是要深入到底层去研究的。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 原子操作的实现原理
|
|
||||||
|
|
||||||
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。让我们一起来聊一聊在Intel处理器和Java里是如何实现原子操作的。
|
|
||||||
|
|
||||||
## 相关术语
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0019.png">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 处理器如何实现原子操作
|
|
||||||
|
|
||||||
32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操 作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
|
|
||||||
|
|
||||||
### 使用总线锁保证原子性
|
|
||||||
|
|
||||||
**第一个机制是通过总线锁保证原子性。**如果多个处理器同时对共享变量进行读改写操作 (i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操 作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行 两次i++操作,我们期望的结果是3,但是有可能结果是2,如图所示。
|
|
||||||
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/youthlql/lql_img/Java_concurrency/Source_code/Second_stage/0020.png">
|
|
||||||
|
|
||||||
原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入 系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享 变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
|
|
||||||
|
|
||||||
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 使用缓存锁保证原子性
|
|
||||||
|
|
||||||
**第二个机制是通过缓存锁定来保证原子性**。在同一时刻,我们只需保证对某个内存地址 的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处 理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
|
|
||||||
|
|
||||||
频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存 行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如上图所示的例子中,当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。
|
|
||||||
|
|
||||||
**但是有两种情况下处理器不会使用缓存锁定。**
|
|
||||||
|
|
||||||
第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
|
|
||||||
|
|
||||||
第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其他一些操作数和逻辑指令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Java如何实现原子操作
|
|
||||||
|
|
||||||
(1)使用循环CAS实现原子操作
|
|
||||||
|
|
||||||
(2)使用锁机制实现原子操作
|
|
||||||
|
|
||||||
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数,在linux上使用`mutex`互斥锁,最底层实现依赖于`futex`,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了`synchronized`关键字但**运行时并没有多线程竞争,或两个线程接近于交替执行的情况**,使用传统锁机制无疑效率是会比较低的。
|
|
||||||
|
|
||||||
futex由一个内核层的队列和一个用户空间层的atomic integer构成。当获得锁时,尝试cas更改integer,如果integer原始值是0,则修改成功,该线程获得锁,否则就将当期线程放入到 wait queue中(即操作系统的等待队列)。【及其类似于AQS的设计思想,可能AQS就参考了futex的思想】
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user