当面试官问起Java内存模型
点击上方“服务端思维”,选择“设为星标”
回复”669“获取独家整理的精选资料集
回复”加群“加入全国服务端高端社群「后端圈」
《并发与高并发系列第三集-Java内存模型》
这篇是并发编程的第三篇,大纲和前几篇如下:
面试官:你好,你先自我介绍一下。
安琪拉:面试官你好,我叫安琪拉,草丛三婊,最强中单,草地摩托车车手,第21套广播体操推广者,火球拥有者、不焚者,安琪拉,这是我的简历,请过目。
面试官:看你简历上写熟悉多线程编程,跟我讲讲Java内存模型。
安琪拉:讲Java内存模型前我希望给您讲一个故事,从CPU的发展史说起。
面试官:我喜欢听故事,你说吧。
安琪拉: 先说现代CPU 架构的形成,一切要从冯洛伊曼计算机体系开始说起!
面试官: 扯的是不是有点远,你能不能快点进入主题!
安琪拉: 你对一个从上海开3个小时车来杭州,真心诚意求职的人就这么没有耐心的。
面试官: 孽缘,真是孽缘。你讲吧。
安琪拉: 下图就是经典的 冯洛伊曼体系结构,基本把计算机的组成模块都定义好了,现在的计算机都是以这个体系弄的,其中最核心的就是由运算器和控制器组成的中央处理器,就是我们常说的CPU。
冯洛伊曼体系结构面试官: 这个跟Java内存模型有什么关系?
安琪拉: 不要着急嘛!
安琪拉: 刚才说到冯洛伊曼体系中的CPU,你应该听过摩尔定律吧!就是英特尔创始人戈登·摩尔讲的:
集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。
面试官: 听过的,然后呢?
安琪拉:所以你看到我们电脑CPU 的性能越来越强劲,英特尔CPU 从Intel Core 一直到 Intel Core i7,前些年单核CPU 的晶体管数量确实符合摩尔定律,看下面这张图。
摩尔定律横轴为新CPU发明的年份,纵轴为可容纳晶体管的对数。所有的点近似成一条直线,这意味着晶体管数目随年份呈指数变化,大概每两年翻一番。
面试官: 后来呢?
安琪拉:别着急啊!后来摩尔定律越来越撑不住了,但是更新换代的程序对电脑性能的期望和要求还在不断上涨,就出现了下面的剧情。
再不转发我也要跪了他为其Pentium 4新一代芯片取消上市而道歉, 近几年来,英特尔不断地在增加其处理器的运行速度。当前最快的一款,其速度已达3.4GHz,虽然强化处理器的运行速度,也增强了芯片运作效能,但速度提升却使得芯片的能源消耗量增加,并衍生出冷却芯片的问题。
因此,英特尔摒弃将心力集中在提升运行速度的做法,在未来几年,将其芯片转为以多模核心(multi-core)的方式设计等其他方式,来提升芯片的表现。多模核心的设计法是将多模核心置入单一芯片中。如此一来,这些核心芯片即能以较缓慢的速度运转,除了可减少运转消耗的能量,也能减少运转生成的热量。此外,集众核心芯片之力,可提供较单一核心芯片更大的处理能力。—《经济学人》
安琪拉:当然上面贝瑞特当然只是在开玩笑,眼看摩尔定律撑不住了,后来怎么处理的呢?一颗CPU 不行,我们多来几颗嘛!这就是现在我们常见的多核CPU,四核8G 听着熟悉不熟悉?当然完全依据冯洛伊曼体系设计的计算机也是有缺陷的!
面试官: 什么缺陷?说说看。
安琪拉:CPU 运算器的运算速度远比内存读写速度快,所以CPU 大部分时间都在等数据从内存读取,运算完数据写回内存。
面试官: 那怎么解决?
安琪拉:因为CPU 运行速度实在太快,主存(就是内存)的数据读取速度和CPU 运算速度差了有几个数量级,因此现代计算机系统通过在CPU 和主存之前加了一层读写速度尽可能接近CPU 运行速度的高速缓存来做数据缓冲,这样缓存提前从主存获取数据,CPU 不再从主存取数据,而是从缓存取数据。这样就缓解由于主存速度太慢导致的CPU 饥饿的问题。同时CPU 内还有寄存器,一些计算的中间结果临时放在寄存器内。
面试官: 既然你提到缓存,那我问你一个问题,CPU 从缓存读取数据和从内存读取数据除了读取速度的差异?有什么本质的区别吗?不都是读数据写数据,而且加缓存会让整个体系结构变得更加复杂。
安琪拉:缓存和主存不仅仅是读取写入数据速度上的差异,还有另外更大的区别:研究人员发现了程序80%的时间在运行20% 的代码,所以缓存本质上只要把20%的常用数据和指令放进来就可以了(是不是和Redis 存放热点数据很像),另外CPU 访问主存数据时存在二个局部性现象:
时间局部性现象
如果一个主存数据正在被访问,那么在近期它被再次访问的概率非常大。想想你程序大部分时间是不是在运行主流程20%的代码。
空间局部性现象
CPU使用到某块内存区域数据,这块内存区域后面临近的数据很大概率立即会被使用到。这个很好解释,我们程序经常用的数组、集合(本质也是数组)经常会顺序访问(内存地址连续或邻近)。
因为这二个局部性现象的存在使得缓存的存在可以很大程度上缓解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 都有自己的缓存,i是共享变量,初始值是0,A 线程从内存读取i 的值存入缓存,B 线程此时也读取i 的值存入自己CPU的缓存,A 线程对i 进行+1操作,i变成了1,B线程缓存中的变量 i 还是0,B线程也对i 进行+1操作,最后A、B线程先后将缓存数据写回内存共享区,预期的结果应该是2,因为发生了二次+1操作,但是实际是1。
执行过程如下图:
缓存不一致缓存不一致
这个就是非常著名的缓存一致性问题,注意这里还只是多CPU的缓存一致性问题,和我们常说的多线程共享变量安全问题还不相同。
说明:单核CPU 的多线程也会出现上面的线程不安全的问题,只是产生原因不是多核CPU缓存不一致的问题导致,而是CPU调度线程切换,多线程局部变量不同步引起的。
面试官: 那CPU 怎么解决缓存一致性问题呢?
安琪拉:早期的一些CPU 设计中,是通过锁总线(总线访问加Lock# 锁)的方式解决的。看下CPU 体系结构图,如下:
CPU内体系结构CPU内体系结构
因为CPU 都是通过总线来读取主存中的数据,因此对总线加Lock# 锁的话,其他CPU 访问主存就被阻塞了,这样防止了对共享变量的竞争。但是锁总线对CPU的性能损耗非常大,把多核CPU 并行的优势直接给干没了!(还记得并发第一集的并行知识吧)
后面研究人员就搞出了一套协议:缓存一致性协议。协议的类型很多(MSI、MESI、MOSI、Synapse、Firefly),最常见的就是Intel (英特尔)的MESI 协议。缓存一致性协议主要规范了CPU 读写主存、管理缓存数据的一系列规范,如下图所示。
缓存一致性协议
面试官: 那讲讲缓存一致性协议(MESI协议)呗!
安琪拉: 缓存一致性协议(MESI协议)的核心思想:
- 定义了缓存中的数据状态只有四种,MESI 是四种状态的首字母。
- 当CPU写数据时,如果写的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态;
- 当CPU读取共享变量时,发现自己缓存的该变量的缓存行是无效的,那么它就会从内存中重新读取。
缓存中数据都是以缓存行(Cache Line)为单位存储;MESI 各个状态描述如下表所示:
面试官: MESI 协议解决了什么问题?
安琪拉: 解决了**多核CPU **缓存不一致的问题。
面试官: 那我有个疑问了,既然有MESI 的存在,解决多核CPU的缓存一致性,为什么还需要Java用volatile 这种关键字?
因为我们知道volatile 也是保证共享变量的可见性。
安琪拉: volatile是Java语言层面来定义的,Java语言实现volatile 的内存可见性需要借助MESI,但是有的CPU只有单核、或者不支持MESI、那怎么实现内存可见呢?可以是通过锁总线的方式,volatile屏蔽了硬件的差异,说直接点:使用volatile 修饰的变量是有内存可见性的,这是Java 语法定的,Java 不关心你底层操作系统、硬件CPU 是如何实现内存可见的,我的语法规定就是volatile 修饰的变量必须是具有可见性的。
虚拟机实现volatile的方式是写入了一条lock 前缀的汇编指令,lock 前缀的汇编指令会强制变量写入主存,也可避免前后指令的CPU重排序,并及时让其他核中的相应缓存行失效,volatile是利用MESI达到符合预期的效果。
面试官: 你故事讲完了吗?可以说说为什么需要Java内存模型了吧?
安琪拉: CPU 有X86(复杂指令集)、ARM(精简指令集)等体系架构,版本类型也有很多种,CPU 可能通过锁总线、MESI 协议实现多核心缓存的一致性。因为有硬件的差异以及编译器和处理器的指令重排优化的存在,所以Java 需要一种协议来规避硬件平台的差异,保障同一段代码在所有平台运行效果一致,这个协议叫做Java 内存模型(Java Memory Model)。
面试官: 详细说说。
安琪拉:Java内存模型( Java Memory Model
),简称JMM, 是 Java 中非常重要的一个概念,是Java 并发编程的核心。JMM 是Java 定义的一套协议,用来屏蔽各种硬件和操作系统的内存访问差异,让Java 程序在各种平台都能有一致的运行效果。
面试官:你说Java 定义的一套协议,那既然是协议,肯定是约定了一些内容,这套协议规定了什么内容?
安琪拉:是的,协议这个词很熟悉,HTTP 协议、TCP 协议等。Java内存模型(JMM) 协议定了一套规范:
所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量(主内存的拷贝),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成,如下图所示,线程的所有操作都是把主内存的数据放在自己的工作内存进行。
面试官:你刚才说了一大堆概念,能详细讲讲吗?比如你刚才讲的所有变量都在主内存中,每个线程有自己的工作内存,能好好讲讲什么是主内存和工作内存吗?
安琪拉:很多人在这里会有一个误区,认为主内存、工作内存是物理的内存条中的内存,实际上工作内存、主内存都是Java内存模型中的概念模型。
面试官:那我们上一节说的JVM内存区域划分,有堆和栈,堆是所有线程共享的,栈是线程私有的,这个和真实的物理存储有什么关系呢?
安琪拉:这个问题非常棒!JMM 中定义的每个线程私有的工作内存是抽象的规范,实际上工作内存和真实的CPU 内存架构如下所示,Java 内存模型和真实硬件内存架构是不同的:
JMM与真实内存架构JMM与真实内存架构
JMM 是内存模型,是抽象的协议。首先真实的内存架构是没有区分堆和栈的,这个Java 的JVM 来做的划分,另外线程私有的本地内存线程栈可能包括CPU 寄存器、缓存和主存。堆亦是如此!
面试官: 能具体讲讲JMM 内存模型规范吗?
安琪拉: 可以。前面已经讲了线程本地内存和物理真实内存之间的关系,说的详细些:
- 初始变量首先存储在主内存中;
- 线程操作变量需要从主内存拷贝到线程本地内存中;
- 线程的本地工作内存是一个抽象概念,包括了缓存、寄存器、store buffer(CPU内的缓存区域)等。
一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量解除锁定,解除锁定后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(有的指令是save/存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,需要顺序执行read 和load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store 和write 操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,也就是操作不是原子的,一组操作可以中断。
- 不允许read和load、store和write操作之一单独出现,必须成对出现。
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
面试官: 并发编程的三个特征,你知道吗?
安琪拉: 多线程并发编程中主要围绕着三个特性实现。
可见性
可见性是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改后的值。
原子性
原子性指的一个操作或一组操作要么全部执行,要么全部不执行。
有序性
有序性是指程序执行的顺序按照代码的先后顺序执行。
主要JMM的内容介绍完了,后面再介绍volatile的时候详细说lock指令,并发编程的原子性、可见性、有序性。
— 本文结束 —
关注我,回复 「加群」 加入各种主题讨论群。
对「服务端思维」有期待,请在文末点个在看
喜欢这篇文章,欢迎转发、分享朋友圈