前言volatile 应该算是Java 后端面试的必考题,因为多线程编程基本绕不开它,很适合作为并发编程的入门题。 开场面试官:你先自我介绍一下吧! 安琪拉: 我是安琪拉,草丛三婊之一,最强中单(钟馗不服)!哦,不对,串场了,我是**,目前在–公司做–系统开发。 面试官: 看你简历上写熟悉并发编程,volatile 用过的吧? 安琪拉: 用过的。(还是熟悉的味道) 面试官: 那你跟我讲讲什么时候会用到 volatile ? 安琪拉: 如果需要保证多线程共享变量的可见性时,可以使用volatile 来修饰变量。 面试官: 什么是共享变量的可见性? 安琪拉: 多线程并发编程中主要围绕着三个特性实现。可见性是其中一种!
面试官: volatile 除了解决共享变量的可见性,还有别的作用吗? 安琪拉: volatile 除了让共享变量具有可见性,还具有有序性(禁止指令重排序)。 面试官: 你先跟我举几个实际volatile 实际项目中的例子? 安琪拉: 可以的。有个特别常见的例子:
面试官: 现在我们来看一下你的例子,如果不加volatile 修饰,会有什么后果? 安琪拉: 比如这是一个带前端交互的系统,有A、 B二个线程,用户点了停止应用按钮,A 线程调用 面试官: volatile还有别的应用场景吗? 安琪拉: 懒汉式单例模式,我们常用的 double-check 的单例模式,如下所示: 使用volatile 修饰保证 singleton 的实例化能够对所有线程立即可见。 面试官: 我们再来看你的单例模式的例子,我有三个问题:
安琪拉: 【心里炸了,举单例模式例子简直给自己挖坑】这三个问题,我来一个个回答:
面试官: 那为什么不加volatile ,A 线程对共享变量的修改,其他线程不可见呢?你知道volatile的底层原理吗? 安琪拉: 果然该来的还是来了,我要放大招了,您坐稳咯! 面试官: 我靠在椅子上,稳的很,请开始你的表演! 安琪拉: 先说结论,我们知道volatile可以实现内存的可见性和防止指令重排序,但是volatile 不保证操作的原子性。那么volatile是怎么实现可见性和有序性的呢?其实volatile的这些内存语意是通过内存屏障技术实现的。 面试官: 那你跟我讲讲内存屏障。 安琪拉: 讲内存屏障的话,这块内容会比较深,我以下面的顺序讲,这个整个知识成体系,不散:
现代CPU 架构的形成安琪拉: 一切要从盘古开天辟地说起,女娲补天!咳咳,不好意思,扯远了!一切从冯洛伊曼计算机体系开始说起! 面试官: 扯的是不是有点远! 安琪拉: 你就说要不要听?要听别打断我! 面试官: 得嘞!您请讲! 安琪拉: 下图就是经典的 冯洛伊曼体系结构,基本把计算机的组成模块都定义好了,现在的计算机都是以这个体系弄的,其中最核心的就是由运算器和控制器组成的中央处理器,就是我们常说的CPU。 面试官: 这个跟 volatile 有什么关系? 安琪拉: 不要着急嘛!理解技术不要死盯着技术的细枝末节,要思考这个技术产生的历史背景和原因,思考发明这个技术的人当时是遇到了什么问题?而发明这个技术的。这样即理解深刻,也让自己思考问题更宏观,更有深度!这叫从历史的角度看问题,站在巨人的肩膀上! 面试官: 来来来,今天你教我做人! 安琪拉: 刚才说到冯洛伊曼体系中的CPU,你应该听过摩尔定律吧!就是英特尔创始人戈登·摩尔讲的:
面试官: 听过的,然后呢? 安琪拉:所以你看到我们电脑CPU 的性能越来越强劲,英特尔CPU 从Intel Core 一直到 Intel Core i7,前些年单核CPU 的晶体管数量确实符合摩尔定律,看下面这张图。 横轴为新CPU发明的年份,纵轴为可容纳晶体管的对数。所有的点近似成一条直线,这意味着晶体管数目随年份呈指数变化,大概每两年翻一番。 面试官: 后来呢?这和今天说的 volatile,以及内存屏障有什么关系? 安琪拉:别着急啊!后来摩尔定律越来越撑不住了,但是更新换代的程序对电脑性能的期望和要求还在不断上涨,就出现了下面的剧情。
安琪拉:当然上面贝瑞特当然只是在开玩笑,眼看摩尔定律撑不住了,后来怎么处理的呢?一颗CPU 不行,我们多来几颗嘛!这就是现在我们常见的多核CPU,四核8G 听着熟悉不熟悉?当然完全依据冯洛伊曼体系设计的计算机也是有缺陷的! 面试官: 什么缺陷?说说看。 安琪拉:CPU 运算器的运算速度远比内存读写速度快,所以CPU 大部分时间都在等数据从内存读取,运算完数据写回内存。 面试官: 那怎么解决? 安琪拉:因为CPU 运行速度实在太快,主存(就是内存)的数据读取速度和CPU 运算速度差了有几个数量级,因此现代计算机系统通过在CPU 和主存之前加了一层读写速度尽可能接近CPU 运行速度的高速缓存来做数据缓冲,这样缓存提前从主存获取数据,CPU 不再从主存取数据,而是从缓存取数据。这样就缓解由于主存速度太慢导致的CPU 饥饿的问题。同时CPU 内还有寄存器,一些计算的中间结果临时放在寄存器内。 面试官: 既然你提到缓存,那我问你一个问题,CPU 从缓存读取数据和从内存读取数据除了读取速度的差异?有什么本质的区别吗?不都是读数据写数据,而且加缓存会让整个体系结构变得更加复杂。 安琪拉:缓存和主存不仅仅是读取写入数据速度上的差异,还有另外更大的区别:研究人员发现了程序80%的时间在运行20% 的代码,所以缓存本质上只要把20%的常用数据和指令放进来就可以了(是不是和Redis 存放热点数据很像),另外CPU 访问主存数据时存在二个局部性现象:
因为这二个局部性现象的存在使得缓存的存在可以很大程度上缓解CPU 饥饿的问题。 面试官: 讲的是那么回事,那能给我画一下现在CPU、缓存、主存的关系图吗? 安琪拉:可以。我们来看下现在主流的多核CPU的硬件架构,如下图所示。 安琪拉:现代操作系统一般会有多级缓存(Cache Line),一般有L1、L2,甚至有L3,看下安琪拉的电脑缓存信息,一共4核,三级缓存,L1 缓存(在CPU核心内)这里没有显示出来,这里L2 缓存后面括号标识了是每个核都有L2 缓存,而L3 缓存没有标识,是因为L3 缓存是4个核共享的缓存: 面试官: 那你能跟我简单讲讲程序运行时,数据是怎么在主存、缓存、CPU寄存器之间流转的吗? 安琪拉:可以。比如以 面试官: 这个数据操作逻辑在单线程环境和多线程环境下有什么区别? 安琪拉: 比如i 如果是共享变量(例如对象的成员变量),单线程运行没有任何问题,但是多线程中运行就有可能出问题。例如:有A、B二个线程,在不同的CPU 上运行,因为每个线程运行的CPU 都有自己的缓存,A 线程从内存读取i 的值存入缓存,B 线程此时也读取i 的值存入自己的缓存,A 线程对i 进行+1操作,i变成了1,B线程缓存中的变量 i 还是0,B线程也对i 进行+1操作,最后A、B线程先后将缓存数据写入内存,内存预期正确的结果应该是2,但是实际是1。这个就是非常著名的缓存一致性问题。
执行过程如下图: 面试官: 那CPU 怎么解决缓存一致性问题呢? 安琪拉:早期的一些CPU 设计中,是通过锁总线(总线访问加Lock# 锁)的方式解决的。看下CPU 体系结构图,如下: ![]() 因为CPU 都是通过总线来读取主存中的数据,因此对总线加Lock# 锁的话,其他CPU 访问主存就被阻塞了,这样防止了对共享变量的竞争。但是锁总线对CPU的性能损耗非常大,把多核CPU 并行的优势直接给干没了! 后面研究人员就搞出了一套协议:缓存一致性协议。协议的类型很多(MSI、MESI、MOSI、Synapse、Firefly),最常见的就是Intel 的MESI 协议。缓存一致性协议主要规范了CPU 读写主存、管理缓存数据的一系列规范,如下图所示。 ![]() 面试官: 那讲讲 **MESI **协议呗! 安琪拉: (MESI这部分内容可以只了解大概思想,不用深究,因为东西多到可以单独成一篇文章了) MESI 协议的核心思想:
![]() 面试官: 那我问你MESI 协议和volatile实现的内存可见性时什么关系? 安琪拉: volatile 和MESI 中间差了好几层抽象,中间会经历java编译器,java虚拟机和JIT,操作系统,CPU核心。 volatile 是Java 中标识变量可见性的关键字,说直接点:使用volatile 修饰的变量是有内存可见性的,这是Java 语法定的,Java 不关心你底层操作系统、硬件CPU 是如何实现内存可见的,我的语法规定就是volatile 修饰的变量必须是具有可见性的。
Java 内存模型(JMM)面试官: 你能详细讲讲Java 内存模型吗? 安琪拉: JMM 全称 协议这个词都不会陌生,HTTP 协议、TCP 协议等。JMM 协议就是一套规范,具体的内容为:
面试官: 你刚才提到每个线程都有自己的工作内存,问个深入一点的问题,线程的工作内存在主存还是缓存中? 安琪拉: 这个问题非常棒!JMM 中定义的每个线程私有的工作内存是抽象的规范,实际上工作内存和真实的CPU 内存架构如下所示,Java 内存模型和真实硬件内存架构是不同的: ![]() JMM 是内存模型,是抽象的协议。首先真实的内存架构是没有区分堆和栈的,这个Java 的JVM 来做的划分,另外线程私有的本地内存线程栈可能包括CPU 寄存器、缓存和主存。堆亦是如此! 面试官: 能具体讲讲JMM 内存模型规范吗? 安琪拉: 可以。前面已经讲了线程本地内存和物理真实内存之间的关系,说的详细些:
![]() 面试官: 那JMM 模型中多线程如何通过共享变量通信呢? 安琪拉: 线程间通信必须要经过主内存。 线程A与线程B之间要通信的话,必须要经历下面2个步骤: 1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。 2)线程B到主内存中去读取线程A之前已更新过的共享变量。 ![]() 关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成:
我们编译一段Java code 看一下。 代码和字节码指令分别为: ![]() ![]() ![]() Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
面试官: 听下来 Java 内存模型真的内容很多,那Java 内存模型是如何保障你上面说的这些规则的呢? 安琪拉: 这就是接下来要说的底层实现原理了,上面叨逼叨说了一堆概念和规范,需要慢慢消化。 Java 通过 Java 内存模型(JMM )实现 volatile 平台无关安琪拉: 我们前面说 并发编程实际就是围绕三个特性的实现展开的:
面试官: 对的。前面已经说过了。我怎么感觉我想是捧哏。😁 安琪拉: 前面我们已经说过共享变量不可见的问题,讲完Java 内存模型,理解的应该更深刻了,如下图所示: ![]() 1. 可见性问题:如果对象obj 没有使用volatile 修饰,A 线程在将对象count读取到本地内存,从1修改为2,B 线程也把obj 读取到本地内存,因为A 线程的修改对B 线程不可见,这是从Java 内存模型层面看可见性问题(前面从物理内存结构分析的)。 2. 有序性问题:重排序发生的地方有很多,编译器优化、CPU 因为指令流水批处理而重排序、内存因为缓存以及store buffer 而显得乱序执行。如下图所示: ![]() 附一张带store buffer (写缓冲)的CPU 架构图,希望详细了解store buffer 可以看文章最后面的扩展阅读。 ![]() 每个处理器上的Store Buffer(写缓冲区),仅仅对它所在的处理器可见。这会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序: 下图是各种CPU 架构允许的指令重排序的情况。 ![]() 3. 原子性问题:例如多线程并发执行 i = i +1。i 是共享变量,看完Java 内存模型,知道这个操作不是原子的,可以分为+1 操作和赋值操作。因此多线程并发访问时,可能发生线程切换,造成不是预期结果。 针对上面的三个问题,Java 中提供了一些关键字来解决。
扩展阅读Java如何实现跨平台作为Java 程序员的我们只需要写一堆 ***.java 文件,编译器把 .java 文件编译成 .class 字节码文件,后面的事就都交给Java 虚拟机(JVM)做了。如下图所示, Java虚拟机是区分平台的,虚拟机来进行 .class 字节码指令翻译成平台相关的机器码。 ![]() 所以 Java 是跨平台的,Java 虚拟机(JVM)不是跨平台的,JVM 是平台相关的。大家可以看 Hostpot1.8 源码文件夹,JVM 每个系统都有单独的实现,如下图所示: ![]() As-if-serialAs-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。 并发&并行现代操作系统,现代操作系统都是按时间片调度执行的,最小的调度执行单元是线程,多任务和并行处理能力是衡量一台计算机处理器的非常重要的指标。这里有个概念要说一下:
内存屏障JSR-133 对应规则需要的规则 ![]() 另外 final 关键字需要 StoreStore 屏障
MESI 协议运作模式MESI 协议运作的具体流程,举个实例 ![]() 第一列是操作序列号,第二列是执行操作的CPU,第三列是具体执行哪一种操作,第四列描述了各个cpu local cache中的cacheline的状态(用meory address/状态表示),最后一列描述了内存在0地址和8地址的数据内容的状态:V表示是最新的,和cache一致,I表示不是最新的内容,最新的内容保存在cache中。 总结篇Java内存模型Java 内存模型(JSR-133)屏蔽了硬件、操作系统的差异,实现让Java程序在各种平台下都能达到一致的并发效果,规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量,JMM使用内存屏障提供了java程序运行时统一的内存模型。 volatile的实现原理volatile可以实现内存的可见性和防止指令重排序。 通过内存屏障技术实现的。 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障指令,内存屏障效果有:
volatile使用总结
各类知识点总结
|
|