java多线程底层实现原理

为什么要写这个

之前做面试题关于volatile的一直错 好吧是我太菜了 然后总有一个疑惑为什么多个线程更新一个值 会不成功呢? 加上volatile就可以了?? 于是就看完多线程编程指南相关章节 这下总算是弄明白了 这篇博客算是读书笔记和自己的一点理解的混合体 如有错误 欢迎指正

多线程编程的硬件基础

高速缓存是一种存取速率远比主内存大而容量远比主内存小的存储部件 每个处理器都有其高速缓存

引入高速缓存之后 处理器在执行内存读,写操作的时候并不直接与主内存打交道 而是通过高速缓存进行的
变量名相当于主内存地址 变量值则相当于相应内存空间所存储的数据的副本 变量值会在不同处理器的缓存中 也就是说高速缓存相当于为程序所访问的每个变量保留了一份相应内存空间所存储数据的副本

高速缓存相当于一个有硬件实现的容量极小的散列表(Hash Table) 其键(Key)是一个内存地址 其值(Value)是内存数据的副本或者准备写入内存的数据 () 那么多个处理器的缓冲是如何写入内存的

DataBlock也被称为缓存行 它是高速缓存与主内存之间的数据交换最小单元(怎么交换) 用于存储从内存中读取或者准备写往内存的数据 Tag则包含了与缓存行中数据相应的内存地址的部分信息

java多线程底层实现原理_第1张图片一个缓存行可以存储若干变量的值 而多个变量的值则可能存储在同一个缓存行之中

内存访问通过解码内存地址 直接访问高速缓冲

处理器在执行内存访问操作时会将相应的内存地址解码 内存地址的解码包括tag,index以及offset这三部分数据 index相当于桶编号 它可以用来定位内存地址对应的桶 一个桶可以包含多个缓存条目 tag相当于缓存条目的相对编号 其作用在于用来与同一个桶中的各个缓存条目中的Tag部分进行比较 以定位一个具体的缓存条目 一个缓存条目中的缓存行可以用来存储多个变量 offset是缓存行内的位置偏移 其作用在于确定一个变量在一个缓存行中的存储起始位置

高速缓存子系统负责找到相应的缓存行并且缓存行所在的缓存条目的flag表示相应缓存条目是有效的 那么我们就称相应的内存操作产生了缓存命中

当读未命中时 即在高速缓存中未找到 处理器所需读取的数据会从主内存中加载并被存入相应的缓存行之中 这个过程会导致处理器停顿(Stall)而不能执行其他指令 因此从性能的角度上来看应该要尽可能地减少缓存未命中

但是缓存为命中不可避免 因为缓存的容量太小 统一缓存行在不同时刻存储的是不同的一段数据

现代处理器一般具有多个层次的高速缓存 相应的高速缓存被称为一级缓存(L1 Cache)
二级缓存(L2 Cache) 三级缓存(L3 cache)

  • 一级缓存

一级缓存很可能直接被集成在处理的内核(Core)里 因此访问的效率非常高
一级缓存分为两个部分 其中一部分用于存储指令 另一部分用于存储数据

距离处理器越近的缓存 其存取的速率越快 制造成本越高 因此容量也越小

缓存一致性

多个线程并发访问同一个共享变量的时候 这些线程的执行处理器上的高速缓存各自都会保留一份该共享变量的副本 那么一个处理器对其副本数据进行更新之后 其他处理器如何 察觉到该更新并作出适当反应 以确保这些处理器后续读取该共性变量时能够读取到这个更新 为了解决这个问题 处理器之间需要一种通信机制 缓存一致性协议(Cache Coherence Protocol)

MESI协议是一种广为使用的缓存一致性协议 x86处理器所使用的缓存一致性协议就是基于MESI协议的 MESI协议对 内存数据访问的控制类似于读写锁 它使得针对同一地址的读内存操作是并发的 针对同一地址的写内存操作是独占的 即针对同一内存地址进行的写操作在任意一个时刻只能够由一个处理器执行

如何保证缓存一致性

为了保障数据的一致性 MESI将缓存条目的状态分为Modified,Exclusive,Shared和Invalid这4种
并在此基础上定义了一组消息 用于协调各个护理期的读,写内存操作

  • Invalid 无效的 相应的缓存行中不包含任何内存地址对应的有效副本数据 该状态是缓存条目的初始状态
  • Shared 共享的 相应的缓存行中包含内存地址对应的副本数据 并且其他处理器也一样
  • Exclusive 独占的 和上面类似 只不过该缓存行以独占的方式保留了相应内存地址的副本数据
  • Modified 更改过的 相应缓存行包含对相应内存地址所做的更新结果数据 MESI协议中任意一个时刻只能过有一个处理器对同一内存地址对应的数据进行更新 所以在任意时刻只能有一个缓存条目处于该状态

MESI协议定义了一组消息(Message)
多个处理器 处理同一个数据 时 是需要通知其他处理器 这条数据我处理了 你别抢我的

多个处理器间如何进行交流

处理器在执行内存读 写操作时在必要的情况下会往总线(Bus)中发送特定的请求消息
并且每个处理还嗅探(也称拦截)总线中其他处理器发送出的请求信息并在某个条件下往总线中回复相应的相应信息 这类似与java web中客户端向服务端发送请求 服务端接受请求并作出响应

java多线程底层实现原理_第2张图片

消息名 消息类型 描述
Read 请求 通知其他处理器 主内存当前处理器准备读取某个数据 该消息包含待读取数据的内存地址
Read Response 响应 该消息包含被请求读取的数据 这个消息可能是主内存提供的也有可能是其他高速缓存提供的
Invalidate 请求 通知其他处理器将其高速缓存中指定内存地址对应的缓存条目状态设置为Inval(无效的)
Invalidate Acknowledge 响应 对应上一个请求 表示删除了其高速缓存上的相应副本数据
Read Invalidate 请求 其作用是由read消息和Invalidate消息组合而成的复合消息 其作用在于通知其他处理器当前处理器准备更新 一个数据(原数据删除)
Writeback 请求 该消息包含需要写入内存的数据及其对应的内存地址

MESI协议的处理器是如何实现内存读操作

处理器 1 读取数据S 处理器 1会根据地址A找到对应的缓存条目 如果该缓存条目的Tag非Invalid 那么处理器 1 就直接从向应的缓存行中读取地址 A对应的值 也就是说 找到了并且存在值 否则 该处理器的高速缓存中并不包含S的有效副本数据 这时 处理器1 就会忘总线发送Read消息 读取内存地址A 对应的数据 其他处理器就必须回复Read Response消息
处理器 1接受到响应后 会将其中携带的数据 存入相应的缓存行并将相应缓存条目的状态更新为S
其他处理器拦截到Read消息 时 会从该消息中取出待读取的内存地址 然后根据地址在高速缓存中查找对应的缓存条目 如果找到并且存在 即该状态不为I(无效的) 其他处理器会构造相应的Read Response消息并将相应缓存行所存储的整个数据 不单单指数据S 放到消息中

如果状态为M修改的 其他处理器 可能在往总线发送Read Response消息前 将数据写入内存 Read Response消息发送后 相应缓存条目的状态会被更新为 S

如果状态为I 则从内存中获取

综上所述 在处理器 1 读取内存的时候 即便处理器 2 对数据进行了修改 根据MESI协议 处理器1也能实时的获取到值

MESI协议的处理器是如何实现内存写操作

任何一个处理器执行内存写操作时必须拥有相应的数据的所有权
处理器1 会先根据内存地址A 到相应的缓存条目 如果状态为E或者M 则说明处理器拥有该数据的所有权 此时该处理器可以直接将数据写入相应的缓存行并将相应缓存条目的状态更新为M

否则(缓存条目的状态不为E或者M ) 该处理器需要往总线发送Invalidate 消息 删除其他处理中的数据 从而获得数据的所有权 其他处理器接收到Invalidate消息后会将相应的缓存条目状态更新为I 并相应Invalidate Acknowledge消息 发送Invalidate消息的处理器(即内存写操作的执行处理器) 必须在接受到 其他所有处理器所回复的Invalidate Acknowledge消息后再将数据更新到相应的缓存行之中

如果处理器1 所找到的缓存条目的状态若为I 表示该处理器不包含地址A 对应的有效副本数据 此时处理器 1 需要往总线发送Read Invalidate消息 处理器 1 在接收到Read Response消息(从其他处理器缓存中获取数据)以及其他 所有处理器所回复的Invalidate Acknowledge 消息之后 会将相应缓存条目的状态更新为E 这表示该处理器已经获得相应数据 的所有权 处理器 1 便可以往相应的缓存行中写入数据了 并将相应缓存条目的状态更新为M

硬件缓冲区:写缓冲器与无效化队列

MESI 协议解决了缓存一致性问题 但是处理器执行写操作时会发生延迟(处理器执行写操作时 必须等待其他所有处理器将其高速缓存中的相应副本数据删除 并接受到这些处理器所回复的Invalidate Acknowledge/Read Response消息后才能将数据写入高速缓存)

写缓冲器

是处理器内部的一个容量比高速缓存还小的私有高速存储部件
每个处理器上的写缓冲器对其他写缓冲器是不可见的

写缓存器的作用 引入后的区别

写缓冲器的引入使得处理器在执行写操作的时候可以不等待 Invalidate Acknowledge消息 从而减少了写操作的延时 这将时写操作的执行处理器在其他处理器回复Invalidate Acknowledge/Read Response 消息这段时间内能过执行其他指令 从而提高处理器的指令执行效率

也就说 之前是必须要其他处理器回复Invalidate Acknowledge/Read Response 消息后才能往后跑
现在可以在等待回复消息的这段时间做其他事情 当处理器接受到其他处理器所回复的Invalidate Acknowledge消息后 处理器会将写缓冲器中针对相应地址的写操作的结果 写入相应的缓冲行

无效化队列

引入无效化队列 之后 处理器在收到Invalidate消息之后并不删除消息中指定地址对应的副本数据 而是将消息存入无效化队列之后就回复Invalidate Acknowledge消息 从而减少了写操作执行处理器的等待时间

存储转发

如果一个处理器在更新一个变量之后紧接着又读取该变量的值的时候 由于该处理器先前对该变量的更新结果可能还停留在写缓冲器上 所以再次读取后可能还是旧值

为了避免这种情况 处理器在执行读操作的时候会根据相应的内存地址查询写缓冲器 也就是说写缓冲器如果存在相应的条目 就返回 否则 处理器从高速缓存中读取数据

内存重排序

  • StoreLoad重排序
    当两个线程并发访问同一个变量时
    处理器A 处理器B 访问共享变量X=0,Y=0;
A B
X=1 //1 Y=1 //3
p1=Y //2
p2=X //4

当发生线程安全的时候 A执行到2时 B此时执行到4 但是行1更新的值1可能这时还在写缓冲器中 此时B无法读取到更新后的值 因为 每个处理器上的写缓冲器对其他写缓冲器是不可见的 那么对于处理器B来说

行1就像未执行 此时p2的值还是0 此时的效果就和 先执行2再执行1一样

也就是说此时写缓冲器导致了行1被重排序到行2 正式点此时写缓冲器导致了1(写操作)被重新排序到了2(读操作) 简称StoreLoad重排序

  • 写缓冲器还可能导致StoreStore重排序
A B
X=1 //1
p1=true //2
while(!p1) continue;//3
print(X) //4

假设 假设A B的缓存中都有对应的副本 除了处理A中没有X的副本

当A执行 行1时会将值存入写缓冲器尽管相应缓冲行的状态值为I
执行到行2时 true值会成功写入缓存行

处理器B执行到行3时此时p1的值为ture 继续向下执行 打印X但是X的值可能还在A处理器的写缓冲器中所以 此时打印的值可能还是初始值0
那么对于处理器1来说 按照1 2的执行顺序和按照2 1的顺序结果一致
好像行2 比行1先执行 即 行1被重排序到行2 因为他们都是写操作所以
StoreStore重排序

  • LoadLoad重排序

处理器在接受无效化信息时 将无效化信息放置队列中 并回复Invalidate Acknowledge消息

当处理器 获取某个数据时 可能无效化消息还停留在队列中 因此处理器从其高速缓存中读取的数据的值仍然是其初始值

也就说处理器在执行内存读取操作前如果没有根据无效化队列中的内容将该处理器上的高速缓存中的相关副本数据删除 那么就可能导致该处理器读到的数据是过时的数据

解决可见性产生的问题

为了使一个处理器上运行的线程对共享变量所做的更新可以被其他处理器上运行的其他线程所读取 我们必须将写缓冲器中的内容写入其所在的处理器上的高速缓存之中

注意 处理器在特定条件下(比如写缓冲器满,I/O指令被执行)会将写缓冲器排空或者冲刷
也就是将写缓冲器中的内容写入高速缓存

那么如何及时的冲刷 我们需要借助内存屏障中的存储屏障可以使执行该指令的处理器冲刷其写缓冲器

现在问题已经解决一半了 另一半就是无效化队列了

必须将堆积在无效化队列中的Invalidate消息删除其高速缓存中的相应副本数据 内存屏障中的加载屏障可以做到

加载屏障会根据无效化队列内容所指定的内存地址 将相应处理器上的高速缓存中相应的缓存条目都标记为I 从而使读内存操作时必须发送read消息 已将其他处理器对共享变量所做的更新同步到该处理器的高速缓存中

写线程的执行处理器所执行的存储屏障保障了该线程对共享变量所做的更新对线程来说是同步的 读线程的执行处理器所执行的加载屏障将写线程对共享变量所做的更新同步到该处理器的高速缓存之中

基本内存屏障

处理器支持哪种内存重新排序 就会提供能够禁止相应重排序的指令 这些指令就被称为基本内存屏障

重排序 屏障名
LoadLoad LoadLoad屏障

Load读 Store写 两两组合共有四种

基本内存屏障是对一类指令的称呼 这类指令的作用是禁止该指令左侧的任何X操作 与该指令右侧的任何Y操作之间进行重排序

Store1;Store2;Store3;StoreLoad;Load1;Load2;Load3;

从而确保该指令左侧所有X操作先于该指令右侧的Y操作被提交

基本内存屏障的作用只是保障其左侧的X操作先于其右侧的Y操作被提交

这里X和Y可以是(Store和Load中的一种) 如果XY屏障中的左侧包含非X操作 这仍将导致重新排序

举个例子

Store1 Load1 Store2 StoreLoad屏障 Store3 Load2 Load3

那么 Store1 和Load1之间可能发生重排序

  • LoadLoad屏障
    LoadLoad屏障是通过清空无效化队列来实现禁止LoadLoad重排序的
    LoadLoad屏障 使其他处理器有机会将其他处理器对共享变量所做的更新同步到该处理器的高速缓存中 因为该屏障会使其执行处理器根据无效化队列中的Invalidate消息删除其高速缓存中相应的副本

  • StoreStore屏障

StoreStore屏障可以通过对写缓冲器中的条目进行标记来实现禁止重排序
StoreStore屏障会将写缓冲器中的现有条目做一个标记 (先于屏障之后的写操作提交) 处理器在执行写操作的时候如果发现写缓冲器中存在被标记的条目 那么此时处理器也不直接将写操作写入高速缓存(就算这个写操作对应的高速缓存条目为E或者M) 而将其写入缓冲器 这就保证了 StoreStore屏障之前的任何写操作先于该屏障之后的写操作被提交
引入StoreStore屏障和未引入的区别在于 为引入如果条目状态为E或者M时 处理器会将其写入缓存行而不是写缓存器

StoreLoad屏障 该屏障实现为一个通用基本内存屏障 即StoreLoad屏障能够实现其他3种基本内存屏障的效果 StoreLoad屏障能够替代其他基本内存屏障 但是它的开销也是最大的-StoreLoad屏障会清空无效化队列
并将写缓冲器中的条目冲刷到高速缓存

Java同步机制与内存屏障

Java虚拟机对synchronized,volatile和final关键字的语义的实现就是借助内存屏障的

volatile关键字内部原理

因为写缓冲器的存在可能导致StoreStore和LoadStore重排序
那么volatile关键字如何保证同步呢?
引用释放屏障针对写线程 引入获取屏障针对读线程

  • 释放屏障
    Java虚拟机在volatile变量写操作之前插入的释放屏障使得该屏障之前的任何读 写操作都先于这个volatile变量写操作被提交
  • 获取屏障
    而Java虚拟机(JIT编译器)在volatile变量读操作之后插入的获取屏障使得这个volatile变量读操作先于该屏障之后的任何读 写操作被提交

JAVA虚拟机会在volatile变量写操作之后插入一个StoreLoad屏障 该屏障不仅禁止该屏障之后的任何读操作与该屏障之前的任何写操作

它还起到以下作用

  • 充当存储屏障 StoreLoad是个多功能的屏障 他可以充当其他3个基本内存屏障 StoreLoad屏障通过清空其执行器的写缓冲器使得该屏障前的所有写操作(包括volatile写操作以及其他任何写操作)的结果得意到达高速缓存 从而是这些更新对其他处理器而言是可同步的

  • 充当加载屏障 消除存储转发的副作用(存储转发可能导致可见性的问题) 假设处理器1在t1更新了变量 在t2读取该变量 但是其他处理器在t1和t2的缝隙之间 也更新了该变量并成功写入缓冲行 那么此时t2读取的变量可能还是停留在写缓冲器中的旧变量

注意

部分参考多线程相关书籍

看完觉得有收获记得点个赞哦

你可能感兴趣的:(并发编程)