大家好,我是大明哥,一个专注「死磕 Java」系列创作的硬核程序员。
本文已收录到我的技术网站:https://skjava.com。有全网最优质的系列文章、Java 全栈技术文档以及大厂完整面经
什么是可见性?
可见性是指一个线程对共享变量所作的修改能够被其他线程及时地看到。
在单核时代,其实是不存在可见性问题的,因为所有的线程都是在一个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
,会不会存在这种情况?会,但是我们通过同步机制让他们不会,为什么?因为这个操作不是原子操作,在并发情况下会产生线程安全问题,我们是需要采用同步或者锁机制来保护的。