这里可以主要讲CMS和G1,简单谈一些其他的
面试官:知道JVM垃圾回收机制吗?
我:知道,首先说一下垃圾回收算法,因为其中的一个分代收集,所以让堆又分为年轻代和老年代。
垃圾回收算法:
- 标记-清除
该算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:
- 效率问题
- 空间问题(标记清除后会产生大量不连续的碎片)
- 标记-整理
根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。(老年代一般存入的是大对象,时间比较久的对象)
- 复制
为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。(堆的年轻代又分为Eden、s0和s1)
- 分代
比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
一般情况:
- 大多数情况下,对象在新生代中 eden 区分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC.
- 大对象直接进入老年代,大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。频繁复制降低性能。
- 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1. 对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数
-XX:MaxTenuringThreshold
来设置。
面试官:如何判断对象死亡?
我:有两种策略,其一为引用计数法,其二为可达性分析。
- 引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
- 可达性分析
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
哪些可以作为GC Roots的根:
- 虚拟机栈(栈帧中的局部变量区,也叫局部变量表)中应用的对象。
- 本地方法栈中JNI(native方法)引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中常量引用的对象
面试官:如何枚举根节点?
我:以上引用作用GC Roots的根,如果方法区和大,要逐个检查这里面的引用,那么必然会消耗很多时间,而且枚举根节点需要停顿的。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得这心信息了。
但一个很现实的问题:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那么会需要大量的额外空间,这样GC成本很高,安全点由此而来。
实际上,在JIT编译过程中,在特定的位置记录下栈和寄存器哪些位置是引用,实际上这些位置就是安全点,意思就是说,程序执行时并非在所有地方都能停顿下来开始GC,只有在达到安全点时才能暂停。
Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint,但如果线程处于Sleep或者Blocked状态,这时候线程无法响应JVM的中断请求,JVM也显然不太可能等待线程重新被分配CPU时间,这种情况就需要安全域来解决。安全域是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。这时候安全点就被扩展到了Safe Region。
面试官:给我讲讲垃圾收集器吧
我:当然没问题,有一张有趣的图
小插曲:咱们知道,堆分为新生代和老年代,那么从这张图可以看出,新生代有Serial、ParNew和Parallel Scavenge而老年代自然也有Serial Old和Parallel Old,新生代和老年代都有串并行收集器,能互相搭配,但看CMS就很特殊,它是老年代的收集器,能从图中可以看出来,它不稳定呀,居然用Serial Old当备胎,而且为了能搭配CMS的并行垃圾收集器,就给它造了一个ParNew,哈哈哈(开个玩笑)。G1暂且不说,横跨新生和老年。在它这一块不分家,一把抓。
我就简单说一下串并行垃圾收集器,太古老了,面试官也不想听。
你像Serial和ParNew呀,其实在STW的时候,一个是单线程,一个是多线程回收垃圾。而ParNew和Parallel Scavenge的区别仅仅是吞吐量,后者重在吞吐量上(高效率利用CPU)。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。而ParNew是在Server 模式下的虚拟机的首要选择之一。以上垃圾收集器新生代采用复制,而老年代采用标记-整理。
CMS垃圾收集器:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
- 初始标记:暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
- 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除:开启用户线程,同时 GC 线程开始对为标记的区域做清扫。
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:
- 对 CPU 资源敏感;
- 无法处理浮动垃圾;
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
因此,为了解决以上缺点,G1就出现了:
-
将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而都是一部分Region(不需要连续)的集合
-
并行与并发: G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU来缩短 Stop-The-World 停顿时间。
-
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
-
空间整合:G1 从整体来看是基于**“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法**实现的。
-
可预测停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
G1的跨代引用:
在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set(RS)来避免全堆扫描的。G1中每个Region都有一个与之对应的RS,虚拟机发现程序对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断操作,检查Reference引用的对象是否处于不同的Region之间(在分代的例子中就是检查是否老年代中的对象引用了新生代中方的对象)如果是,便通过CardTable(每个Region块又细分了2000多个卡表,记录一波我引用了哪个对象)把相关引用信息记录到被引用对象所属的Region的RS之中。当进行内存回收时,在GC根节点的枚举范围中加入RS即可保证不对全堆扫描,也不会又遗漏。
当然G1有也大致的四个过程:
- 初始标记:仅仅只是标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,此阶段需要停顿线程,但耗时很短。
- 并发标记:从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
- 筛选回收:首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
在这里,简单做一个CMS和G1的比较:
- CMS收集器是获取最短回收停顿时间为目标的收集器,因为CMS工作时,GC工作线程与用户线程可以并发执行,以此来达到降低收集停顿时间的目的(只有初始标记和重新标记会STW)。但是CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。
- CMS仅作用于老年代,是基于标记清除算法,所以清理的过程中会有大量的空间碎片。
- CMS收集器无法处理浮动垃圾,由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自然会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留在下一次GC时将其清理掉。
- G1是一款面向服务端应用的垃圾收集器,适用于多核处理器、大内存容量的服务端系统。G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU或核心来缩短STW的停顿时间,它满足短时间停顿的同时达到一个高的吞吐量。
- 从JDK 9开始,G1成为默认的垃圾回收器。当应用有以下任何一种特性时非常适合用G1:Full GC持续时间太长或者太频繁;对象的创建速率和存活率变动很大;应用不希望停顿时间长(长于0.5s甚至1s)。
- G1将空间划分成很多块(Region),然后他们各自进行回收。堆比较大的时候可以采用,采用复制算法,碎片化问题不严重。整体上看属于标记整理算法,局部(region之间)属于复制算法。
- G1 需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率。所以 CMS 在小内存应用上的表现要优于 G1,而大内存应用上 G1 更有优势,大小内存的界限是6GB到8GB。