参考书籍: 《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
操作的请求,如果存在,则必须要把当前缓存行状态设置为Invalid
E
状态的监听: 如果一个缓存行处于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