面试官:您好,请先自我介绍一下。Angela:面试官你好,我叫Angela,草地三贱人,最强中单,草地摩托手,21套广播体操推广人,火球的拥有者,不燃烧的,Angela,这是我的简历,请看一下。面试官:看你简历上对多线程编程很熟悉,说说Java内存模型吧。Angela:在讲Java内存模型之前,我想给大家讲一个故事,从CPU的发展史说起。面试官:我喜欢听故事,你说吧。Angela:我们先谈谈现代CPU架构的形成。一切都始于冯·鲁曼计算机系统!采访者:是不是有点牵强?能不能快点进入主题!从上海开车到杭州需要3个小时。真心求职的人就是这么没耐心。采访者:聂远,真的是聂远。你说。Angela:下图是经典的VonLeumann架构,它基本上定义了计算机的组件。今天的计算机都是用这个系统构建的,核心是由运算器和控制器组成的。中央处理器就是我们常说的CPU。冯·诺依曼架构面试官:这和Java内存模型有什么关系?安吉拉:别担心!Angela:刚才你提到了冯诺依曼架构的CPU,你应该听说过摩尔定律吧!正如英特尔创始人戈登摩尔所说:一块集成电路上所能容纳的晶体管数量每18个月就会翻一番,性能也会翻一番。面试官:是的,然后呢?Angela:所以你看到我们电脑CPU的性能越来越强了。IntelCPU的范围从IntelCore到IntelCorei7。摩尔定律,见下图。摩尔定律的横轴是新CPU发明的年份,纵轴是可容纳晶体管数量的对数。所有的点都近似为一条直线,这意味着晶体管的数量逐年呈指数变化,大约每两年翻一番。采访者:后来发生了什么?安吉拉:别担心!后来,摩尔定律越来越难以为继,但更新程序对计算机性能的期望和要求不断上升,就出现了下面的情节。不转发他就得跪了。他为取消奔腾4下一代芯片而道歉。近年来,英特尔不断提高其处理器的运行速度。当前最快的速度为3.4GHz。虽然处理器的速度提升了,芯片的运行性能也提升了,但是提升的速度也增加了芯片的能耗,导致了芯片散热的问题。因此,英特尔不再专注于速度,而是将其芯片转向多核设计和其他方式,以在未来几年内提高芯片性能。多模核心的设计方法是将多模核心放在单芯片中。这样一来,这些核心芯片就可以以更慢的速度运行,既减少了运行消耗的能量,也减少了运行产生的热量。此外,集成多颗核心芯片的力量,可以提供比单颗核心芯片更大的处理能力。—♂Angela:当然,Barrett在上面只是开玩笑。眼看摩尔定律已经站不住脚了,以后怎么应对呢?一个CPU不行,让我们多买几个!这就是我们现在经常看到的多核CPU,四核8G,是不是有点耳熟?当然,完全按照冯·鲁曼系统设计的计算机也是有缺陷的!面试官:什么缺陷?告诉我。Angela:CPU计算器的计算速度远快于内存的读写速度,所以CPU大部分时间都在等待从内存中读取数据,然后再将数据写回内存经过计算。面试官:那怎么解决呢?Angela:因为CPU运行速度太快,导致主存(也就是内存)的数据读取速度与CPU运行速度相差几个数量级。在存储前加一层读写速度尽可能接近CPU运行速度的高速缓存,用于数据缓冲,使缓存提前从主存中获取数据,CPU不用longer从主存中取数据,但是从缓存中取数据。这缓解了主内存太慢导致的CPU饥饿问题。同时CPU中有寄存器,一些计算的中间结果暂时放在寄存器中。面试官:既然你提到了缓存,那我问你一个问题。CPU从缓存中读取数据和从内存中读取数据除了读取速度外还有什么区别?有什么本质区别吗?并不是所有的读取数据和写入数据,而且加入缓存会让整个架构变得更加复杂。Angela:缓存和主存的区别不仅仅是读写数据的速度,还有一个更大的区别:研究人员发现程序80%的时间都在运行20%的代码,所以缓存本质上只要放20%的常用数据和指令就够了(是不是类似于Redis存储热点数据),CPU访问主存数据时有两种局部现象:时间局部性现象如果一个主存数据正在被访问,那么在不久的将来再次被访问的概率很高。想想你的程序是否大部分时间都在运行20%的主进程代码。空间局部性现象当CPU使用某个内存区域的数据时,很有可能会立即使用这个内存区域后面的相邻数据。这很容易解释。我们程序中经常用到的数组和集合(本质上是数组),访问时往往是顺序访问的(内存地址是连续的或相邻的)。由于这两种局部现象的存在,缓存的存在可以在很大程度上缓解CPU饥饿的问题。面试官:你说的就是这个,能不能给我画一张CPU、缓存、主存现在的关系图?安吉拉:是的。我们来看看目前主流的多核CPU的硬件架构,如下图所示。Multi-coreCPUarchitectureMulti-coreCPUarchitectureAngela:现代操作系统一般都有多级缓存(CacheLine),通常是L1、L2,甚至L3。查看Angela的电脑缓存信息,一共4个核心,三级缓存,L1缓存(在CPU核心中)这里没有显示,这里L2缓存后面的括号表示每个核心都有一个L2缓存,而三级缓存没有标识,因为三级缓存是4核共享的:安吉拉的电脑缓存安吉拉的电脑多级缓存面试官:能简单说一下程序运行时数据在主存、缓存、CPU寄存器之间是如何流动的吗?跑步?安吉拉:可以。例如,取i=i+2;举个例子,当线程执行这条语句时,会先从主存中读取i的值,然后复制一份到缓存中,CPU读取缓存数据(fetch指令),执行i+2操作(中间数据放在寄存器中),然后将结果写入缓存,最后将缓存中i的最新值刷新到主存(写回主存的时间不确定)。面试官:这个数据操作逻辑在单线程环境和多线程环境下有什么区别?Angela:比如i是共享变量(比如类的成员变量),在单线程中运行没有问题,但是在多线程中运行就可能有问题。例如:有两个线程A和B运行在两个不同的CPU上,因为每个线程上运行的CPU都有自己的缓存,i是一个初始值为0的共享变量,线程A从memory的值保存在缓存中,B线程此时也读取了i的值,保存在自己CPU的缓存中。A线程对i进行+1操作,i变为1。B线程缓存中的变量i仍为0,B线程也对i进行+1操作,最后A、B线程写入缓存数据依次回到内存共享区。预期结果应该是2,因为发生了第二次+1操作,但实际上是1。执行过程如下图所示:CacheinconsistencyCacheinconsistency这是一个非常有名的缓存一致性问题。注意这只是一个多CPU缓存一致性问题,和我们常说的多线程共享变量安全问题是不一样的。解释:上述线程不安全问题在单核CPU的多线程中也会出现,但原因不是多核CPU缓存不一致造成的,而是CPU调度线程切换和多线程造成的局部变量不同步。面试官:那么CPU是怎么解决缓存一致性问题的呢?Angela:在一些早期的CPU设计中,是通过锁定总线(busaccessplusLock#lock)来解决的。看一下CPU架构图,如下:CPU内部架构CPU内部架构因为CPU是通过总线读取主存中的数据,所以如果在总线上加上Lock#,就会阻止其他CPU访问主存,这可以防止共享变量上的竞争。但是锁总线对CPU的性能损失非常大,多核CPU并行的优势直接被抹杀!(还记得并发第一集中的并行知识吗)后来研究人员想出了一套协议:CacheConsistencyProtocol。协议有很多种(MSI、MESI、MOSI、Synapse、Firefly),最常见的是Intel的MESI协议。缓存一致性协议主要规定了CPU读写主存和管理缓存数据的一系列规范,如下图所示。缓存一致性协议面试官:说说缓存一致性协议(MESIProtocol)吧!Angela:缓存一致性协议(MESIProtocol)的核心思想:它定义了缓存中只有四种数据状态,MESI是四个状态的首字母。CPU写入数据时,如果写入的变量是共享变量,即该变量在其他CPU中有副本,则会发送信号通知其他CPU使该变量的缓存行失效;当CPU读取共享变量时,发现自己缓存的变量的缓存行无效,就会重新从内存中读取。缓存中的数据以缓存行(CacheLine)为单位存储;MESI各个状态的描述如下表:采访者:MESI协议解决什么问题?Angela:解决了**多核CPU**缓存不一致的问题。面试官:那我有一个问题。既然MESI的存在就是为了解决多核CPU的缓存一致性问题,为什么Java还要用volatile关键字呢?因为我们知道volatile也保证了共享变量的可见性。Angela:volatile是在Java语言级别定义的。要在Java语言中实现易失性内存可见性,就需要MESI。但是,有些CPU只有单核或不支持MESI。记忆如何可见?可以通过lockbus这样volatile屏蔽了硬件的差异。说白了:用volatile修饰的变量是有内存可见性的,这是Java语法决定的。Java并不关心你的底层操作系统和硬件CPU是如何实现内存可见性的。我的语法规则是volatile修饰的变量必须是可见的。虚拟机实现volatile的方式是写一条以lock为前缀的汇编指令。以lock为前缀的汇编指令会强制将变量写入主存,同时也可以避免CPU对前后指令重新排序,及时让其他核中对应的缓存行失效,volatile就是使用MESI以达到预期的效果。采访者:你的故事讲完了吗?您能告诉我为什么需要Java内存模型吗?Angela:CPU有X86(复杂指令集)、ARM(精简指令集)等架构,版本也有很多种。可以通过锁总线和MESI协议实现多核缓存一致性。由于硬件的差异以及编译器和处理器指令重排优化的存在,Java需要一个协议来避免硬件平台的差异,保证同一段代码在所有平台上运行一致。这种协议称为Java内存模型(JavaMemoryModel)。面试官:说的详细一点。Angela:Java内存模型(JavaMemoryModel),简称JMM,是Java中一个非常重要的概念,是Java并发编程的核心。JMM是Java定义的一套协议,用于屏蔽各种硬件和操作系统的内存访问差异,使Java程序能够在各种平台上一致运行。面试官:你说Java定义了一套协议。既然是协议,肯定是约定了一些内容。这个协议规定了什么?Angela:是的,协议这个词很熟悉。HTTP协议、TCP协议等等。Java内存模型(JMM)协议设定了一套规范:所有的变量都存储在主内存中,每个线程也有自己的工作内存,线程使用的变量存储在线程的工作内存(mainmemory)Copy),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量。不同的线程不能直接访问彼此工作内存中的变量。线程间变量值的传递需要在主存中完成。如下图所示,线程的所有操作都是将主内存的数据放到自己的工作内存中进行。采访者:您刚才提到了很多概念,能详细解释一下吗?比如你刚才提到的所有变量都在主存中,每个线程都有自己的工作内存。能详细解释一下什么是主存和工作存吗?记忆?Angela:很多人在这里有一个误区,认为主内存和工作内存就是物理记忆棒中的内存。实际上,工作内存和主内存都是Java内存模型中的概念模型。面试官:那么上一节我们说的JVM内存区域划分有堆和栈。堆是所有线程共享的,栈是线程私有的。这与实际物理存储有什么关系?Angela:这个问题太棒了!JMM中定义的每个线程的私有工作内存是一个抽象的规范。其实工作内存和真正的CPU内存架构如下。Java内存模型和真实的硬件内存架构不同:JMM和真实内存架构JMM和RealMemoryArchitectureJMM是一种内存模型,是一种抽象协议。首先,真正的内存架构是不区分堆和栈的。这个JavaJVM做除法。此外,线程私有本地内存线程堆栈可能包括CPU寄存器、高速缓存和主内存。堆也是一样!面试官:能介绍一下JMM内存模型规范吗?安吉拉:是的。前面已经讲过线程本地内存和物理实内存的关系,说的很详细:初始变量先存入主存;线程运行变量需要从主内存拷贝到线程本地内存;线程的localworkingmemory是一个抽象的概念,包括cache、register、storebuffer(CPU中的cache区)等,一个变量如何从mainmemory复制到workingmemory,如何从working同步的实现细节内存到主存,Java内存模型定义了以下8个操作(单个操作是原子的)来完成:锁(lock):作用于主存的变量,标记一个变量为线程独占状态。解锁(unlock):作用于主内存变量,解锁一个处于锁定状态的变量,解锁后的变量可以被其他线程锁定。读取(read):作用于主存变量,将一个变量值从主存传递到线程的工作内存,以便后续的加载动作使用加载(load):作用于工作内存的变量,它将读操作从主存中获取的变量值放入工作内存中的变量副本中。use(使用):作用于工作内存的变量,将工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到需要使用该变量值的字节码指令时,就执行这个操作。Assign(赋值):作用于工作内存的变量,将从执行引擎接收到的一个值赋值给工作内存的变量,每当虚拟机遇到为该变量赋值的字节码指令时执行此操作.store(有的指令是save/storage):作用于工作内存中的变量,将工作内存中某个变量的值传送到主存中,用于后续的写操作。写(write):作用于主存的变量,它将存储操作从工作内存中的一个变量的值转移到主存中的变量。Java内存模型也规定了在执行以上八种基本操作时必须满足以下规则:如果要将一个变量从主内存复制到工作内存,需要依次执行读取和加载操作。如果将变量从工作内存同步回主内存,则需要依次进行存储和写入操作。但是Java内存模型只是要求上述操作必须按顺序执行,并不能保证一定要连续执行,即操作不是原子的,一组操作可以被打断。读取和加载、存储和写入操作之一不允许单独出现,它们必须成对出现。一个线程不允许丢弃它最新的赋值操作,即变量在工作内存中发生变化后必须同步到主内存中。一个线程不允许无故(没有发生assign操作)将数据从工作内存同步回主内存。一个新的变量只能在主内存中创建,不允许在工作内存中直接使用一个未初始化(加载或赋值)的变量。也就是说,在对一个变量实现use和store操作之前,必须先进行assign和load操作。一个变量在同一时刻只能被一个线程加锁,但是加锁操作可以被同一个线程多次执行。多次执行锁后,只有执行相同次数的解锁操作后,变量才会被解锁。lock和unlock必须成对出现。如果对一个变量进行加锁操作,工作内存中该变量的值将被清空。执行引擎在使用这个变量之前,需要重新执行load或者assign操作来初始化这个变量的值。如果一个变量之前没有被使用过lock操作加锁,则不允许对其进行解锁操作;也不允许解锁被其他线程锁定的变量。在对变量执行解锁操作之前,变量必须同步到主存(执行存储和写入操作)。面试官:你知道并发编程的三个特点吗?Angela:多线程并发编程主要围绕三个特点展开。可见性可见性是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程可以立即看到修改后的值。原子性原子性是指一个操作或一组操作要么全部执行,要么根本不执行。Sequence顺序是指按照代码执行的先后顺序,程序执行的先后顺序。JMM主要内容已经介绍完毕。后面介绍volatile的时候,我们会详细讲到lock指令,以及并发编程的原子性、可见性和有序性。
