面试官: 今天我们来聊聊JVM吧!!!
我:好的,我准备好了。
面试官:来,给你张草稿纸,需要方便你画图和写伪码。
我:谢谢!
面试官:那你先说一下 JVM 的内存区域有哪些?
我:好的。
我: 根据 《Java 虚拟机规范》的规定,Java 虚拟机锁管理的内存将会包括程序计数器、虚拟机栈、本地方法栈、堆、方法区这几个运行时区域。
我:我挨个讲一下这些内存区域的用途吧!
我:首先是程序计数器,是一块相对较小的空间,它相当于一个执行程序的行号指示器。它记录程序下一条需要执行的指令的地址,分支、循环、跳转、异常处理、线程恢复等基础功能并且是线程私有的。
我:Java 虚拟机栈,我们在写程序的时候都会为了复用封装很多个方法,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等这些在执行函数时会用到的信息信息。和程序计数器一样,这块内存也是线程私有的。
我:本地方法栈,它与虚拟机栈的作用非常的像,它们的区别就是虚拟机栈是为执行 Java 方法服务的,而本地方法栈是为 native 服务的,它也是线程私有的
我:Java堆,在日常的代码里面处处都有创建对象的代码,堆就是存放这些对象的内存区域,可想而知这块区域也是最大的一块区域,而且它是被所有线程共享的区域
我:最后一个方法区,他和Java堆一样是在各个线程中共享的内存区域,它是用来存储已经被虚拟机加载的类信息、常量、静态变量以及即时编译后的代码,在 HotSpot 虚拟机上 虚拟机的设计人员用永久代来实现方法区。
面试官:很好,你刚刚提到永久代,那你知道永久代和元空间的区别吗
我:嗯嗯,知道的
我:我们刚刚讲的方法区这块运行内存它是在《Java 虚拟机规范》里面定义的。不同的虚拟机对这个方法区的定义有不同的实现,在HotSpot虚拟机中在 JDK1.7 版本之前的方法区的实现叫永久代,JDK1.8 之后的实现叫元空间
我:至于它们两个的区别嘛,最大的区别在于元空间并不在虚拟机中,而是在本地内存。
我:除此之外它们参数调配也改变了,JDK1.7 通过-xx:Permsize来设置永久代初始分配空间和-XX:MaxPermsize来设定永久代最大可分配空间,而元空间大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定。
面试官:很不错,那我再问问,那你知道为什么 Java8 之后要移除永久代替换成元空间吗?
我:这个嘛!
我:按照官方上的说法是为了 JRockit 和 Hotspot 融合工作,JRockit不需要配置永久代,因为JRockit没有永久代
我:而且程序员很难确定永久代的空间大小,某些业务场景下不断的做类加载等工作会导致永久代空间不足,但是元空间使用本地内存,默认情况下本地大小限制的。
我:还有一点就是调优困难,方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。一般来说这个区域的回收效果比较难令人满意但是这部分区域的回收有时又确实是必要的。以前sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
面试官:JMM 是什么能简单说一下么?
我:JMM (Java Memory Model)是Java内存模型,JMM定义了程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存读取变量这样的底层细节
我:我来给您画一画这个图:
我:每条线程还有自己的工作内存,线程的工作内存保存了主内存的副本拷贝,对变量的操作在工作内存中进行,不能直接操作主内存中的变量.不同线程间无法直接访问对方的工作内存变量,需要通过主内存完成
面试官:那你能聊聊主内存和工作内存是如何交互操作的吗?
我:可以的!
我:Java 内存模型定义了以下8种操作来完成,它们都是原子操作(除了对 long 和double 类型的变量)
lock(锁定),作用于主内存中的变量,它将一个变量标志为一个线程独占的状态。
unlock(解锁),作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
read(读取),作用于主内存中的变量,它把一个变量的值从主内存中传递到工作内存,以便进行下一步的load操作。
load(载入),作用于工作内存中的变量,它把read操作传递来的变量值放到工作内存中的变量副本中。
use(使用),作用于工作内存中的变量,这个操作把变量副本中的值传递给执行引擎。当执行需要使用到变量值的字节码指令的时候就会执行这个操作。
assign(赋值),作用于工作内存中的变量,接收执行引擎传递过来的值,将其赋给工作内存中的变量。当执行赋值的字节码指令的时候就会执行这个操作。
store(存储),作用于工作内存中的变量,它把工作内存中的值传递到主内存中来,以便进行下一步write操作。
write(写入),作用于主内存中的变量,它把store传递过来的值放到主内存的变量中。
这里给您画一下具体的交互流程图:
面试官:可以的,我们接着来聊。
面试官:我们聊聊堆这块内存,你刚刚提到了对象是分配在堆内存里的,那怎么判断是否要回收呢?
我:主流的商用程序都是用可达性分析算法来判定对象是否存活,还有一种引用计数的方法也可以判断,不过有些缺陷
面试官:嗯,能具体聊聊这两种方法的区别吗?
我:引用计数法是给对象中添加一个引用器,每当一个地方引用它时,计数器值就加 1 ,当引用失效时,计数器就减 1 。当时这种方式很难解决对象循环引用的问题所以Java没有采用。
我:可达性分析是通过一个 GC Root 的对象作为起始点,如果没有任何引用链到达这个对象,则证明该对象不可用,我来画一下给您看看吧。
面试官:老年代的对象可能引用新生代的对象,那标记存活对象的时候,需要扫描老年代中的所有对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为GC Roots。那不是得又做全堆扫描?成本太高了吧?
我:HotSpot给出的解决方案是一项叫做卡表(Card Table)的技术。
我:该技术将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
我:在进行Minor GC的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。
我:JVM 通过卡表能用于减少老年代的全堆空间扫描,这能很大的提升GC效率
面试官:不错,你刚刚提到了引用,能说说引用分为那几种吗?还有被引用的对象一定能活吗?
我:这个我知道,Java 的引用概念分为四种,分别是强引用、软引用、弱引用和虚引用
我:强引用,就是普遍的类似 Object obj = new Object() 这类引用,强引用存在就不会被回收。
我: 软引用,描述一些非必需的对象,在系统将要发生内存溢出之前,将会堆这些对象列如回收范围中
我:弱引用,也是描述非必需对象,被弱引用关联的对象只能生存到下一次垃圾手机发生之前。
我:虚引用,它是最弱的一种引用关系,为一个对象设置虚引用的唯一目的就是能在这个对象被回收时收到一个系统通知,它不会对对象的生存时间构成影响。
我:所以,被引用的对象不一定能活,具体需要看引用类型
面试官:对象引用被置为 null 该对象会立即被回收吗?
我:这个嘛,是不会的,在下一个垃圾回调周期中,这个对象将是被可回收的,那时候才会释放其占用的内存。
面试官:你刚刚提到垃圾回收,你知道哪几种垃圾回收算法?
我:这个的话,不同的虚拟机操作内存方法不相同,我了解的有标记清除算法、复制算法、标记整理算法、分代收集算法。
面试官:行,那你说说这几种垃圾回收算法分别是怎么做的吧!
我:行!
我:标记清除算法,简单的来说,通过可达性分析后找出所有不可达的对象,并将它们放入空闲列表Free,清扫过程将分为标记阶段和清扫阶段。这种算法缺点是需要扫描整个堆区,时间开销较大,而且容易产生碎片空间。
我:复制算法,首先堆中对象分为新生代、老年代和永久代(java8前)而复制算法发生是发生在新生代的。我来给您画一下堆内存图吧!
我:新建的对象一般分配在新生代的Eden区,当Eden快满时进行一次小型的垃圾回收。存活的对象会移动到 Survivor1区
我:当再次发生 GC 时,Survivor1区的存活对象将复制到先前闲置的Survivor2区,同时存活对象寿命+1;以后每次发生GC,Survivor1和Survivor2区将交替的作为存活对象的存放区和闲置区。并且如果存活对象的寿命达到某个阈值,它将被分配到老年代中。
我:如果对象存活率较高的情况下,使用复制方法就会需要较多的复制操作,效率也是会变低的。而且还需要有额外的空间做分配担保。所以老年代一般不直接用这种算法。
我:标记-整理算法,根据老年代的特性,标记整理更适合,标记过程和「标记-清除」一样,后续多了一步整理过程,为了解决碎片化空间的问题。
我:最后一个「分代收集算法」它没什么新思想,就是根据不同区域使用不同算法,新生代使用「复制算法」老年代使用「标记清除」或者「标记整理」。
面试官:嗯,非常好。你能说说垃圾你知道的垃圾回收器么?
我:好的,刚刚我说的垃圾回收算法是方法论,那么垃圾回收器就是JVM的内存回收的具体实现。
我:HotSpot 虚拟机中有 serial收集器、parnew 收集器、parallel Scavenge 收集器、serial old 收集器、cms收集器、g1收集器等。而且这些收集器在虚拟机里面都是搭配使用的。
面试官:先说说CMS回收的过程?
我:CMS是一种老年代垃圾收集器,其主要目标是获取最短垃圾回收停顿时间,和其他老年代使用标记-整理算法不同,它使用多线程标记-清除算法,最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。CMS工作机制分为 4 个阶段。
我:首先是初始标记,只是标记一下 GC Roots 能直接关联的对象,需要暂停所有工作线程。
然后是并发标记,进行 GC Roots 跟踪过程,和用户线程一起工作,不需要暂停工作线程。
之后是重新标记,为了修正并发标记期间,因用户程序继续运行导致的标记变动的那一部分对象的标记记录,这一步还是需要停止工作线程。
最后进行并发清除,清除 GC Roots 不可达对象,和用户线程一起。
我:由于并发标记和并发清除和用户线程一起工作的,由此可知 CMS 收集器内存会后和用户线程是一起并发执行的。
面试官:串行(serial)收集器和吞吐量收集器有什么区别?
我:串行收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。虚拟机运行在Client模式下的默认新生代收集器
我:他的优势简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
我:吞吐量收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,它的优势是高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
面试官:我们聊聊 G1 垃圾收集器吧,你知道 G1 分区是怎么样的吗?
我:首先在 G1 之前的垃圾收集器都会将堆分成三个部分,新生代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation),根据不同的分区类型,采用不同的策略进行回收
我:而G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。G1把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以作为新生代的Eden空间、Survivor空间,或者老年代空间。G1堆和操作系统交互的最小管理单位称为分区(Heap Region,HR)或称堆分区,就像这样:
面试官:你知道 G1 中的 RSet 和 CSet 吗?
我:知道!
我:每一个 Region 都会划出一部分内存用来储存记录其他 Region 对当前持有 Rset Region 中 Card 的引用,这个记录就叫做 Remember Set。RSet 的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可
我:CSet 全称 Collection Set 这个set中装着需要被回收的region,在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。
面试官:你知道 G1 是怎么高效的标记这些对象的呢?
我:我想您是想问我三色标记法吧!
面试官:可以这么说,那你聊聊三色标记法吧
我:好的!
我:三色标记法,主要是为了高效的标记可被回收的内存块。
我:三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过
面试官:那你简单聊一聊它的标记过程是怎样的吧
我:好的!
我:假设现在有白、灰、黑三个集合(表示当前对象的颜色),初始时,所有对象都在 【白色集合】中
我:然后将GC Roots 直接引用到的对象 挪到 【灰色集合】中
我:从灰色集合中获取对象,将本对象 引用到的 其他对象 全部挪到 【灰色集合】中,将本对象 挪到 【黑色集合】里面
我:重复步骤3,直至【灰色集合】为空时结束
我:结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收
我:上面就是三色标记的过程。
面试官:并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,就会出现多标和漏标的情况,你能说说这两种情况吗?
我:我先来说说多标记,先看看下面这张图:
我:如上图所示,对象 E/F/G 是“应该”被回收的。但是这个时候应用程序把 E 的引用断开了,可是因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。
我:这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
面试官:那漏标记呢?
我:下面是漏标记的一种情况:
我:如上图,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。
面试官:既然漏标记非常危险,那它是怎么解决的呢?
我:首先我们对漏标做个简单的代码描述
读取 对象E的成员变量fieldG的引用值,即对象G
对象E 往其成员变量fieldG,写入 null值
对象D 往其成员变量fieldG,写入 对象G
我:所以漏标必须要同时满足以下两个条件:
赋值器插入了一条或者多条从黑色对象到白色对象的新引用;
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
我:我们只要在上面这三步中的任意一步中做一些“手脚”,将对象G记录起来,然后作为灰色对象再进行遍历即可。这里有两种解决方案:增量更新(Incremental Update),原始快照(SATB)
我:在说这两种方案前还得说一个东西:写屏障
我:给某个对象的成员变量赋值时,其底层代码大概长这样:
我:所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):
我:当对象 E 的成员变量的引用发生变化时 objE.fieldG = null; 我们可以利用写屏障,将E原来成员变量的引用对象G记录下来:
我:这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的GC Roots确定后,当时的对象图就已经确定了。比如 当时 D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。
我:这里可以知道 SATB 破坏了条件一:【灰色对象断开了白色对象的引用】,从而保证了不会漏标。
我:以上方式是【写屏障 + SATB】,接下来讲讲【写屏障 + 增量更新】
我:当对象D的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将D新的成员变量引用对象G记录下来:
我:这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)
我:增量更新破坏了条件二:【黑色对象 重新引用了 该白色对象】,从而保证了不会漏标
我:对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
CMS:写屏障 + 增量更新 G1:写屏障 + SATB ZGC:读屏障
面试官:聊聊 G1 的收集模式吧
我:G1收集器的模式主要有两种:Young GC(新生代垃圾收集)、Mixed GC(混合垃圾收集)
-
Young GC:收集年轻代里的Region。
-
Mixed GC:年轻代的所有Region + 全局并发标记阶段选出的收益高的Region。
-
无论是Young GC还是Mixed GC都只是并发拷贝的阶段。
-
分代G1模式下选择CSet有两种子模式,分别对应Young GC和Mixed GC:
-
Young GC:CSet就是所有年轻代里面的Region。
-
Mixed GC:CSet是所有年轻代里的Region加上在全局并发标记阶段标记出来的收益高的Region。
-
G1的运行过程是这样的:会在Young GC和Mixed GC之间不断地切换运行,同时定期地做全局并发标记,在实在赶不上对象创建速度的情况下使用Full GC(Serial GC,也就是从G1会回收到备选的方案,一定要尽量避免此情况出现)。
-
初始标记是在Young GC上执行的,在进行全局并发标记的时候不会做Mixed GC,在做Mixed GC的时候也不会启动初始标记阶段。
-
当Mixed GC赶不上对象产生的速度的时候就退化成Full GC,这一点是需要重点调优的地方。
面试官:你用过哪几个 G1 垃圾回收器哪几个重要参数?
我:比如如下:
-XX:+UseG1GC,告诉JVM使用G1垃圾收集器
-XX:MaxGCPauseMillis=200,设置GC暂停时间的目标最大值,这是个柔性的目标,JVM会尽力达到这个目标
-XX:INitiatingHeapOccupancyPercent=45,如果整个堆的使用率超过这个值,G1会触发一次并发周期。记住这里针对的是整个堆空间的比例,而不是某个分代的比例
面试官:很好,你刚刚提到可达性分析,你知道在可达性分析过程中借助了OopMap来增加效率吗?
我:当然知道了!
面试官:那你能了说说这个 OopMap吗?以及什么是 SavePoint 什么是 SaveRegion?
我:刚刚我们聊了在GC之前总是需要进行可达性分析来查找内存中所有存活的对象以便回收死亡对象,栈存储的数据不止是对象的引用,因此对整个栈进行全量扫描很费时间,因此在类加载的时候JVM使用OopMap这个数据结构来存储引用信息,GC开始的时候,就通过OopMap这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中哪些位置是引用。
我:当时这样子还有个问题,在方法执行的过程中, 可能会导致引用关系发生变化那么保存的OopMap就要随着变化,如果每一条指令的执行都要去修改OopMap的话,这又是一件成本很高的事情。所以这里就引入了安全点(SavePoint)概念,只有在 Safe Point 才会生成(或更新)对应的 OopMap。
我:安全点,可以理解成用户执行到特殊的位置,在这些位置上中断用户线程然后记录更新 OopMap 。这些位置可以是方法的调用、循环跳转、异常跳转等
我:有了OopMap + SavePoint 还是会有缺陷,比如线程处于 Sleep 状态或者 Blocked 状态,那么线程就无法达到一个SavePoint,因此针对这种问题,JVM引入了 安全区域(Save Region)的概念
我:安全区域很好理解,就是在程序的一段代码片段中并不会导致引用关系发生变化,也就不用去更新OopMap表了,那么在这段代码区域内任何地方进行GC都是没有问题的。这段区域就称之为安全区域。