什么是可见性?
可见性是指一个线程对共享变量所作的修改能够被其他线程及时地看到。
在单核时代,其实是不存在可见性问题的,因为所有的线程都是在一个CPU中工作的,一个线程的写操作对于其他的线程一定是可见的。
但是,在多核时代,每个 CPU 都有自己的缓存。一个线程对共享变量的修改可能只是在它所在 CPU 的本地缓存中进行,而不是在主内存中进行。这就可能导致其他线程看不到这个修改,从而引发可见性问题。
解决可见性的方案有两种:
- 使用
volatile
修饰共享变量:一个变量被声明为volatile
后,对这个变量的读写操作都是在主内存中进行的,从而保证了不同线程之间对该变量修改的可见性。 - 使用同步机制,比如锁或者
synchronized
。当一个线程成功获取锁进入一个同步块时,它会看到由其他线程在相同同步块内对共享变量的修改。
volatile 是如何保证可见性的?
这部分内容在 volatile 的实现原理中有,但是为了更好地阅读,大明哥直接复制过来了。
对于 volatile 变量,会在写入 volatile
变量的指令前添加 lock
前缀(汇编层面),当某个线程写入 volatile 变量时,其值会被强制刷入主内存,而其他处理器的缓存由于遵守了缓存一致性协议(MESI
协议),其他处理器的工作内存会被标志为无效。当其他处理器来访问这个变量时,由于它们的本地缓存是无效的,它们就不得不从主内存中重新加载这个变量的最新值。这样就保证了线程的可见性。
lock
前缀是用于实现原子操作的一种机制。当它用于一个指令前,它会锁定一个特定的内存地址,确保该指令执行期间,该内存地址不会被其他处理器访问。
MESI 协议
MESI协议,即缓存一致性协议,它是一种用于维护多处理器系统中缓存一致性的协议。从上面我们知道,每个处理器都有自己的工作内存,这可能导致同一内存位置的多个副本同时存在于不同的缓存中。为了保证这些副本的一致性,引入 MESI 协议来保证一致性。
其核心思想:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
MESI 代表四种缓存行状态:Modified
(修改)、Exclusive
(独占)、Shared
(共享)和Invalid
(无效)。
- Modified(修改):数据有效,数据已被修改,且只存在于当前缓冲中。这个状态下的缓存行数据于主内存数据不一致,在数据被写回主内存之前,任何对这个缓存行的读取或写入操作都只会发生在这个缓存中。
- Exclusive(独占):数据有效,且只存在于当前缓存中。这个状态下的缓存行数据与主内存中的数据是一致的。如果 CPU 需要写入这个缓存行,它可以直接改变状态到Modified,而无需与其他处理器或主内存通信。
- Shared(共享):数据有效,且可能存在于多个 CPU 的缓存中,并且数据与主内存中的数据是一致的。这个状态下的缓存行任何 CPU 都可以读到,但如果某个 CPU 需要写入,它必须首先通知其他拥有该缓存行副本的 CPU,使它们的副本无效。
- Invalid(无效):数据无效。如果有 CPU 需要读取这个缓存行数据,它必须从拥有有效副本的其他缓存或主内存中读取数据。
其工作流程如下:
读数据
- 如果数据在本地缓存中并且状态是 Modified、Exclusive 或 Shared,处理器直接从缓存中读取,因为这三种状态的数据是有效的。
- 如果数据不在本地缓存中,或者缓存行状态是 Invalid,处理器向其他缓存发送读取请求:
- 如果其他缓存中没有该数据,或者都是 Invalid 状态,处理器从主内存读取数据,并将本地缓存行状态设置为 Exclusive。
- 如果其他缓存中有该数据并且至少一个是 Shared 或 Modified 状态,处理器从拥有该数据的缓存复制数据,并将所有拥有该数据的缓存行状态设置为 Shared。
写数据
- 如果数据在本地缓存且状态是 Modified,处理器直接写入本地缓存。
- 如果数据在本地缓存且状态是 Exclusive,处理器将缓存行状态改为 Modified,并执行写入操作。
- 如果数据在本地缓存且状态是 Shared 或者不在本地缓存中,处理器向其他缓存发送失效通知:
- 其他缓存如果有该数据,则将其缓存行状态设置为 Invalid。
- 本地缓存将数据写入,并将缓存行状态设置为 Modified。
内存屏障
volatile 通过在在每个读操作前都加上**Load屏障,强制从主内存读取最新的数据,在每个写操作后加上Store屏障,强制将数据刷新到主内存。**这样每次写都能将最新数据刷入到主内存,读都能从主内存读取最新数据,以此达到可见性。
下面以 i++ 为例来阐述下:
如上图所示,流程如下:
- 线程 A 读取
i
时,遇到Load 屏障
,需要强制从主内存中读取得到i = 0
,加载到工作内存中。 - 线程 A 执行
i++
操作得到i = 1
,执行 assign指令进行赋值,遇到Store 屏障
,需要将i = 1
强制刷新回主内存,此时主内存数据i = 1
。 - 然后线程 B 读取
i
,也遇到Load 屏障
,强制从主内存读取i
的最新值,i = 1
,执行i++
操作,得到i = 2
,同样在执行 assign 赋值后,遇到Store屏障
立即将数据刷新回主内存,此时主内存数据i = 2
。
这里可能有小伙们会认为,线程 A 和线程 B 同时执行,都从主内存读取 i = 0
,然后执行 i++
,最后主内存数据 i = 1
,会不会存在这种情况?会,但是我们通过同步机制让他们不会,为什么?因为这个操作不是原子操作,在并发情况下会产生线程安全问题,我们是需要采用同步或者锁机制来保护的。
Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。
它的内容包括:
- 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
- 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
- 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
- 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
- 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
- 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
- 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
- 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw
目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:
想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询
同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。