参考书籍: 《Java并发编程深度解析与实战》 -- 谭锋
什么是可见性
如果一个线程对一个共享变量进行了修改,而其他线程不能及时地读取修改之后的值,那么我们认为在多线程环境下该共享变量存在可见性问题
可见性问题的根源
cpu 的速度远远高于内存,但是cpu 执行的指令和数据都是来源于内存,所以在 cpu 在等待内存的数据时,一直处于空闲状态,这个过程很显然会导致CPU资源的浪费,为了解决这个问题,开发者在硬件设备、操作系统及编译器层面做了很多优化
- 在
CPU层面增加了寄存器,来保存一些关键变量和临时数据,还增加了CPU高速缓存,以减少CPU和内存的I/O等待时间 - 在操作系统层面引入了进程和线程,在当前进程或线程处于阻塞状态时,
CPU会把自己的时间片分配给其他线程或进程使用,从而减少CPU的空闲时间,最大化地提升CPU的使用率 - 在编译器层面增加指令优化,减少与内存的交互次数
上述优化都是为了提升 cpu 利用率,但是这些优化也会导致可见性问题
cpu 缓存
cpu 缓存伪共享问题
cpu的缓存是以缓存行为单位进行缓存的,在 32 位和 64 位架构中cpu缓存行的大小都是 64 字节- 假设有两个 字段
int x = 0, int y =0现在有两个线程分别修改x和 修改y,假设这两个线程是在不同的cpu执行,那么每个cpu都会加载对应的变量到自己的cpu缓存中,但是x和y加起来才 8 个字节,所以这两个变量一定是在同一个缓存行中, 当线程1(修改x的线程)修改了x后,因为缓存一致性原理,导致第二个cpu中的x需要失效,间接导致 第二个cpu中的y的缓存也失效了,这就是缓存伪共享问题 - 在
java中可以对字段或类添加@Contented注解让每个字段独自占用一个缓存行
cpu 高速缓存
现代 cpu 一般都有三级缓存
- 1,2 级缓存是在
cpu内部 - 三级缓存是所有
cpu共享 - 一级缓存包含
L1D(数据缓存)和L1I(指令缓存)
缓存一致性的解决方案
- 所谓的缓存一致性是指不同的线程加载同一个共享变量到不同的
cpu进行修改,这就会导致不同cpu缓存中相同变量的缓存值内容不同 - 为了解决缓存一致性问题 在
cpu层面引入了 总线锁 和 缓存锁机制
为了解决缓存一致性问题,开发者在 CPU 层面引入了总线锁和缓存锁机制。
在了解锁之前,我们先介绍一下总线。所谓的总线,就是 CPU 与内存、输入/输出设备传专递信息的公共通道(也叫前端总线),当CPU访问内存进行数据交互时,必须经过总线来传输,那么什么是总线锁呢?
简单来说,总线锁就是在总线上声明一个 L0ck# 信号,这个信号能够确保共享内存只有当前 CPU 可以访问,其他的处理器请求会被阻塞,这就使得同一时刻只有一个处理能够访问共享内存,从而解决了缓存不一致的问题。但是这种做法产生的代价是,CPU 的利用率直线下降,很显然这是无法让人接受的,于是从 P6 系列的处理器开始增加了缓存锁的机制。
缓存锁指的是,如果当前 CPU 访问的数据已经缓存在其他 CPU 的高速缓存中,那么 CPU 不会在总线上发出 Lock# 信号,而是采用缓存一致性协议来保证多个 CPU 的缓存一致性。
CPU 最终用哪种锁来解决缓存一致性问题,取决于当前 CPU 是否支持缓存锁,如果不支持,就会采用总线锁。还有一种情况是,当前操作的数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行时,也会使用总线锁。
缓存一致性协议
- 缓存锁通过缓存一致性协议来保证缓存的一致性,那么什么是缓存一致性协议呢
- 不同的
CPU类型支持的缓存一致性协议也有区别,比如MSI、MESI、MOSI、MESIF协议等,比较常见的是MESI(Modified Exclusive Shared Or Invalid)协议
MESI 缓存一致性协议定义的 cpu 缓存行的四种状态
M (Modified): 只有当前cpu含有数据的缓存,并且缓存数据已经被修改了,和主内存数据不一致E (Exclusive): 只有当前cpu含有数据的缓存, 缓存还没有被修改,和主内存数据一致S (Shared): 多个cpu都含有同一份数据的缓存,所有cpu上的缓存都没有被修改I (Invalid): 缓存已失效
MESI 协议针对缓存行的事件监听
随着 cpu 对缓存行的操作,缓存行会发生状态转移,也就是从一种状态到另外一种状态,所以 MESI 协议对缓存行的不同状态有不同的事件监听
M状态的监听: 如果一个缓存行处于M状态,则必须监听所有试图读取该缓存行对应的主内存地址的操作,如果监听到有这类操作的发生,则必须在该操作执行之前把缓存行中的数据写回主内存S状态的监听: 如果一个缓存行处于S状态,那么它必须要监听使该缓存行状态设置为Invalid或者对缓存行执行Exclusive操作的请求,如果存在,则必须要把当前缓存行状态设置为InvalidE状态的监听: 如果一个缓存行处于E状态,那么它必须要监听其他试图读取该缓存行对应的主内存地址的操作,一旦有这种操作,那么该缓存行需要设置为Shared
缓存行事件监听的实现原理
- 监听过程是基于
CPU中的Snoopy嗅探协议来完成的,该协议要求每个CPU缓存都可以监听到总线上的数据事件并做出相应的反应 - 所有
CPU都会监听地址总线上的事件,当某个处理器发出请求时,其他CPU会监听到地址总线的请求,根据当前缓存行的状态及监听的请求类型对缓存行状态进行更新
MESI 状态变化示例
参考 《Java并发编程深度解析与实战》-- 谭峰 3.2 节(深度理解可见性问题的本质)
volatile 实现原理
volatile可以解决内存可见性问题就是基于缓存锁/总线锁的方式达到的一致性- 总线锁和缓存锁通过
Lock#信号触发,如果当前CPU支持缓存锁,则不会在总线上声明Lock#信号,而是基于缓存一致性协议来保证缓存的一致性。如果CPU不支持缓存锁,则会在总线上声明Lock#信号锁定总线,从而保证同一时刻只允许一个CPU对共享内存的读写操作
内存屏障-解决cpu层面的指令重排
CPU 本身只是一个工具,它主要用于接收和执行指令,并不清楚什么时候应该优化,什么时候不应该优化,因此 CPU 设计者们提供了一个内存屏障指令,开发者可以在合适的位置插入内存屏障指令,相当于告诉 CPU指令之间的关系,避免 CPU 内存系统重排序问题的发生。
什么是指令重排
指令重排序是指编译器或CPU为了优化程序的执行性能而对指令进行重新排序的一种手段,重排序会带来可见性问题,所以在多线程开发中必须要关注并规避重排序,java 从源代码到运行会经过两个阶段的重排
- 编译器重排序,就是在编译过程中,编译器根据上下文分析对指令进行重排序,目的是减少
CPU和内存的交互,重排序之后尽可能保证CPU从寄存器或缓存行中读取数据 - 处理器重排序,处理器重排序分为两个部分
- 并行指令集重排序,这是处理器优化的一种,处理器可以改变指令的执行顺序
- 内存系统重排序,这是处理器引入
Store Buffer缓冲区延时写入产生的指令执行顺序不一致的问题
指令重排需要遵循 as-if-serial语义
as-if-serial 表示所有的程序指令都可以因为优化而被重排序,但是在优化的过程中必须要保证是在单线程环境下,重排序之后的运行结果和程序代码本身预期的执行结果一致,Java 编译器、CPU 指令重排序都需要保证在单线程环境下的 as-if-serial 语义是正确的
as-if-serial语义允许重排序,CPU层面的指令优化依然存在。在单线程中,这些优化并不会影响整体的执行结果,在多线程中,重排序会带来可见性问题
重谈 MESI 过程
假设存在一个 S 状态的缓存行(就是说 CPU0 和 CPU1 共享同一个缓存行),如果 CPU0 对这个缓存进行修改,那么 CPU0 需要发送一个 Invalidate 消息到 CPU1,在等待 CPU1 返回Acknowledgement 消息之前,CPU0 一直处于空闲状态
Store Buffers
-
从上图可以看到,当一个
cpu从发出Invalidate指令到收到Acknowledgement指令之间,这个cpu是需要阻塞的 -
Store Buffer是一个存储器,它存储着那些被修改过的缓存行。当一个cpu要修改一个缓存行,它会将这个缓存行的副本放到Store Buffer中,然后发出一个Invalidate指令,让其他cpu知道这个缓存行

在 CPU 中引入Store Buffers的设计后,CPU0会先发送一个Invalidate消息给其他包含该缓存行的CPU1,并把当前修改的数据写入Store Buffers中,然后继续执行后续的指令。等收到CPU1的Acknowledgement消息后,CPU0再把Store Buffers移到缓存行中, 因为加入了 Store Buffers,就会导致内存可见性问题
使用 Store Forwarding 优化 Store Buffers 问题
-
Store Buffers之所以存在问题是因为,在 其他cpu返回Acknowledgement之前,数据是存放在Store Buffer中的,cpu缓存行中并没有最新的数据,cpu执行指令时就会用到缓存行中的旧数据 -
Store Forwarding是指每个CPU在加载数据之前,会先引用当前CPU的Store Buffers,也就是说支持将CPU存入Store Buffers的数据传递给后续的加载操作,而不需要经过Cache

使用 Invalidate Queues 优化 Store Buffers 问题
-
前面讲到的
Store Forwarding针对的时发出Invalidate指令的cpu,而接下来要讲解的Invalidate Queues则是针对接收到Invalidate指令并且要响应Acknowledgement指令的cpu, 具体原因如下 -
Store Buffers本身的存储容量是有限的,在当前CPU的所有写入操作都存在缓存未命中的情况时,就会导致Store Buffers很容易被填充满。被填满之后,必须要等到CPU返回Invalidate Acknowledge消息,Store Buffers中对应的指令才能被清理,而这个过程CPU必须要等待,无论该CPU中后续指令是否存在缓存未命中的情况 -
如果收到
Invalidate消息的CPU此时处于繁忙状态,那么会导致Invalidate Acknowledge消息返回延迟 -
增加一个
Invalidate Queues,用于存储让缓存行失效的消息。也就是说,CPU收到Invalidate消息时,把让该缓存行失效的消息放入Invalidate Queues,然后同步返回一个Invalidate Acknowledge消息。这样就大大缩短了响应的时间

内存屏障指令
- 读屏障指令(Ifence)
- 将
Invalidate Queues中的指令立即处理,并且强制读取CPU的缓存行, 执行lfence指令之后的读操作不会被重排序到执行lfence指令之前,这意味着其他CPU暴露出来的缓存行状态对当前CPU可见 - 写屏障指令(sfence)
- 它会把
Store Buffers中的修改刷新到本地缓存中,使得其他CPU能够看到这些修改,而且在执行sfence指令之后的写操作不会被重排序到执行sfence指令之前,这意味着执行sfence指令之前的写操作一定要全局可见(内存可见性及禁止重排序) - 读写屏障指令(mfence)
- 相当于
lfence和sfence的混合体,保证mfence指令执行前后的读写操作的顺序,同时要求执行mfence指令之后的写操作的结果全局可见,执行mfence指令之前的写操作结果全局可见
JVM 内存屏障指令
前面讲到的内存屏障指令是 cpu 可以执行的指令,但是这些指令是需要应用来调用的,同样的应用也需要提供相应的指令给开发者调用,对应到 JVM 中,提供了如下几种指令
- loadload
- storestore
- loadstore
- storeload
