0
点赞
收藏
分享

微信扫一扫

高薪程序员&面试题精讲系列71之你熟悉volatile关键字吗?内存屏障知道吗?CPU总线嗅探机制你知道吗?

南陵王梁枫 2022-03-11 阅读 32

一. 面试题及剖析

1. 今日面试题

2. 题目剖析

在上一篇文章中,壹哥给大家讲解了线程安全问题的由来,JMM线程模型,以及保证线程安全的第一种手段--原子性,如果你还没看过壹哥的上一篇文章,可以移步到前文:

高薪程序员&面试题精讲系列70之如何保证线程安全?你有没有遇到过线程死锁问题

接下来在本文中,壹哥会讲解保证线程安全的第2种手段--保证volatile可见性,并且会在本文中给大家讲解其他几个非常关键的特性,比如内存屏障、内存屏障相关的指令,CPU总线嗅探机制等,希望大家可以认真阅读本文哦。

二. 可见性--volatile

接下来我们学习如何满足线程安全的第2个特性,即可见性。在此之前,我们先对可见性进行必要地了解。

1. 可见性问题的由来

壹哥 在前面给大家绘制了JMM模型图,从中我们可以得知,JMM模型定义了工作线程和主内存之间的抽象关系:线程之间的共享变量都储存在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了共享变量的副本。JMM模型其实有如下规定:

所以正因为有JMM模型 这样的机制,就出现了线程间共享变量的可见性问题。也就是说,以上这些JMM的规定,可能会导致线程对共享变量副本的修改没有及时更新到主内存,或者线程没能及时将共享变量副本的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。

2. 可见性的概念

所谓的线程可见性,是指一个线程对主内存中共享变量的修改,可以及时的被其他线程观察到,感知到这个变量的变化也就是说,如果线程 A 修改了共享变量 V 的值,那么线程 B 在使用 V 的值时,能立即读到 V 的最新值。而导致共享变量在线程间不可见的原因可能有如下几种:

3. 可见性的实现方式

既然我们现在已经明白了线程可见性问题的由来、产生原因及可见性的概念,那么该如何保证线程的可见性呢?

在JVM中提供了volatile关键字 与 synchronized同步锁 来满足可见性要求,但因为volatile不能保证原子性操作,所以使用volatile时经常会结合synchronized一起使用,即:

因为之前我已经给大家详细讲解过synchronized同步锁,所以接下来壹哥 会重点讲解volatile的特性、作用及用法、原理等。

4. synchronized保证可见性

synchronized除了可以作为同步锁,保证线程的原子性之外,与此同时还可以确保线程的可见性。这是因为在JMM模型中,有关于syncronized可见性的两条规定:

所以syncronized既可以保证原子性,又可以保证可见性,共享变量只要被syncronized修饰,我们就可以放心的使用了。

5. volatile保证可见性

5.1 volatile简介

volatile关键字是JDK 5中出现的一个修饰符,我们可以把volatile看作是“程度较轻的synchronized”与 synchronized相比,volatile所需的编码较少,且运行时开销也较少但它所能实现的功能也只是synchronized的一部分。

我们知道,每个线程操作共享变量时,都会先从主内存中将共享变量拷贝到线程的本地内存作为副本。线程操作变量副本并写回主内存后,如果该共享变量被volatile修饰了,就会通过 CPU的总线嗅探机制 告知其他线程该变量副本已经失效,从而需要重新从主内存中读取。

所以volatile关键字主要用于确保被volatile修饰的变量,在每个线程中都能获取到该变量的最新值,从而避免出现数据脏读的现象。所以 volatile关键字 就保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回到主内存时,其他线程能立即看到最新值。

另外volatile还可以禁止指令重排序,保证了操作的有序性。

大家注意,volatile并不能保证原子性,所以volatile修饰的变量只适合作为状态标记量,而不适合进行“++”这样的自增操作。

这些可见性、指令重排序等概念,到底是什么鬼,先别头疼,在下面的章节中,壹哥都会详细地给大家介绍。只要你跟着我的思路往下走,保你都能把这些内容吃透。

5.2 volatile基本原理

volatile之所以可以确保线程的可见性,主要是通过 内存屏障和禁止指令重排序 来实现的。volatile在写操作时,会在写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障,将本地内存中的共享变量副本值刷新到主内存,然后CPU的总线嗅探机制会告知其他线程变量副本已失效,需要重新从主内存中读取共享变量。

volatile的具体实现过程大致如下:

我们可以参考下图来进行理解:

5.3 内存屏障

上面5.2小节中,壹哥给大家提到了内存屏障的概念,这里我再对其做一个简单介绍,毕竟有些童鞋之前可能没有了解过这个概念。如果你想对此有更深入的了解,可以问度娘查找更多资料,我这里只是简单介绍一下。

5.3.1 内存屏障指令

我们现在应该知道,为了避免每次都直接从内存中读取数据,提高数据加载性能,所以每个CPU内核中都会有自己的缓存。但这样也会存在一定的弊端,即不能实时的和内存进行信息交互,不同CPU内核中执行的不同线程得到的共享变量缓存值可能就会不同。

所以volatile就使用内存屏障来解决这一问题,这里所谓的 内存屏障 其实是硬件层的概念。不同的硬件平台实现内存屏障的手段是不一样的,而Java通过跨平台屏蔽了这些不同硬件平台的差异,统一由JVM来生成内存屏障的指令。

整体来看,内存屏障指令可以分为2大类:

但在Java中,我们又把这2种内存屏障,具体细分成了4种:

我们仔细观察一下,就会发现以上4种指令,其实就是对上述2种读、写屏障命令的组合,这4种屏障指令可以完成一系列的屏障和数据同步功能,其具体作用如下表所示:

5.3.2 内存屏障策略

volatile就是利用上面这4种具体的指令,会严格保守地执行如下内存屏障策略:

5.3.3 内存屏障作用

所以从上面的介绍来看,内存屏障指令有2大作用:

对于面试而言,我们记住内存屏障的作用即可。

5.4 禁止指令重排序

我们知道,volatile是通过 内存屏障和禁止指令重排序 来实现线程之间的数据可见性的,我们已经对内存屏障有了基本的了解,那什么是指令重排序呢?为什么会有指令重排序。

5.4.1 指令重排序由来

为了给大家讲清楚为什么要有指令重排序,壹哥设计了如下一段代码:

int a = 0;

//线程 A
a = 1; //代码1

flag = true; //代码2

//线程 B
if (flag) { //代码3
int i = a; //代码4
}

上面的代码,从代码编写顺序上看,"代码1" 是在 "代码2" 前面的,那么JVM在真正执行这段代码时,会保证 "代码1" 一定是在 "代码2"前面执行吗?不一定!为什么呢?因为编译器和处理器为了提高性能,可能会对输入的代码进行优化它不保证程序中各个语句的执行顺序同代码的编写顺序一致,但它会保证程序最终的执行结果和代码顺序的执行结果一致。这就是所谓的代码指令重排序(Instruction Reorder),也就是说可能会打乱固定的代码执行顺序。

这里重排序之后,之所以可以保证程序最终的结果会和代码执行顺序结果相同,主要是取决于数据的依赖性:编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

总之不管怎么样,如果是在单线程环境下执行,这里最终的执行结果应该都不会有什么问题,最后 i 的结果都是 1。

但在多线程环境下,假设此时有个线程 A,在执行时有可能会被重排序成先执行 “代码2",再执行 "代码 1",也就是不一定会优先执行 "代码1"。

而与此同时,另一个线程B,可能会在线程A 执行完 "代码2" 后,就恰好立即读取了 flag变量 的值。此时由于条件判断为真,线程B 将读取 变量a。但此时 变量a 还根本没有被线程A 写入数据,那么 i 最后的结果就是 0,而不是正常的 1,所以就导致执行结果不正确。

那我们该如何确保这段程序执行得到正确的结果呢?这里就需要使用 volatile 关键字了!

因为 volatile关键字,不仅可以保证变量的内存可见性,还可以通过内存屏障指令,禁止指令的重排序。也就是volatile可以保证其所修饰的变量,在编译后得到的顺序与程序的执行顺序一样。所以如果我们使用 volatile关键字来修饰 flag 变量后,在线程A 中,就可以保证 "代码1" 的执行顺序一定在是 "代码2" 之前。

5.4.2 指令重排序概念

所以,我们这里所谓的指令重排序,指的是 为了提高程序执行性能,在遵守 as-if-serial 语义(即不管怎么重排序,单线程下程序的执行结果不会被改变,编译器、虚拟机和处理器都会遵守)的情况下,编译器和处理器通常会对指令进行重新规划排序。

一般重排序有如下3种类型:

一段Java代码,从编写、编译到最后被执行,会分别经历下面3种重排序:

5.5 CPU总线嗅探机制

同样的,在上面5.2小节中,壹哥 给大家还提到了另一个“总线嗅探机制”的概念,这里我也对其进行简单介绍。

5.5.1 缓存机制

我们知道,计算机中的CPU运算速度极快,而内存相对CPU来说就很慢,两者之间速度不匹配,就会造成CPU的极大的浪费。所以为了提高CPU的处理速度,会在CPU和内存之间添加很多寄存器,作为多级缓存,比如L1、L2、L3级缓存。这些寄存器比内存的存取速度高得多,这样就在CPU和内存之间起到了缓冲的效果,解决了 CPU 运算速度和内存读取速度不一致问题。如下图所示:

5.5.2 缓存一致性协议

但由于 CPU 与 内存 之间加入了多级缓存,在进行数据读写时,会先将数据从内存拷贝到多级缓存中,然后再将多级缓存中的数据传递给CPU。但在多核CPU处理器中,因为每个CPU内核中都有多级缓存,就有可能会导致不同CPU内核里多级缓存中的数据不一致(这也是可见性问题的由来)。所以为了保证各个处理器中缓存的数据是一致的,就需要遵循一个 缓存一致性协议,而 CPU嗅探机制 就是实现 缓存一致性协议 的常见机制

5.5.3 CPU总线嗅探机制

我们现在知道,CPU总线嗅探机制,其实是对缓存一致性协议的一种具体实现方案,它的工作原理如下:

基于 CPU 的缓存一致性协议,JVM 实现了 volatile 的可见性。但总线嗅探机制会不断的监听CPU总线,如果我们的代码中大量使用 volatile,有可能会引起总线风暴。所以,volatile 的使用要结合具体场景。

5.6 volatile存在的问题

至此,我们已经知道,volatile可以确保线程并发操作时的数据可见性,且进行读操作时所消耗的性能与普通变量几乎相同。但它也并不是完美的,存在着一定的问题:

要解决volatile存在的原子性问题,我们可以结合之前讲过的锁机制,或者使用原子类(如 AtomicInteger)。

三. 结语

至此,壹哥 就带各位复习了如何保证线程的可见性,并且带各位掌握了volatile的底层原理、内存屏障及其指令,还有CPU总线嗅探机制等核心内容,现在你学会了吗?如果各位有不明白的地方,可以在评论区给壹哥留言。下一篇文章中,壹哥会继续讲解保证线程安全的第3种手段,即保证线程安全的有序性,敬请期待哦!

举报

相关推荐

0 条评论