volatile解决可见性和有序性

参考书籍: 《Java并发编程深度解析与实战》 -- 谭锋

什么是可见性

如果一个线程对一个共享变量进行了修改,而其他线程不能及时地读取修改之后的值,那么我们认为在多线程环境下该共享变量存在可见性问题

可见性问题的根源

cpu 的速度远远高于内存,但是cpu 执行的指令和数据都是来源于内存,所以在 cpu 在等待内存的数据时,一直处于空闲状态,这个过程很显然会导致CPU资源的浪费,为了解决这个问题,开发者在硬件设备、操作系统及编译器层面做了很多优化

  • CPU层面增加了寄存器,来保存一些关键变量和临时数据,还增加了CPU高速缓存,以减少CPU和内存的I/O等待时间
  • 在操作系统层面引入了进程和线程,在当前进程或线程处于阻塞状态时,CPU会把自己的时间片分配给其他线程或进程使用,从而减少CPU的空闲时间,最大化地提升CPU的使用率
  • 在编译器层面增加指令优化,减少与内存的交互次数

上述优化都是为了提升 cpu 利用率,但是这些优化也会导致可见性问题

cpu 缓存

cpu 缓存伪共享问题

  1. cpu 的缓存是以缓存行为单位进行缓存的,在 32 位和 64 位架构中 cpu 缓存行的大小都是 64 字节
  2. 假设有两个 字段 int x = 0, int y =0 现在有两个线程分别修改 x 和 修改 y,假设这两个线程是在不同的 cpu 执行,那么每个 cpu 都会加载对应的变量到自己的 cpu 缓存中,但是 xy 加起来才 8 个字节,所以这两个变量一定是在同一个缓存行中, 当线程1(修改 x 的线程)修改了x 后,因为缓存一致性原理,导致第二个 cpu 中的 x 需要失效,间接导致 第二个 cpu 中的 y 的缓存也失效了,这就是缓存伪共享问题
  3. java 中可以对字段或类添加 @Contented 注解让每个字段独自占用一个缓存行

cpu 高速缓存

现代 cpu 一般都有三级缓存

  1. 1,2 级缓存是在 cpu 内部
  2. 三级缓存是所有 cpu 共享
  3. 一级缓存包含 L1D(数据缓存)和 L1I(指令缓存)

缓存一致性的解决方案

  1. 所谓的缓存一致性是指不同的线程加载同一个共享变量到不同的 cpu 进行修改,这就会导致不同 cpu 缓存中相同变量的缓存值内容不同
  2. 为了解决缓存一致性问题 在 cpu 层面引入了 总线锁缓存锁机制

为了解决缓存一致性问题,开发者在 CPU 层面引入了总线锁和缓存锁机制。

在了解锁之前,我们先介绍一下总线。所谓的总线,就是 CPU 与内存、输入/输出设备传专递信息的公共通道(也叫前端总线),当CPU访问内存进行数据交互时,必须经过总线来传输,那么什么是总线锁呢?

简单来说,总线锁就是在总线上声明一个 L0ck# 信号,这个信号能够确保共享内存只有当前 CPU 可以访问,其他的处理器请求会被阻塞,这就使得同一时刻只有一个处理能够访问共享内存,从而解决了缓存不一致的问题。但是这种做法产生的代价是,CPU 的利用率直线下降,很显然这是无法让人接受的,于是从 P6 系列的处理器开始增加了缓存锁的机制。

缓存锁指的是,如果当前 CPU 访问的数据已经缓存在其他 CPU 的高速缓存中,那么 CPU 不会在总线上发出 Lock# 信号,而是采用缓存一致性协议来保证多个 CPU 的缓存一致性。

CPU 最终用哪种锁来解决缓存一致性问题,取决于当前 CPU 是否支持缓存锁,如果不支持,就会采用总线锁。还有一种情况是,当前操作的数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行时,也会使用总线锁。

缓存一致性协议

  1. 缓存锁通过缓存一致性协议来保证缓存的一致性,那么什么是缓存一致性协议呢
  2. 不同的 CPU 类型支持的缓存一致性协议也有区别,比如MSI、MESI、MOSI、MESIF协议等,比较常见的是MESI(Modified Exclusive Shared Or Invalid)协议

MESI 缓存一致性协议定义的 cpu 缓存行的四种状态

  1. M (Modified): 只有当前 cpu 含有数据的缓存,并且缓存数据已经被修改了,和主内存数据不一致
  2. E (Exclusive): 只有当前 cpu 含有数据的缓存, 缓存还没有被修改,和主内存数据一致
  3. S (Shared): 多个 cpu 都含有同一份数据的缓存,所有 cpu 上的缓存都没有被修改
  4. I (Invalid): 缓存已失效

MESI 协议针对缓存行的事件监听

随着 cpu 对缓存行的操作,缓存行会发生状态转移,也就是从一种状态到另外一种状态,所以 MESI 协议对缓存行的不同状态有不同的事件监听

  1. M 状态的监听: 如果一个缓存行处于 M 状态,则必须监听所有试图读取该缓存行对应的主内存地址的操作,如果监听到有这类操作的发生,则必须在该操作执行之前把缓存行中的数据写回主内存
  2. S 状态的监听: 如果一个缓存行处于 S 状态,那么它必须要监听使该缓存行状态设置为 Invalid 或者对缓存行执行 Exclusive 操作的请求,如果存在,则必须要把当前缓存行状态设置为 Invalid
  3. E 状态的监听: 如果一个缓存行处于 E 状态,那么它必须要监听其他试图读取该缓存行对应的主内存地址的操作,一旦有这种操作,那么该缓存行需要设置为 Shared

缓存行事件监听的实现原理

  1. 监听过程是基于 CPU 中的 Snoopy 嗅探协议来完成的,该协议要求每个 CPU 缓存都可以监听到总线上的数据事件并做出相应的反应
  2. 所有 CPU 都会监听地址总线上的事件,当某个处理器发出请求时,其他 CPU 会监听到地址总线的请求,根据当前缓存行的状态及监听的请求类型对缓存行状态进行更新

MESI 状态变化示例

参考 《Java并发编程深度解析与实战》-- 谭峰 3.2 节(深度理解可见性问题的本质)

volatile 实现原理

  1. volatile 可以解决内存可见性问题就是基于缓存锁/总线锁的方式达到的一致性
  2. 总线锁和缓存锁通过 Lock# 信号触发,如果当前CPU支持缓存锁,则不会在总线上声明Lock#信号,而是基于缓存一致性协议来保证缓存的一致性。如果CPU不支持缓存锁,则会在总线上声明Lock#信号锁定总线,从而保证同一时刻只允许一个CPU对共享内存的读写操作

内存屏障-解决cpu层面的指令重排

CPU 本身只是一个工具,它主要用于接收和执行指令,并不清楚什么时候应该优化,什么时候不应该优化,因此 CPU 设计者们提供了一个内存屏障指令,开发者可以在合适的位置插入内存屏障指令,相当于告诉 CPU指令之间的关系,避免 CPU 内存系统重排序问题的发生。

什么是指令重排

指令重排序是指编译器或CPU为了优化程序的执行性能而对指令进行重新排序的一种手段,重排序会带来可见性问题,所以在多线程开发中必须要关注并规避重排序,java 从源代码到运行会经过两个阶段的重排

  1. 编译器重排序,就是在编译过程中,编译器根据上下文分析对指令进行重排序,目的是减少CPU和内存的交互,重排序之后尽可能保证CPU从寄存器或缓存行中读取数据
  2. 处理器重排序,处理器重排序分为两个部分
  3. 并行指令集重排序,这是处理器优化的一种,处理器可以改变指令的执行顺序
  4. 内存系统重排序,这是处理器引入Store Buffer缓冲区延时写入产生的指令执行顺序不一致的问题

指令重排需要遵循 as-if-serial语义

as-if-serial 表示所有的程序指令都可以因为优化而被重排序,但是在优化的过程中必须要保证是在单线程环境下,重排序之后的运行结果和程序代码本身预期的执行结果一致,Java 编译器、CPU 指令重排序都需要保证在单线程环境下的 as-if-serial 语义是正确的

as-if-serial语义允许重排序,CPU层面的指令优化依然存在。在单线程中,这些优化并不会影响整体的执行结果,在多线程中,重排序会带来可见性问题

重谈 MESI 过程

假设存在一个 S 状态的缓存行(就是说 CPU0CPU1 共享同一个缓存行),如果 CPU0 对这个缓存进行修改,那么 CPU0 需要发送一个 Invalidate 消息到 CPU1,在等待 CPU1 返回Acknowledgement 消息之前,CPU0 一直处于空闲状态

sequenceDiagram participant A as CPU0 participant B as CPU1 A->>A: "加载变量, 缓存行状态S" B->>B: "加载变量,缓存行状态S" A->>A: "修改共享s变量" A->>B: "Invalidate" A->>A: "空闲等待" B->>A: "Acknowledgement"

Store Buffers

  1. 从上图可以看到,当一个 cpu 从发出 Invalidate 指令到收到 Acknowledgement 指令之间,这个 cpu 是需要阻塞的

  2. Store Buffer 是一个存储器,它存储着那些被修改过的缓存行。当一个 cpu 要修改一个缓存行,它会将这个缓存行的副本放到 Store Buffer 中,然后发出一个 Invalidate 指令,让其他 cpu 知道这个缓存行

CPU 中引入Store Buffers的设计后,CPU0会先发送一个Invalidate消息给其他包含该缓存行的CPU1,并把当前修改的数据写入Store Buffers中,然后继续执行后续的指令。等收到CPU1Acknowledgement消息后,CPU0再把Store Buffers移到缓存行中, 因为加入了 Store Buffers,就会导致内存可见性问题

使用 Store Forwarding 优化 Store Buffers 问题

  1. Store Buffers 之所以存在问题是因为,在 其他 cpu 返回 Acknowledgement 之前,数据是存放在 Store Buffer 中的,cpu 缓存行中并没有最新的数据,cpu 执行指令时就会用到缓存行中的旧数据

  2. Store Forwarding 是指每个 CPU 在加载数据之前,会先引用当前 CPUStore Buffers,也就是说支持将 CPU 存入Store Buffers 的数据传递给后续的加载操作,而不需要经过 Cache

使用 Invalidate Queues 优化 Store Buffers 问题

  1. 前面讲到的 Store Forwarding 针对的时发出 Invalidate 指令的 cpu,而接下来要讲解的 Invalidate Queues 则是针对接收到 Invalidate 指令并且要响应 Acknowledgement 指令的 cpu, 具体原因如下

  2. Store Buffers本身的存储容量是有限的,在当前CPU的所有写入操作都存在缓存未命中的情况时,就会导致Store Buffers很容易被填充满。被填满之后,必须要等到CPU返回Invalidate Acknowledge消息,Store Buffers中对应的指令才能被清理,而这个过程CPU必须要等待,无论该CPU中后续指令是否存在缓存未命中的情况

  3. 如果收到Invalidate消息的CPU此时处于繁忙状态,那么会导致Invalidate Acknowledge消息返回延迟

  4. 增加一个Invalidate Queues,用于存储让缓存行失效的消息。也就是说,CPU收到Invalidate消息时,把让该缓存行失效的消息放入Invalidate Queues,然后同步返回一个Invalidate Acknowledge消息。这样就大大缩短了响应的时间

内存屏障指令

  1. 读屏障指令(Ifence)
  2. Invalidate Queues中的指令立即处理,并且强制读取CPU的缓存行, 执行lfence指令之后的读操作不会被重排序到执行lfence指令之前,这意味着其他CPU暴露出来的缓存行状态对当前CPU可见
  3. 写屏障指令(sfence)
  4. 它会把Store Buffers中的修改刷新到本地缓存中,使得其他CPU能够看到这些修改,而且在执行sfence指令之后的写操作不会被重排序到执行sfence指令之前,这意味着执行sfence指令之前的写操作一定要全局可见(内存可见性及禁止重排序)
  5. 读写屏障指令(mfence)
  6. 相当于lfencesfence的混合体,保证mfence指令执行前后的读写操作的顺序,同时要求执行mfence指令之后的写操作的结果全局可见,执行mfence指令之前的写操作结果全局可见

JVM 内存屏障指令

前面讲到的内存屏障指令是 cpu 可以执行的指令,但是这些指令是需要应用来调用的,同样的应用也需要提供相应的指令给开发者调用,对应到 JVM 中,提供了如下几种指令

  1. loadload
  2. storestore
  3. loadstore
  4. storeload
相关推荐
小池先生24 分钟前
springboot启动不了 因一个spring-boot-starter-web底下的tomcat-embed-core依赖丢失
java·spring boot·后端
小蜗牛慢慢爬行2 小时前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
wm10432 小时前
java web springboot
java·spring boot·后端
龙少95434 小时前
【深入理解@EnableCaching】
java·后端·spring
溟洵6 小时前
Linux下学【MySQL】表中插入和查询的进阶操作(配实操图和SQL语句通俗易懂)
linux·运维·数据库·后端·sql·mysql
SomeB1oody8 小时前
【Rust自学】6.1. 定义枚举
开发语言·后端·rust
SomeB1oody8 小时前
【Rust自学】5.3. struct的方法(Method)
开发语言·后端·rust
啦啦右一10 小时前
Spring Boot | (一)Spring开发环境构建
spring boot·后端·spring
森屿Serien10 小时前
Spring Boot常用注解
java·spring boot·后端
盛派网络小助手12 小时前
微信 SDK 更新 Sample,NCF 文档和模板更新,更多更新日志,欢迎解锁
开发语言·人工智能·后端·架构·c#