一、硬件基础
主内存执行一次读、写操作所需的时间可能足够处理器执行上百条的指令,为了弥补处理器与主内存处理能力之间的鸿沟,硬件设计者在主内存与处理器之间引入了高速缓存(Cache)
1、高速缓存是一种存取速率远比主内存大,容量远比主内存小的存储部件,每个处理器都有其高速缓存。引入高速缓存之后,处理器在执行内存读、写操作的时候并不直接与主内存打交道,而是通过高速缓存进行。
高速缓存相当于一个拉链散列表,它包含若干桶,每个桶又可以包含若干个缓存条目。
2、缓存条目可以进一步划分为 Tag、Data Block 以及 Flag 这三个部分:
-
Data Block:缓存行,用于存储从内存中读取或者准备写往内存的数据
-
Tag:与缓存行中数据相对应的内存地址,相当于缓存条目的编号
-
Flag:相应缓存行的状态信息
3、缓存未命中具体包括读未命中和写未命中,当读未命中时,处理器所需要读取的数据会从主内存加载并存入相应的缓存行中
二、缓存一致性协议
多个线程并发访问同一个共享变量的时候,这些现成的执行处理器上的高速缓存各自都会保留一份该共享变量的副本,这就带来一个新的问题,一个处理器对其副本进行更新之后,其他处理器如何 “察觉” 到该更新并做出适当反应,以确保这些处理器后续读取该共享变量时能够读取到这个更新。
缓存一致性问题本质上就是防止读脏数据和丢失更新的问题。
1、MESI 缓存一致性协议对内存数据访问的控制类似于读写锁,它使得针对同一地址的读内存操作是并发的,而针对同一地址的写内存操作是独占的。
2、MESI 协议中一个缓存条目的 Flag 值可能有以下 4 种可能:
- Invalid :无效的,记为 I,该缓存行中不包含任何内存地址对应的有效副本数据,是缓存条目的初始状态。
- Shared:共享的,记为 S,一个缓存条目的状态如果为 S,那么其他处理器上 Tag 值和 当前处理器 Tag 值相同的缓存条目状态也为 S。该状态下缓存条目的数据与主内存包含数据一致。
- Exclusive:独占的,记为 E,该缓存行以独占方式保留副本数据,其他处理器的高速缓存中都不保留该副本数据。该状态下缓存条目的数据与主内存包含数据一致。
- Modified:更改过的,记为 M,缓存行中包含更新后的结果数据,任意时刻,只能有一个缓存条目上处于该状态。该状态下缓存条目的数据与主内存包含数据不一致。
3、请求消息: MESI 协议定义了一组消息用于协调各个处理器的读、写内存操作,处理器在执行内存读、写操作时在必要的情况下会往总线发送特定的请求消息,同时每个处理器还嗅探(也称拦截)总线中由其他处理器发出的请求下次,并在一定条件下往总线中回复相应的响应信息:
- Read(请求):通知其他处理器、主内存,当前处理器要准备读取某个数据。
- Read Response(响应):该消息包含被请求读取的数据,可能有主内存或其他处理器提供。
- Invalidate(请求):通知其他处理器将缓存中指定内存地址对应的缓存条目状态设置为 I,即通知删除副本数据(逻辑上删除,实际是修改 Flag 值)。
- Invalidate Acknowledge(响应):接受到 Invalidate 消息的处理器必须回复该消息,以表示删除了副本数据。
- Read Invalidate(请求):用于通知其他处理器,当前处理器准备更新一个数据(Read-Modify-Write:读后写更新),并请求其他处理器删除副本数据,接收到该消息的处理器必须回复 Read Response 和 Invalidate Acknowledge 消息。
- Writeback(请求):该消息包含需要写入内存的数据及其对应内存地址。
下面看看使用 MESI 协议的处理器是如何实现内存读、写操作的,假设内存地址 A 上的数据 S 是处理器 Process 0 和处理器 Process 1 可能共享的数据。
三、在 Process 0 上读取数据 S 的实现:
P0 会根据内存地址 A 找到对应的缓存条目,并读取其 Tag 值和 Flag 值:
-
如果状态为 M、S、E,直接读取数据,无往总线发送任何消息。
-
如果状态为 I,表示该处理器的缓存中并没有 S 的有效副本数据,此时 P0 需要往总线发送 Read 消息,读取 A 对应的数据,其他处理器 P1 (或主内存)需要回复 Read Response 以提供相应的数据。
P0 收到响应消息时,会将其携带的数据存入缓存行,并更新状态为 S。
P0 收到响应消息到底来自于其他处理器还是主内存:
- 其他处理器:P1 会嗅探到总线上其他处理器发送的 Read 消息,去除需要读取数据的内存地址,查找对应缓存条目,如果状态是 S 或 E,会将数据塞入 Read Response 里面。与此同时,如果发现是 M,会先将数据写入主内存,再讲数据塞入 Read Response 里面
- 主内存:如果状态是 I,那么 Read Response 就来自主内存
由此可见,在 P0 读取内存的时候,即使 P1 对同一数据进行更新,并且处于高速缓存中,也不会导致 P0 读取到一个就值。
四、在 Process 0 上往地址 A 写数据的实现:
任何一个处理器执行一个处理器执行内存写操作时必须拥有相应数据的所有权。P0 会先根据内存地址 A 找到相应的缓存条目
-
如果状态为 E 或者 M,说明该处理器已经拥有所有权,此时可以直接写入数据,并更新状态为 M
-
如果状态不为 E 或者 M,处理器要往总线发送 Invalidate 消息,其他处理器收到消息之后将相应缓存条目 Flag 值置为 I,并且其他全部处理器回复 Invalidate Acknowledge消息之后更新状态为 E,获取了数据所有权,再将数据写入相应的缓存行中,再次更新状态为 M
由此可见,Invalidate 消息和 Invalidate Acknowledge 下次使得针对同一内存地址的写操作,在任意一个时刻只能有一个数据处理,从而避免了多个处理器同时更新同一数据可能导致的数据不一致的问题。
五、写缓冲器和无效队列
处理器执行写操作的时候,必须等待其他所有处理器将其缓存中的副本数据删除并接收到 Invalidate Acknowledge /Read Response 消息之后才能将数据写入高速缓存,为了避免和减少这种等待造成的写延迟,硬件设计器引入了写缓冲器和无效化队列。
1、写缓冲器是处理器内部一个容量比高速缓存还小的私有高速存储部件。处理器在执行写操作的时候
-
如果响应缓存条目状态为 S,那么处理器会将数据写入写缓冲器,并发送 Invalidate 消息。
-
如果状态为 I,成为写操作为写未命中,那么处理器会将数据写入写缓冲器中,并发送 Read Invalidate 消息。
最后等到其他处理器回复 Invalidate Acknowledge/Read Response 时,该处理器会将写缓冲器中数据写入到相应缓存行,此时写操作对于其他处理器而言才算是完成的。
2、无效队列,处理器接收到 Invalidate 之后,并不删除副本数据,而是将消息存入无效队列之后就回复 Invalidate Acknowledge 消息,从而减少写操作执行处理器所需的等待时间。
写缓冲器和无效化队列的引入又会带来一些新的问题 - 内存重排序和可见性问题。
3、基本内存屏障:是一类指令的称呼,这类指令的作用是禁止该指令左侧的任何 X 操作与该指令右侧的任何 Y 操作之间进行重排序,从而保证所有 X 操作都先于 Y 操作被提交(从内存操作作用到主内存或缓存)。
4、存储转发:一个处理器在更新一个变量之后,写入写缓冲器,未更新到缓存,紧接着又读取该变量的值,如果从缓存行读,那么读取到的就是旧值,新值在写缓冲器里,这个时候将从缓存读取数据转为直接从写缓冲器中读取数据来实现读操作的技术被称为存储转发。
六、Java 同步机制与内存屏障
Java 虚拟机对 synchronized、volatile 和 final 关键字的实现就是借助内存屏障。
以下列出常用内存屏障:
- 获取屏障:禁止该屏障之前的读操作和屏障之后的读、写操作进行重排序(读 -> 获取屏障 -> 读写)
- 释放屏障:禁止该屏障之前的读、写操作和屏障之后的写操作进行重排序(读写 -> 释放屏障 -> 写)
七、volatile 关键字的实现
1、有序性
Java 虚拟机在 volatile 变量读操作之后加入了获取屏障,使得读操作先于该屏障之后的任何读写操作。
写操作之前插入释放屏障,使得该屏障之前的任何读写操作都在屏障之前执行。
写线程和读线程通过各自使用释放屏障和获取屏障来保证了有序性。
2、可见性
可见性问题分析:
- 一个处理器更新了一个共享变量,停留在写缓冲器上,其他处理器读取不了处理器私有的写缓冲器,而从缓存读取到的是旧值,为了使更新对其他处理器可见,需要存储屏障冲刷写缓冲器,保证可见性。
- 处理器在读操作时,如果没有根据无效队列中的内容将该处理器上的高速缓存中的副本数据删除,那么可能导致该处理器读取到的数据是过时的旧数据,为了使处理器能读到最新值,需要加载屏障根据无效队列内容所指定的内存地址,将高速缓存中缓存条目标记为 I,然后 发送 Read 消息,以将其他处理器对共享变量的更新同步到该处理器的高速缓存中。
2-1、Java 虚拟机会在 volatile 变量写操作之后插入一个 StoreLoad 屏障,作用如下:
- 禁止屏障之前的写操作和屏障之后的读操作重排序
- 充当存储屏障:清空写缓冲器,使其结果数据到达高速缓存
- 充当加载屏障:假设 P0 在 t1 时刻更新的数据,随后 t2 时刻又要读取数据,由于存储转发技术,更新的操作可以停留在写缓冲器上,没有更新到缓存上,这样另外一个处理器 P1 无法从 P0 身上读取到最新的值。Java 虚拟机在 Volatile 的变量写操作之后加入 StoreLoad 屏障,可以冲刷写缓冲器和无效队列,从而实现其他处理器对 volatile 变量所做的更新能被同步到当前处理器的读线程执行上。
2-2、java 虚拟机会在 volatile 变量读操作之前加入加载屏障,通过清空无效队列,来使得其后的读操作有机会读取到其他处理器对共享变量所做的更新。
写操作和读操作通过存储屏障和加载屏障保证了可见性。
八、synchronized 关键字的实现
Java 虚拟机在 monitorenter 字节码指令对应的机器码指令之后,临界区开始之前插入获取屏障,在monitorexit 字节码指令对应的机器码指令之前,临界区之后加入释放屏障,确保了临界区的读写操作不会被重排序到临界区之外,这一点再加上锁的排他性,保证了临界区的操作成为了一个原子操作。
Java 虚拟机也会在 monitorexit 之后插入一个 StoreLoad 屏障,充当了存储屏障,保证释放锁之前的操作可以到达高速缓存,并消除了存储转发的作用。另外禁止了monitorexit 对应的指令与他同步块的 monitorenter 对应的指令重排序,保证了 monitorenter 和 monitorexit 总是成对的。