文章来源:https://mp.weixin.qq.com/s/R1D5tfuMVL-v8qQlZvPhmA
作者;安琪拉的博客
volatile 应该算是Java 后端面试的必考题,因为多线程编程基本绕不开它,很适合作为并发编程的入门题。
面试官:你先自我介绍一下吧!
安琪拉: 我是安琪拉,草丛三婊之一,最强中单(钟馗不服)!哦,不对,串场了,我是**,目前在–公司做–系统开发。
面试官: 看你简历上写熟悉并发编程,volatile 用过的吧?
安琪拉: 用过的。(还是熟悉的味道)
面试官: 那你跟我讲讲什么时候会用到 volatile ?
安琪拉: 如果需要保证多线程共享变量的可见性时,可以使用volatile 来修饰变量。
面试官: 什么是共享变量的可见性?
安琪拉: 多线程并发编程中主要围绕着三个特性实现。可见性是其中一种!
面试官: volatile 除了解决共享变量的可见性,还有别的作用吗?
安琪拉: volatile 除了让共享变量具有可见性,还具有有序性(禁止指令重排序)。
面试官: 你先跟我举几个实际volatile 实际项目中的例子?
安琪拉: 可以的。有个特别常见的例子:状态标志
比如我们工程中经常用一个变量标识程序是否启动、初始化完成、是否停止等,如下:
volatile 很适合只有一个线程修改,其他线程读取的情况。volatile 变量被修改之后,对其他线程立即可见。
面试官: 现在我们来看一下你的例子,如果不加volatile 修饰,会有什么后果?
安琪拉: 比如这是一个带前端交互的系统,有A、 B二个线程,用户点了停止应用按钮,A 线程调用shutdown() 方法,让变量shutdown 从false 变成 true,但是因为没有使用volatile 修饰, B 线程可能感知不到shutdown 的变化,而继续执行 doWork 内的循环,这样违背了程序的意愿:当shutdown 变量为true 时,代表应用该停下了,doWork函数应该跳出循环,不再执行。
面试官: volatile还有别的应用场景吗?
安琪拉: 懒汉式单例模式,我们常用的 double-check 的单例模式,如下所示:
使用volatile 修饰保证 singleton 的实例化能够对所有线程立即可见。
面试官: 我们再来看你的单例模式的例子,我有三个问题:
安琪拉: 【心里炸了,举单例模式例子简直给自己挖坑】这三个问题,我来一个个回答:
volatile 只保证了共享变量 singleton 的可见性,但是 singleton = new Singleton(); 这个操作不是原子的,可以分为三步:
步骤1:在堆内存申请一块内存空间;
步骤2:初始化申请好的内存空间;
步骤3:将内存空间的地址赋值给 singleton;
所以singleton = new Singleton(); 是一个由三步操作组成的复合操作,多线程环境下A 线程执行了第一步、第二步之后发生线程切换,B 线程开始执行第一步、第二步、第三步(因为A 线程singleton 是还没有赋值的),所以为了保障这三个步骤不可中断,可以使用synchronized 在这段代码块上加锁。(synchronized 原理参考《安琪拉与面试官二三事》系列第二篇文章)
A 线程进行判空检查之后开始执行synchronized代码块时发生线程切换(线程切换可能发生在任何时候),B 线程也进行判空检查,B线程检查 singleton == null 结果为true,也开始执行synchronized代码块,虽然synchronized 会让二个线程串行执行,如果synchronized代码块内部不进行二次判空检查,singleton 可能会初始化二次。
volatile 修饰的变量除了可见性,还能防止指令重排序。
指令重排序 是编译器和处理器为了优化程序执行的性能而对指令序列进行重排的一种手段。现象就是CPU 执行指令的顺序可能和程序代码的顺序不一致,例如 a = 1; b = 2; 可能 CPU 先执行b=2; 后执行a=1;
singleton = new Singleton(); 由三步操作组合而成,如果不使用volatile 修饰,可能发生指令重排序。步骤3 在步骤2 之前执行,singleton 引用的是还没有被初始化的内存空间,别的线程调用单例的方法就会引发未被初始化的错误。
指令重排序也遵循一定的规则:
因此volatile 还有禁止指令重排序的作用。
面试官: 那为什么不加volatile ,A 线程对共享变量的修改,其他线程不可见呢?你知道volatile的底层原理吗?
安琪拉: 果然该来的还是来了,我要放大招了,您坐稳咯!
面试官: 我靠在椅子上,稳的很,请开始你的表演!
安琪拉: 先说结论,我们知道volatile可以实现内存的可见性和防止指令重排序,但是volatile 不保证操作的原子性。那么volatile是怎么实现可见性和有序性的呢?其实volatile的这些内存语意是通过内存屏障技术实现的。
面试官: 那你跟我讲讲内存屏障。
安琪拉: 讲内存屏障的话,这块内容会比较深,我以下面的顺序讲,这个整个知识成体系,不散:
安琪拉: 一切要从盘古开天辟地说起,女娲补天!咳咳,不好意思,扯远了!一切从冯洛伊曼计算机体系开始说起!
面试官: 扯的是不是有点远!
安琪拉: 你就说要不要听?要听别打断我!
面试官: 得嘞!您请讲!
安琪拉: 下图就是经典的 冯洛伊曼体系结构,基本把计算机的组成模块都定义好了,现在的计算机都是以这个体系弄的,其中最核心的就是由运算器和控制器组成的中央处理器,就是我们常说的CPU。
面试官: 这个跟 volatile 有什么关系?
安琪拉: 不要着急嘛!理解技术不要死盯着技术的细枝末节,要思考这个技术产生的历史背景和原因,思考发明这个技术的人当时是遇到了什么问题?而发明这个技术的。这样即理解深刻,也让自己思考问题更宏观,更有深度!这叫从历史的角度看问题,站在巨人的肩膀上!
面试官: 来来来,今天你教我做人!
安琪拉: 刚才说到冯洛伊曼体系中的CPU,你应该听过摩尔定律吧!就是英特尔创始人戈登·摩尔讲的:
集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。
面试官: 听过的,然后呢?
安琪拉:所以你看到我们电脑CPU 的性能越来越强劲,英特尔CPU 从Intel Core 一直到 Intel Core i7,前些年单核CPU 的晶体管数量确实符合摩尔定律,看下面这张图。
横轴为新CPU发明的年份,纵轴为可容纳晶体管的对数。所有的点近似成一条直线,这意味着晶体管数目随年份呈指数变化,大概每两年翻一番。
面试官: 后来呢?这和今天说的 volatile,以及内存屏障有什么关系?
安琪拉:别着急啊!后来摩尔定律越来越撑不住了,但是更新换代的程序对电脑性能的期望和要求还在不断上涨,就出现了下面的剧情。
他为其Pentium 4新一代芯片取消上市而道歉, 近几年来,英特尔不断地在增加其处理器的运行速度。当前最快的一款,其速度已达3.4GHz,虽然强化处理器的运行速度,也增强了芯片运作效能,但速度提升却使得芯片的能源消耗量增加,并衍生出冷却芯片的问题。
因此,英特尔摒弃将心力集中在提升运行速度的做法,在未来几年,将其芯片转为以多模核心(multi-core)的方式设计等其他方式,来提升芯片的表现。多模核心的设计法是将多模核心置入单一芯片中。如此一来,这些核心芯片即能以较缓慢的速度运转,除了可减少运转消耗的能量,也能减少运转生成的热量。此外,集众核心芯片之力,可提供较单一核心芯片更大的处理能力。 —《经济学人》
安琪拉:当然上面贝瑞特当然只是在开玩笑,眼看摩尔定律撑不住了,后来怎么处理的呢?一颗CPU 不行,我们多来几颗嘛!这就是现在我们常见的多核CPU,四核8G 听着熟悉不熟悉?当然完全依据冯洛伊曼体系设计的计算机也是有缺陷的!
面试官: 什么缺陷?说说看。
安琪拉:CPU 运算器的运算速度远比内存读写速度快,所以CPU 大部分时间都在等数据从内存读取,运算完数据写回内存。
面试官: 那怎么解决?
安琪拉:因为CPU 运行速度实在太快,主存(就是内存)的数据读取速度和CPU 运算速度差了有几个数量级,因此现代计算机系统通过在CPU 和主存之前加了一层读写速度尽可能接近CPU 运行速度的高速缓存来做数据缓冲,这样缓存提前从主存获取数据,CPU 不再从主存取数据,而是从缓存取数据。这样就缓解由于主存速度太慢导致的CPU 饥饿的问题。同时CPU 内还有寄存器,一些计算的中间结果临时放在寄存器内。
面试官: 既然你提到缓存,那我问你一个问题,CPU 从缓存读取数据和从内存读取数据除了读取速度的差异?有什么本质的区别吗?不都是读数据写数据,而且加缓存会让整个体系结构变得更加复杂。
安琪拉:缓存和主存不仅仅是读取写入数据速度上的差异,还有另外更大的区别:研究人员发现了程序80%的时间在运行20% 的代码,所以缓存本质上只要把20%的常用数据和指令放进来就可以了(是不是和Redis 存放热点数据很像),另外CPU 访问主存数据时存在二个局部性现象:
如果一个主存数据正在被访问,那么在近期它被再次访问的概率非常大。想想你程序大部分时间是不是在运行主流程。
CPU使用到某块内存区域数据,这块内存区域后面临近的数据很大概率立即会被使用到。这个很好解释,我们程序经常用的数组、集合(本质也是数组)经常会顺序访问(内存地址连续或邻近)。
因为这二个局部性现象的存在使得缓存的存在可以很大程度上缓解CPU 饥饿的问题。
面试官: 讲的是那么回事,那能给我画一下现在CPU、缓存、主存的关系图吗?
安琪拉:可以。我们来看下现在主流的多核CPU的硬件架构,如下图所示。
安琪拉:现代操作系统一般会有多级缓存(Cache Line),一般有L1、L2,甚至有L3,看下安琪拉的电脑缓存信息,一共4核,三级缓存,L1 缓存(在CPU核心内)这里没有显示出来,这里L2 缓存后面括号标识了是每个核都有L2 缓存,而L3 缓存没有标识,是因为L3 缓存是4个核共享的缓存:
面试官: 那你能跟我简单讲讲程序运行时,数据是怎么在主存、缓存、CPU寄存器之间流转的吗?
安琪拉:可以。比如以 i = i + 2; 为例, 当线程执行到这条语句时,会先从主存中读取i 的值,然后复制一份到缓存中,CPU 读取缓存数据(取数指令),进行 i + 2 操作(中间数据放寄存器),然后把结果写入缓存,最后将缓存中i最新的值刷新到主存当中(写回时间不确定)。
面试官: 这个数据操作逻辑在单线程环境和多线程环境下有什么区别?
安琪拉: 比如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缓存不一致的问题导致,而是CPU调度线程切换,多线程局部变量不同步引起的。
执行过程如下图:
面试官: 那CPU 怎么解决缓存一致性问题呢?
安琪拉:早期的一些CPU 设计中,是通过锁总线(总线访问加Lock# 锁)的方式解决的。看下CPU 体系结构图,如下:
因为CPU 都是通过总线来读取主存中的数据,因此对总线加Lock# 锁的话,其他CPU 访问主存就被阻塞了,这样防止了对共享变量的竞争。但是锁总线对CPU的性能损耗非常大,把多核CPU 并行的优势直接给干没了!
后面研究人员就搞出了一套协议:缓存一致性协议。协议的类型很多(MSI、MESI、MOSI、Synapse、Firefly),最常见的就是Intel 的MESI 协议。缓存一致性协议主要规范了CPU 读写主存、管理缓存数据的一系列规范,如下图所示。
面试官: 那讲讲 **MESI **协议呗!
安琪拉: (MESI这部分内容可以只了解大概思想,不用深究,因为东西多到可以单独成一篇文章了)
MESI 协议的核心思想:
缓存中数据都是以缓存行(Cache Line)为单位存储;MESI 各个状态描述如下表所示:
面试官: 那我问你MESI 协议和volatile实现的内存可见性时什么关系?
安琪拉: volatile 和MESI 中间差了好几层抽象,中间会经历java编译器,java虚拟机和JIT,操作系统,CPU核心。
volatile 是Java 中标识变量可见性的关键字,说直接点:使用volatile 修饰的变量是有内存可见性的,这是Java 语法定的,Java 不关心你底层操作系统、硬件CPU 是如何实现内存可见的,我的语法规定就是volatile 修饰的变量必须是具有可见性的。
CPU 有X86(复杂指令集)、ARM(精简指令集)等体系架构,版本类型也有很多种,CPU 可能通过锁总线、MESI 协议实现多核心缓存的一致性。因为有硬件的差异以及编译器和处理器的指令重排优化的存在,所以Java 需要一种协议来规避硬件平台的差异,保障同一段代表在所有平台运行效果一致,这个协议叫做Java 内存模型(Java Memory Model)。
面试官: 你能详细讲讲Java 内存模型吗?
安琪拉: JMM 全称 Java Memory Model, 是 Java 中非常重要的一个概念,是Java 并发编程的核心和基础。JMM 是Java 定义的一套协议,用来屏蔽各种硬件和操作系统的内存访问差异,让Java 程序在各种平台都能有一致的运行效果。
协议这个词都不会陌生,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 内存模型,理解的应该更深刻了,如下图所示:
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 中提供了一些关键字来解决。
实现原理:上面说的happens-before原则保障可见性,禁止指令重排保证有序性,如何实现的呢?
Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,保证共享变量操作的有序性。
内存屏障指令:写操作的会让线程本地的共享内存变量写完强制刷新到主存。读操作让本地线程变量无效,强制从主内存读取,保证了共享内存变量的可见性。
JVM中提供了四类内存屏障指令:
JSR-133 定义的相应的内存屏障,在第一步操作(列)和第二步操作(行)之间需要的内存屏障指令如下:
Java volatile 例子:
以下是区分各个CPU体系支持的内存屏障(也叫内存栅栏),由JVM 实现平台无关(volatile所有平台表现一致)
synchronized 也可以实现有序性和可见性,但是是通过锁让并发串行化实现有序,内存屏障实现可见。
作为Java 程序员的我们只需要写一堆 ***.java 文件,编译器把 .java 文件编译成 .class 字节码文件,后面的事就都交给Java 虚拟机(JVM)做了。如下图所示, Java虚拟机是区分平台的,虚拟机来进行 .class 字节码指令翻译成平台相关的机器码。
所以 Java 是跨平台的,Java 虚拟机(JVM)不是跨平台的,JVM 是平台相关的。大家可以看 Hostpot1.8 源码文件夹,JVM 每个系统都有单独的实现,如下图所示:
As-if-serial
As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。
现代操作系统,现代操作系统都是按时间片调度执行的,最小的调度执行单元是线程,多任务和并行处理能力是衡量一台计算机处理器的非常重要的指标。这里有个概念要说一下:
JSR-133 对应规则需要的规则
另外 final 关键字需要 StoreStore 屏障
x.finalField = v; StoreStore; sharedRef = x;
MESI 协议运作的具体流程,举个实例
第一列是操作序列号,第二列是执行操作的CPU,第三列是具体执行哪一种操作,第四列描述了各个cpu local cache中的cacheline的状态(用meory address/状态表示),最后一列描述了内存在0地址和8地址的数据内容的状态:V表示是最新的,和cache一致,I表示不是最新的内容,最新的内容保存在cache中。
Java 内存模型(JSR-133)屏蔽了硬件、操作系统的差异,实现让Java程序在各种平台下都能达到一致的并发效果,规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量,JMM使用内存屏障提供了java程序运行时统一的内存模型。
volatile可以实现内存的可见性和防止指令重排序。
通过内存屏障技术实现的。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障指令,内存屏障效果有:
volatile 是Java 提供的一种轻量级同步机制,可以保证共享变量的可见性和有序性(禁止指令重排),常用于状态标志、双重检查的单例等场景。
使用原则:
volatile的使用场景不是很多,使用时需要仔细考虑下是否适用volatile,注意满足上面的二个原则。
单个的共享变量的读/写(比如a=1)具有原子性,但是像num++或者a=b+1;这种复合操作,volatile无法保证其原子性;
对了,在这里说一下,我目前是在职Java开发,如果你现在正在学习Java,了解Java,渴望成为一名合格的Java开发工程师,在入门学习Java的过程当中缺乏基础入门的视频教程,可以关注并私信我:01。获取。我这里有最新的Java基础全套视频教程。