全网最硬核 Java 新内存模型解析与实验 - 3. 硬核理解内存屏障(CPU+编译器)(上)

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 全网最硬核 Java 新内存模型解析与实验 - 3. 硬核理解内存屏障(CPU+编译器)(上)
个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~本篇文章参考了大量文章,文档以及论文,但是这块东西真的很繁杂,我的水平有限,可能理解的也不到位,如有异议欢迎留言提出。 本系列会不断更新,结合大家的问题以及这里的错误和疏漏,欢迎大家留言如果你喜欢单篇版,请访问: 全网最硬核 Java 新内存模型解析与实验单篇版(不断更新QA中)如果你喜欢这个拆分的版本,这里是目录:


JMM 相关文档:


内存屏障,CPU 与内存模型相关:


x86 CPU 相关资料:


ARM CPU 相关资料:


各种一致性的理解:


Aleskey 大神的 JMM 讲解:


相信很多 Java 开发,都使用了 Java 的各种并发同步机制,例如 volatile,synchronized 以及 Lock 等等。也有很多人读过 JSR 第十七章 Threads and Locks(地址:https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html),其中包括同步、Wait/Notify、Sleep & Yield 以及内存模型等等做了很多规范讲解。但是也相信大多数人和我一样,第一次读的时候,感觉就是在看热闹,看完了只是知道他是这么规定的,但是为啥要这么规定,不这么规定会怎么样,并没有很清晰的认识。同时,结合 Hotspot 的实现,以及针对 Hotspot 的源码的解读,我们甚至还会发现,由于 javac 的静态代码编译优化以及 C1、C2 的 JIT 编译优化,导致最后代码的表现与我们的从规范上理解出代码可能的表现是不太一致的。并且,这种不一致,导致我们在学习 Java 内存模型(JMM,Java Memory Model),理解 Java 内存模型设计的时候,如果想通过实际的代码去试,结果是与自己本来可能正确的理解被带偏了,导致误解。

我本人也是不断地尝试理解 Java 内存模型,重读 JLS 以及各路大神的分析。这个系列,会梳理我个人在阅读这些规范以及分析还有通过 jcstress 做的一些实验而得出的一些理解,希望对于大家对 Java 9 之后的 Java 内存模型以及 API 抽象的理解有所帮助。但是,还是强调一点,内存模型的设计,出发点是让大家可以不用关心底层而抽象出来的一些设计,涉及的东西很多,我的水平有限,可能理解的也不到位,我会尽量把每一个论点的论据以及参考都摆出来,请大家不要完全相信这里的所有观点,如果有任何异议欢迎带着具体的实例反驳并留言


5. 内存屏障


5.1. 为何需要内存屏障

内存屏障(Memory Barrier),也有叫内存栅栏(Memory Fence),还有的资料直接为了简便,就叫 membar,这些其实意思是一样的。内存屏障主要为了解决指令乱序带来了结果与预期不一致的问题,通过加入内存屏障防止指令乱序(或者称为重排序,reordering)。

那么为什么会有指令乱序呢?主要是因为 CPU 乱序(CPU乱序还包括 CPU 内存乱序以及 CPU 指令乱序)以及编译器乱序。内存屏障可以用于防止这些乱序。如果内存屏障对于编译器和 CPU 都生效,那么一般称为硬件内存屏障,如果只对编译器生效,那么一般被称为软件内存屏障。我们这里主要关注 CPU 带来的乱序,对于编译器的重排序我们会在最后简要介绍下。


5.2. CPU 内存乱序相关


我们从 CPU 高速缓存以及缓存一致性协议出发,开始分析为何 CPU 中会有乱序。我们这里假设一种简易的 CPU 模型请大家一定记住,实际的 CPU 要比这里列举的简易 CPU 模型复杂的多


5.2.1. 简易 CPU 模型 - CPU 高速缓存的出发点 - 减少 CPU Stall

我们在这里会看到,现代的 CPU 的很多设计,一切以减少 CPU Stall 出发。什么是 CPU Stall 呢?举一个简单的例子,假设 CPU 需要直接读取内存中的数据(忽略其他的结构,例如 CPU 缓存,总线与总线事件等等):


image.png


CPU 发出读取请求,在内存响应之前,CPU 需要一直等待,无法处理其他的事情。这一段 CPU 就是处于 Stall 状态。如果 CPU 一直直接从内存中读取,CPU 直接访问内存消耗时间很长,可能需要几百个指令周期,也就是每次访问都会有几百个指令周期内 CPU 处于 Stall 状态什么也干不了,这样效率会很低。一般需要引入若干个高速缓存(Cache)来减少 Stall:高速缓存即与处理器紧挨着的小型存储器,位于处理器和内存之间。

我们这里不关心多级高速缓存,以及是否存在多个 CPU 共用某一缓存的情况,我们就简单认为是下面这个架构:


image.png


当需要读取一个地址的值时,访问高速缓存看是否存在:存在代表命中(hit),直接读取。不存在被称为缺失(miss)。同样的,如果需要写一个值到一个地址,这个地址在缓存中存在也就不需要访问内存了。大部分程序都表现出较高的局部性(locality):

  • 如果处理器读或写一个内存地址,那么它很可能很快还会读或写同一个地址
  • 如果处理器读或写一个内存地址,那么它很可能很快还会读或写附近的地址

针对局部性,高速缓存一般会一次操作不止一个字,而是一组临近的字,称为缓存行

但是呢,由于告诉缓存的存在,就给更新内存带来了麻烦:当一个 CPU 需要更新一块缓存行对应内存的时候,它需要将其他 CPU 缓存中这块内存的缓存行也置为失效。为了维持每个 CPU 的缓存数据一致性,引入了缓存一致性协议(Cache Coherence Protocols)


5.2.2. 简易 CPU 模型 - 一种简单的缓存一致性协议(实际的 CPU 用的要比这个复杂) - MESI

现代的缓存一致性的协议以及算法非常复杂,缓存行可能会有数十种不同的状态。这里我们并不需要研究这种复杂的算法,我们这里引入一个最经典最简单的缓存一致性协议即 4 状态 MESI 协议(再次强调,实际的 CPU 用的协议要比这个复杂,MESI 其实本身有些问题解决不了),MESI 其实指的就是缓存行的四个状态:

  • Modified:缓存行被修改,最终一定会被写回入主存,在此之前其他处理器不能再缓存这个缓存行。
  • Exclusive:缓存行还未被修改,但是其他的处理器不能将这个缓存行载入缓存
  • Shared:缓存行未被修改,其他处理器可以加载这个缓存行到缓存
  • Invalid:缓存行中没有有意义的数据

根据我们前面的 CPU 缓存结构图中所示,假设所有 CPU 都共用在同一个总线上,则会有如下这些信息在总线上发送:

  1. Read:这个事件包含要读取的缓存行的物理地址。
  2. Read Response:包含前面的读取事件请求的数据,数据来源可能是内存或者是其他高速缓存,例如,如果请求的数据在其他缓存处于 modified 状态的话,那么必须从这个缓存读取缓存行数据作为 Read Response
  3. Invalidate:这个事件包含要过期掉的缓存行的物理地址。其他的高速缓存必须移除这个缓存行并且响应 Invalidate Acknowledge 消息。
  4. Invalidate Acknowledge:收到 Invalidate 消息移除掉对应的缓存行之后,回复 Invalidate Acknowledge 消息。
  5. Read Invalidate:是 Read 消息还有 Invalidate 消息的组合,包含要读取的缓存行的物理地址。既读取这个缓存行并且需要 Read Response 消息响应,同时发给其他的高速缓存,移除这个缓存行并且响应 Invalidate Acknowledge 消息。
  6. Writeback:这个消息包含要更新的内存地址以及数据。同时,这个消息也允许状态为 modified 的缓存行被剔除,以给其他数据腾出空间。

缓存行状态转移与事件的关系:


image.png


这里只是列出这个图,我们不会深入去讲的,因为 MESI 是一个非常精简的协议,具体实现的时候会有很多额外的问题 MESI 无法解决,如果详细的去讲,会把读者绕进去,读者会思考在某个极限情况下这个协议要怎么做才能保证正确,但是 MESI 实际上解决不了这些。在实际的实现中,CPU 一致性协议要比 MESI 复杂的多得多,但是一般都是基于 MESI 扩展的

举一个简单的 MESI 的例子:


image.png


1.CPU A 发送 Read 从地址 a 读取数据,收到 Read Response 将数据存入他的高速缓存并将对应的缓存行置为 Exclusive

2.CPU B 发送 Read 从地址 a 读取数据,CPU A 检测到地址冲突,CPU A 响应 Read Response 返回缓存中包含 a 地址的缓存行数据,之后,地址 a 的数据对应的缓存行被 A 和 B 以 Shared 状态装入缓存


image.png


3.CPU B 对于 a 马上要进行写操作,发送 Invalidate,等待 CPU A 的 Invalidate Acknowledge 响应之后,状态修改为 Exclusive。CPU A 收到 Invalidate 之后,将 a 所在的缓存行状态置为 Invalid 失效

4.CPU B 修改数据存储到包含地址 a 的缓存行上,缓存行状态置为 modified

5.这时候 CPU A 又需要 a 数据,发送 Read 从地址 a 读取数据,CPU B 检测到地址冲突,CPU B 响应 Read Response 返回缓存中包含 a 地址的缓存行数据,之后,地址 a 的数据对应的缓存行被 A 和 B 以 Shared 状态装入缓存

我们这里可以看到,MESI 协议中,发送 Invalidate 消息需要当前 CPU 等待其他 CPU 的 Invalidate Acknowledge,也就是这里有 CPU Stall。为了避免这个 Stall,引入了 Store Buffer


5.2.3. 简易 CPU 模型 - 避免等待 Invalidate Response 的 Stall - Store Buffer

为了避免这种 Stall,在 CPU 与 CPU 缓存之间添加 Store Buffer,如下图所示:


image.png


有了 Store Buffer,CPU 在发送 Invalidate 消息的时候,不用等待 Invalidate Acknowledge 的返回,将修改的数据直接放入 Store Buffer。如果收到了所有的 Invalidate Acknowledge 再从 Store Buffer 放入 CPU 的高速缓存的对应缓存行中。但是加入的这个 Store Buffer 又带来了新的问题:

假设有两个变量 a 和 b,不会处于同一个缓存行,初始都是 0,a 现在位于 CPU A 的缓存行中,b 现在位于 CPU B 的缓存行中:

假设 CPU B 要执行下面的代码:


image.png


我们肯定是期望最后 b 会等于 2 的。但是真的会如我们所愿么?我们来详细看下下面这个运行步骤:


image.png


1.CPU B 执行 a = 1:

(1)由于 CPU B 缓存中没有 a,并且要修改,所以发布 Read Invalidate 消息(因为是要先把包含 a 的整个缓存行读取后才能更新,所以发的是 Read Invalidate,而不只是 Invalidate)。

(2)CPU B 将 a 的修改(a=1)放入 Storage Buffer

(3)CPU A 收到 Read Invalidate 消息,将 a 所在的缓存行标记为 Invalid 并清除出缓存,并响应 Read Response(a=0) 和 Invalidate Acknowlegde


image.png



目录
打赏
0
0
0
0
33
分享
相关文章
|
23天前
|
重学Java基础篇—Java类加载顺序深度解析
本文全面解析Java类的生命周期与加载顺序,涵盖从加载到卸载的七个阶段,并深入探讨初始化阶段的执行规则。通过单类、继承体系的实例分析,明确静态与实例初始化的顺序。同时,列举六种触发初始化的场景及特殊场景处理(如接口初始化)。提供类加载完整流程图与记忆口诀,助于理解复杂初始化逻辑。此外,针对空指针异常等问题提出排查方案,并给出最佳实践建议,帮助开发者优化程序设计、定位BUG及理解框架机制。最后扩展讲解类加载器层次与双亲委派机制,为深入研究奠定基础。
48 0
重学Java基础篇—ThreadLocal深度解析与最佳实践
ThreadLocal 是一种实现线程隔离的机制,为每个线程创建独立变量副本,适用于数据库连接管理、用户会话信息存储等场景。
60 5
重学Java基础篇—类的生命周期深度解析
本文全面解析了Java类的生命周期,涵盖加载、验证、准备、解析、初始化、使用及卸载七个关键阶段。通过分阶段执行机制详解(如加载阶段的触发条件与技术实现),结合方法调用机制、内存回收保护等使用阶段特性,以及卸载条件和特殊场景处理,帮助开发者深入理解JVM运作原理。同时,文章探讨了性能优化建议、典型异常处理及新一代JVM特性(如元空间与模块化系统)。总结中强调安全优先、延迟加载与动态扩展的设计思想,并提供开发建议与进阶方向,助力解决性能调优、内存泄漏排查及框架设计等问题。
40 5
Java机器学习实战:基于DJL框架的手写数字识别全解析
在人工智能蓬勃发展的今天,Python凭借丰富的生态库(如TensorFlow、PyTorch)成为AI开发的首选语言。但Java作为企业级应用的基石,其在生产环境部署、性能优化和工程化方面的优势不容忽视。DJL(Deep Java Library)的出现完美填补了Java在深度学习领域的空白,它提供了一套统一的API,允许开发者无缝对接主流深度学习框架,将AI模型高效部署到Java生态中。本文将通过手写数字识别的完整流程,深入解析DJL框架的核心机制与应用实践。
58 3
|
23天前
|
重学Java基础篇—Java Object类常用方法深度解析
Java中,Object类作为所有类的超类,提供了多个核心方法以支持对象的基本行为。其中,`toString()`用于对象的字符串表示,重写时应包含关键信息;`equals()`与`hashCode()`需成对重写,确保对象等价判断的一致性;`getClass()`用于运行时类型识别;`clone()`实现对象复制,需区分浅拷贝与深拷贝;`wait()/notify()`支持线程协作。此外,`finalize()`已过时,建议使用更安全的资源管理方式。合理运用这些方法,并遵循最佳实践,可提升代码质量与健壮性。
29 1
|
2月前
|
【Java并发】【线程池】带你从0-1入门线程池
欢迎来到我的技术博客!我是一名热爱编程的开发者,梦想是编写高端CRUD应用。2025年我正在沉淀中,博客更新速度加快,期待与你一起成长。 线程池是一种复用线程资源的机制,通过预先创建一定数量的线程并管理其生命周期,避免频繁创建/销毁线程带来的性能开销。它解决了线程创建成本高、资源耗尽风险、响应速度慢和任务执行缺乏管理等问题。
184 60
【Java并发】【线程池】带你从0-1入门线程池
Java网络编程,多线程,IO流综合小项目一一ChatBoxes
**项目介绍**:本项目实现了一个基于TCP协议的C/S架构控制台聊天室,支持局域网内多客户端同时聊天。用户需注册并登录,用户名唯一,密码格式为字母开头加纯数字。登录后可实时聊天,服务端负责验证用户信息并转发消息。 **项目亮点**: - **C/S架构**:客户端与服务端通过TCP连接通信。 - **多线程**:采用多线程处理多个客户端的并发请求,确保实时交互。 - **IO流**:使用BufferedReader和BufferedWriter进行数据传输,确保高效稳定的通信。 - **线程安全**:通过同步代码块和锁机制保证共享数据的安全性。
80 23
|
1月前
|
【源码】【Java并发】【线程池】邀请您从0-1阅读ThreadPoolExecutor源码
当我们创建一个`ThreadPoolExecutor`的时候,你是否会好奇🤔,它到底发生了什么?比如:我传的拒绝策略、线程工厂是啥时候被使用的? 核心线程数是个啥?最大线程数和它又有什么关系?线程池,它是怎么调度,我们传入的线程?...不要着急,小手手点上关注、点赞、收藏。主播马上从源码的角度带你们探索神秘线程池的世界...
123 0
【源码】【Java并发】【线程池】邀请您从0-1阅读ThreadPoolExecutor源码
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
157 14

热门文章

最新文章