Java面试--GC垃圾回收器

Java面试–GC垃圾回收器

GC 的算法及收集器

Garbage Collection(垃圾收集),Java 中 GC 的对象是堆空间永久区

1.1 基础概念

  • GC: GC 本身有三种语义,下文需要根据具体场景带入不同的语义:
    • Garbage Collection:垃圾收集技术,名词。
    • Garbage Collector:垃圾收集器,名词。
    • Garbage Collecting:垃圾收集动作,动词。
  • Mutator: 生产垃圾的角色,也就是我们的应用程序,垃圾制造者,通过 Allocator 进行 allocate 和 free。
  • TLAB: Thread Local Allocation Buffer 的简写,基于 CAS 的独享线程(Mutator Threads)可以优先将对象分配在 Eden 中的一块内存,因为是 Java 线程独享的内存区没有锁竞争,所以分配速度更快,每个 TLAB 都是一个线程独享的。
  • Card Table: 中文翻译为卡表,主要是用来标记卡页的状态,每个卡表项对应一个卡页。当卡页中一个对象引用有写操作时,写屏障将会标记对象所在的卡表状态改为 dirty,卡表的本质是用来解决跨代引用的问题。具体怎么解决的可以参考 StackOverflow 上的这个问题 how-actually-card-table-and-writer-barrier-works,或者研读一下 cardTableRS.app 中的源码。

1.2 分配对象

Java 中对象地址操作主要使用 Unsafe 调用了 C 的 allocate 和 free 两个方法,分配方法有两种:

  • 空闲链表(free list): 通过额外的存储记录空闲的地址,将随机 IO 变为顺序 IO,但带来了额外的空间消耗。
  • 碰撞指针(bump pointer): 通过一个指针作为分界点,需要分配内存时,仅需把指针往空闲的一端移动与对象大小相等的距离,分配效率较高,但使用场景有限。

1.3 收集对象

1.3.1 识别垃圾
  • 引用计数法(Reference Counting): 对每个对象的引用进行计数,每当有一个地方引用它时计数器 +1、引用失效则 -1,引用的计数放到对象头中,大于 0 的对象被认为是存活对象。虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。
  • 可达性分析,又称引用链法(Tracing GC): 从 GC Root 开始进行对象搜索,可以被搜索到的对象即为可达对象,此时还不足以判断对象是否存活/死亡,需要经过多次标记才能更加准确地确定,整个连通图之外的对象便可以作为垃圾被回收掉。目前 Java 中主流的虚拟机均采用此算法。

备注:引用计数法是可以处理循环引用问题的,下次面试时不要再这么说啦~ ~

1.3.2 收集算法

自从有自动内存管理出现之时就有的一些收集算法,不同的收集器也是在不同场景下进行组合。

  • Mark-Sweep(标记-清除)

    回收过程主要分为两个阶段,第一阶段为追踪(Tracing)阶段,即从 GC Root 开始遍历对象图,并标记(Mark)所遇到的每个对象,第二阶段为清除(Sweep)阶段,即回收器检查堆中每一个对象,并将所有未被标记的对象进行回收,整个过程不会发生对象移动。整个算法在不同的实现中会使用三色抽象(Tricolour Abstraction)、位图标记(BitMap)等技术来提高算法的效率,存活对象较多时较高效.是现代垃圾回收算法的基本思想。标记-清除将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始可达的对象。因此,未标记的对象就是未被引用的对象。然后,在清除阶段,清除所有未被标记的对象。
    imgimg
    img

    Mark-Compact (标记-整理)

    这个算法的主要目的就是解决在非移动式回收器中都会存在的碎片化问题,也分为两个阶段,第一阶段与 Mark-Sweep 类似,第二阶段则会对存活对象按照整理顺序(Compaction Order)进行整理。主要实现有双指针(Two-Finger)回收算法、滑动回收(Lisp2)算法和引线整理(Threaded Compaction)算法等。适合用于存活对象较多的场合,如老年代。在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记。但之后,并不是简单的清理未标记的对象,而是将所有的存活对象压缩到内存的另一端,之后,清理边界外所有的对象
    imgimgimg
    img

    Copying(复制)

    将空间分为两个大小相同的 From 和 To 两个半区,同一时间只会使用其中一个,每次进行回收时将一个半区的存活对象通过复制的方式转移到另一个半区。有递归(Robert R. Fenichel 和 Jerome C. Yochelson提出)和迭代(Cheney 提出)算法,以及解决了前两者递归栈、缓存行等问题的近似优先搜索算法。复制算法可以通过碰撞指针的方式进行快速地分配内存,但是也存在着空间利用率不高的缺点,另外就是存活对象比较大时复制的成本比较高。

    ①、与标记算法相比,复制算法是一种相对高效的回收方法。
    ②、不适用存活对象较多的场合,比如老年代。
    ③、将原有的内存空间分为两块(form、to),每次只是用一块,在垃圾回收时,将正在适用的内存存活对象复制一份到未使用的内存中,之后,清除正在适用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
    img

    复制算法的最大问题就是空间浪费严重,整合标记清理思想。(to就是浪费掉的内存空间,不计算在total内)
    imgimgimg
    img

三种算法在是否移动对象、空间和时间方面的一些对比,假设存活对象数量为 L、堆空间大小为 H,则:

img

把 mark、sweep、compaction、copying 这几种动作的耗时放在一起看,大致有这样的关系:

img

虽然 compaction 与 copying 都涉及移动对象,但取决于具体算法,compaction 可能要先计算一次对象的目标地址,然后修正指针,最后再移动对象。copying 则可以把这几件事情合为一体来做,所以可以快一些。另外,还需要留意 GC 带来的开销不能只看 Collector 的耗时,还得看 Allocator 。如果能保证内存没碎片,分配就可以用 pointer bumping 方式,只需要挪一个指针就完成了分配,非常快。而如果内存有碎片就得用 freelist 之类的方式管理,分配速度通常会慢一些。

分代思想:

依据对象的存活周期进行分类,短命对象归类为新生代,长命对象归为老年代。根据不同的特点,选取合适的收集算法:
①、少量对象存活,适合复制算法。
②、大量对象存活,适合标记-压缩算法。

1.4 收集器

目前在 Hotspot VM 中主要有分代收集和分区收集两大类,具体可以看下面的这个图,不过未来会逐渐向分区收集发展(感兴趣的同学可以学习下这篇文章 新一代垃圾回收器ZGC的探索与实践),其余基本都停留在 CMS 和 G1 上。另外在 JDK11 后提供了一个不执行任何垃圾回收动作的回收器 Epsilon(A No-Op Garbage Collector)用作性能分析。另外一个就是 Azul 的 Zing JVM,其 C4(Concurrent Continuously Compacting Collector)收集器也在业内有一定的影响力。

img

备注:值得一提的是,早些年国内 GC 技术的布道者 RednaxelaFX (江湖人称 R 大)也曾就职于 Azul,本文的一部分材料也参考了他的一些文章。

1.4.1 分代收集器
ParNew

一款多线程的收集器,采用复制算法,主要工作在 Young 区,可以通过 -XX:ParallelGCThreads 参数来控制收集的线程数,整个过程都是 STW 的,常与 CMS 组合使用。

其实就是 Serial 收集器新生代的并行版本,多线程需要多核支持。
-XX:+UseParNewGC(新生代并行,老年代串行)
-XX:ParallelGCThreads 限制线程数量
img
【2】Parallel Scanvenge收集器:类似于 ParNew,但更加关注吞吐量。
-XX:+UseParallelGC 使用 Parallel Scanvenge 收集器:新生代并行,老年代串行。
【3】Parallel Old收集器:是【2】的老年代版本。
-XX:+UseParallelOldGC 使用Parallel Old收集器:新生代并行,老年代并行。
img
各种参数设置:-XX:MaxGCPauseMills :最大停顿时间,单位毫秒。
-XX:GCTimeRatio:0-100的取值范围,垃圾收集时间占总时间的比,默认99,即最大允许1%时间做GC。

注意:这两个参数是矛盾的。因为停顿时间和吞吐量不可能同时调优。我们一方面希望停顿时间少,另外一方面希望吞吐量高,其实这是矛盾的。因为:在 GC的时候,垃圾回收的工作总量是不变的,如果将停顿时间减少,那频率就会提高;既然频率提高了,说明就会频繁的进行 GC,那吞吐量就会减少,性能就会降低。

吞吐量 = 运行用户代码的时间/(运行用户代码时间+垃圾收集时间)。比如,虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

注意:以上所有的收集器当中,当执行GC时,都会 stop the world,但是下面的 CMS 收集器却不会这样。

CMS

以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW ,多数应用于互联网站或者 B/S 系统的服务器端上,JDK9 被标记弃用,JDK14 被删除,详情可见 JEP 363。CMS收集器(Concurrent Mark Sweep:并发标记清除)是一种以获取最短回收停顿时间为目标的收集器。适合应用在互联网站或者B/S系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短。
● Concurrent Mark Sweep 并发标记清除,并发低停顿。
● 标记-清除算法。
● 并发阶段会降低吞吐量(因为停顿时间减少了,于是GC的频率会变高)
● 老年代收集器(新生代用ParNew)
-XX:+UseConcMarkSweepGC 打开这收集器

注意:这里的并发指的是与用户线程一起执行。

CMS收集器运行过程:(着重实现标记过程)
▶ 初始化标记:根直接关联对象,速度快。
▶ 并发标记:主要标记过程,标记全部对象(和用户线程一块)
▶ 重新标记:由于并发标记时,用户线程依然运行,因此在正式清理前,再做修正
▶ 并发清除:基于标记结果,直接清理对象(和用户线程一块)
img

结论:其中,初始标记和重新标记时,需要 stop the world。整个过程中耗时最长的是并发标记和并发清除,这两个过程都可以和用户线程一起工作。

CMS收集器特点:
♣ 尽可能降低停顿
♣ 会影响系统整体吞吐量和性能(用户线程运行过程中,分一半CPU去做GC,系统性能在GC阶段,反应速度就下降一半)
♣ 清理不彻底(因为在清理阶段,用户线程还在运行,会产生新的垃圾,无法清理)
♣ 因为和用户线程一起运行,不能在空间快满时再清理

-XX:CMSInitiatingOccupancyFraction 设置触发GC的阈值,如果不幸内存预留空间不够,就会引起 concurrent mode failure 我们来看一下 concurrent mode failure 的日志:碰到下图中的情况,我们需要使用串行收集器作为后备。
img

问题:既然标记清除算法会造成内存空间的碎片化,CMS收集器为什么使用标记清除算法而不是使用标记整理算法?

CMS 收集器更加关注停顿,它在做 GC 的时候是和用户线程一起工作的(并发执行),如果使用标记整理算法的话,那么在清理的时候就会去移动可用对象的内存空间,那么应用程序的线程就很有可能找不到应用对象在哪里。为了解决碎片的题,CMS 收集器会有一些整理上的参数,如下:
-XX:+ UseCMSCompactAtFullCollection:Full GC后,进行一次整理。整理过程是独占的,会引起停顿时间变长
-XX:+CMSFullGCsBeforeCompaction:设置进行几次Full GC后,进行一次碎片整理
-XX:ParallelCMSThreads:设定CMS的线程数量

1.4.2 分区收集器
G1

一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。是一款面向服务端应用的垃圾收集器。HotSpot开 发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点。
☞ 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
☞ 分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
☞ 空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
☞ 可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合,它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者 Survivor 空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。
img
在G1中,还有一种特殊的区域,叫 Humongous 区域。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

G1提供了两种GC模式,Young GC和Mixed GC,两种都是Stop The World(STW)的:对TLAB(Thread Local Allocation Buffer)空间中无法分配的对象,JVM会尝试在Eden空间中进行分配。如果Eden空间无法容纳该对象,就只能在老年代中进行分配空间。

【1】G1 Young GC:Young GC 主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。在这种情况下,Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
img
img
【2】G1 Mix GC:Mix GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。它的GC步骤分2步:
☞ 全局并发标记(global concurrent marking)
☞ 拷贝存活对象(evacuation)

在进行 Mix GC 之前,会先进行 global concurrent marking(全局并发标记)。 global concurrent marking 的执行过程是怎样的呢?

在G1 GC 中,它主要是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。global concurrent marking 的执行过程分为五个步骤:
● 初始标记(initial mark,STW):在此阶段,G1 GC 对根进行标记。该阶段与常规的 (STW) 年轻代垃圾回收密切相关。
● 根区域扫描(root region scan):G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。
● 并发标记(Concurrent Marking):G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断
● 最终标记(Remark,STW):该阶段是 STW 回收,帮助完成标记周期。G1 GC 清空 SATB 缓冲区,跟踪未被访问的存活对象,并执行引用处理。
● 清除垃圾(Cleanup,STW):在这个最后阶段,G1 GC 执行统计和 RSet 净化的 STW 操作。在统计期间,G1 GC 会识别完全空闲的区域和可供进行混合垃圾回收的区域。清理阶段在将空白区域重置并返回到空闲列表时为部分并发。

G1 到现在可以知道哪些老的分区可回收垃圾最多。 当全局并发标记完成后,在某个时刻,就开始了Mix GC。这些垃圾回收被称作“混合式”是因为他们不仅仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。混合式垃圾收集如下图:
img

混合式GC也是采用的复制的清理策略,当GC完成后,会重新释放空间。
img

  • ZGC: JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。
  • Shenandoah: 由 Red Hat 的一个团队负责开发,与 G1 类似,基于 Region 设计的垃圾收集器,但不需要 Remember Set 或者 Card Table 来记录跨 Region 引用,停顿时间和堆的大小没有任何关系。停顿时间与 ZGC 接近,下图为与 CMS 和 G1 等收集器的 benchmark。

img

1.4.3 常用收集器

目前使用最多的是 CMS 和 G1 收集器,二者都有分代的概念,主要内存结构如下:

img

1.4.4 其他收集器

以上仅列出常见收集器,除此之外还有很多,如 Metronome、Stopless、Staccato、Chicken、Clover 等实时回收器,Sapphire、Compressor、Pauseless 等并发复制/整理回收器,Doligez-Leroy-Conthier 等标记整理回收器,由于篇幅原因,不在此一一介绍。

1.5 可触及性


所有的算法,需要能够识别一个垃圾对象,因此需要给出一个可触及性的定义。
✔ 可触及性:从根节点(栈中引用的对象,方法区中静态成员或者常量引用的对象《全局对象》或JNI方法栈中引用对象)可以触及到这个对象。
✔ 可复活的:一旦所有引用被释放,就是可复活状态,因为在finalize()中可能复活该对象。
✔ 不可触及的:在 finalize 后,可能会进入不可触及状态,不可触及的对象不可能复活,可以回收。
如下:第一次垃圾回收时,finallize方法中的 new CanReliveObj() 对象还未被清理,因此obj = this 是可用的。因此为可复活对象。但finalize后,new CanReliveObj()对象被清理,再次进行垃圾回收时,obj = null 为不可触及对象。
img

注意:避免使用 finalize() 方法,操作不慎可能导致错误。且优先级低,何时被调用也不确定。尽量用 try-catch-finally 来代替。

1.6 Stop-The-World


Java 中一种全局暂停的现象,全局停顿,所有 Java代码停止,native(本地)代码可运行,但不能和 JVM交互。多半由 GC 引起的。Dump 线程、死锁检查、堆Dump 也会引起 Stop-The-World。

☛ GC为什么会有全局停顿?
类比在聚会的时候打扫房间,只有让大家停止活动,才能打扫干净房间,否则会不断有新垃圾产生。所带来的危害:长时间停止服务,没有响应。当遇到HA(High Available:高可用)系统,可能引起主备切换,严重危害生产环境。

1.7 常用工具

工欲善其事,必先利其器,此处列出一些笔者常用的工具,具体情况大家可以自由选择,本文的问题都是使用这些工具来定位和分析的。

1.7.1 命令行终端
  • 标准终端类:jps、jinfo、jstat、jstack、jmap
  • 功能整合类:jcmd、vjtools、arthas、greys
1.7.2 可视化界面
  • 简易:JConsole、JVisualvm、HA、GCHisto、GCViewer
  • 进阶:MAT、JProfiler

器,此处列出一些笔者常用的工具,具体情况大家可以自由选择,本文的问题都是使用这些工具来定位和分析的。

1.7.1 命令行终端
  • 标准终端类:jps、jinfo、jstat、jstack、jmap
  • 功能整合类:jcmd、vjtools、arthas、greys
1.7.2 可视化界面
  • 简易:JConsole、JVisualvm、HA、GCHisto、GCViewer
  • 进阶:MAT、JProfiler

命令行推荐 arthas ,可视化界面推荐 JProfiler,此外还有一些在线的平台 gceasyheapherofastthread ,美团内部的 Scalpel(一款自研的 JVM 问题诊断工具,暂时未开源)也比较好用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值