浅谈 Java 垃圾回收

567 阅读11分钟

写在最前

在 Java 中,程序员是不需要显示的去释放一个对象的内存,而是由虚拟机自行执行。在 JVM 中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

GC 与 DGC 是啥?

GC

GC(Garbage Collection)是垃圾收集,内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的。

DGC

DGC(Distributed Garbage Collection)是分布式垃圾回收。RMI(Remote Method Invocation 远程⽅法调⽤)使用 DGC 来做自动垃圾回收。因为 RMI 包含了跨虚拟机的远程对象的引用,垃圾回收是很困难的。DGC 使用引用计数算法来给远程对象提供自动内存管理。

如何找出回收对象

在堆里面存放着 Java 运行中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就是判断哪些对象已死(可回收)。

引用计数法

JDK1.2之前使用的是引用计数器算法。在对象中添加一个引用计数器,当有地方引用这个对象的时候,引用计数器的值就+1,当引用失效的时候,计数器的值就-1,当引用计数器被减为的时候,标志着这个对象已经没有引用了,可以回收了!

image-20220112112754468

引用计数法存在一个问题,导致部分类型的垃圾无法回收

如果在对象 C 中调用对象 D 的方法,对象 D 中调用对象 C 的方法,这样当其他所有的引用都消失了之后,对象 C 和 对象 D 还有一个相互的引用,也就是说两个对象的引用计数器各为1,而实际上这两个对象都已经没有额外的引用,已经是垃圾了。但是该算法并不会计算出该类型的垃圾。

可达性分析法

在主流商用语言(如:Java、C#)的主流实现中,都是通过可达性分析算法来判定对象是否存活的:通过一系列的称为 GC Roots 的对象作为起点,然后向下搜索,搜索所走过的路径称为引用链 Reference Chain,当一个对象到 GC Roots 没有任何引用链相连时,即该对象不可达,也就说明此对象是不可用的

如下图: 虽然 Obj 7和 Obj 8相互关联,但它们到 GC Roots 是不可达的,因此也会被判定为可回收的对象。

image-20220112185402706

即使在可达性分析算法中不可达的对象,JVM 也并不是马上对其回收,因为要真正宣告一个对象死亡,至少要经历两次标记过程: 第一次是在可达性分析后发现没有与 GC Roots 相连接的引用链;第二次是 GC 对在 F-Queue 执行队列中的对象进行的小规模标记(对象需要覆盖 finalize() 方法且没被调用过)。

垃圾收集器

针对不同的 GC 收集器,我们要对应应用场景来进行选择和调优,回顾 GC 的历史,主要有4GC 收集器: SerialParallelCMSG1

图片

Serial

激活收集器: -XX:+UseSerialGC

单线程回收资源。它在 GC 进行时,会暂停所有的工作进程,用一个线程去完成 GC 工作,所以程序会进入长时间的暂停时间,一般不太建议使用。

下图为 Serial 收集器的运作步骤:

image-20220110210026800

特点:简单高效,适合 JVM 管理内存不大的情况(十兆到百兆)。

Parallel

激活收集器:-XX:+UseParallelGC -XX:+UseParallelOldGCParallel

称之为吞吐量优先的收集器,因为 Parallel 最主要的优势在于并行使用多线程去完成垃圾清理工作,这样可以充分利用多核的特性,大幅降低 GC 时间。当你的程序场景吞吐量较大。

例如消息队列这种应用,需要保证有效利用 CPU 资源,可以忍受一定的停顿时间,可以优先考虑这种方式。

下图为 Parallel 收集器的运作步骤:

image-20220112193953530

Parallel 是多线程 GC 收集,所以它配合多核心的 CPU 效果更好(可用 -XX:ParallelGCThreads 参数控制 GC 线程数)。

CMS(Concurrent Mark Sweep)

激活收集器:-XX:+UseConcMarkSweepGCCMS

一款具有划时代意义的收集器,一款真正意义上的并发收集器。虽然现在已经有了理论意义上表现更好的 G1 收集器,但现在主流互联网企业线上选用的仍是 CMS,又称多并发低暂停的收集器。

当应用尤其重视服务器的响应速度(比如 Apiserver),希望系统停顿时间最短,以给用户带来较好的体验,那么可以选择CMSCMS 收集器在 MinorGC 时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。在 FullGC 时不暂停应用线程,而是使用若干个后台线程定期的对老年代空间进行扫描,及时回收其中不再使用的对象。

下图为 CMS 收集器的运作步骤:

image-20220111165405368

执行过程

由他的英文组成可以看出,它是基于标记-清除算法实现的。整个过程分4个步骤:

  1. 初始标记(initial mark):仅只标记一下 GC Roots 能直接关联到的对象,速度很快;
  2. 并发标记(concurrent mark):GC Roots Tracing 过程;
  3. 重新标记(remark):修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录;
  4. 并发清除(concurrent sweep):已死对象将会就地释放;

可以看到,初始标记、重新标记需要 STW (stop the world 即:挂起用户线程)操作。因为最耗时的操作是并发标记和并发清除。所以总体上我们认为 CMS 的 GC 与用户线程是并发运行的。

优点/缺点

  • 优点:
    • 并发收集、低停顿
  • 缺点:
    • CMS 默认启动的回收线程数 (CPU 数目+3)*4,当 CPU 数>4时, GC 线程最多占用不超过25%的 CPU 资源。但是当 CPU 数<=4时,GC 线程可能就会过多的占用用户 CPU 资源,从而导致应用程序变慢,总吞吐量降低。
    • 无法清除浮动垃圾(Floating Garbage:GC 运行到并发清除阶段时用户线程产生的垃圾),可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。因为用户线程是需要内存的,如果浮动垃圾施放不及时,很可能就造成内存溢出,所以 CMS 不能像别的垃圾收集器那样等老年代几乎满了才触发。CMS 提供了参数 -XX:CMSInitiatingOccupancyFraction 来设置 GC 触发百分比(1.6后默认92%),当然我们还得设置启用该策略 -XX:+UseCMSInitiatingOccupancyOnly
    • 因为采用标记-清除算法,所以可能会带来很多的碎片,如果碎片太多没有清理,JVM 会因为无法分配大对象内存而触发 GC。因此 CMS 提供了 -XX:+UseCMSCompactAtFullCollection 参数,它会在 GC 执行完后接着进行碎片整理。同时还有一个伴生问题:碎片整理不能并发,必须单线程去处理,就导致了如果每次 GC 完都整理用户线程,stop 的时间累积会很长,所以有 -XX:CMSFullGCsBeforeCompaction 参数设置隔几次 GC 进行一次碎片整理(默认为0)。

G1(Garbage First)

激活收集器:-XX:+UseG1GC

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。G1 也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集。

下图为 G1 收集器的运作步骤:

image-20220111195333755

G1 最大的特点是引入分区的思路,弱化分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至 CMS 的众多缺陷。

下图为 G1 的内存布局:

image-20220112103948436

G1 整个堆空间分成若干个大小相等的内存区域-Regions。默认将整堆划分为2048个分区,可以通过参数 -XX:G1HeapRegionSize=n 可指定分区大小(1MB~32MB,且必须是2的幂)。

G1 保留了分代的概念,但是年轻代和老年代不再是物理上的隔离,他们都是一部分的 Regions (不需要连续)的集合,每个 Region 都可能随 G1 的运行在不同代之间切换。

新生代收集

方法中 new 一个对象,就会先进入新生代。

G1 的新生代收集跟 ParNew 类似,如果存活时间超过某个阈值,就会被转移到 S/O 区。年轻代内存由一组不连续的 heap 区组成, 这种方法使得可以动态调整各代区域的大小。

老年代收集

  1. 新生代中经历了 N 次垃圾回收仍然存活的对象就会被放到老年代中。
  2. 大对象一般直接放入老年代。
  3. 当 Survivor 空间不足,需要老年代担保一些空间,也会将对象放入老年代。
  • 初始标记 (Initial Mark): 这一步会触发 STW。在 G1 中,该操作附着一次年轻代 GC,以标记 Survivor 中有可能引用到老年代对象的 Regions。
  • 扫描根区域 (Root Region Scanning):扫描 Survivor 中能够引用到老年代的 references。但必须在 Minor GC 触发前执行完。
  • 并发标记 (Concurrent Marking):在整个堆中查找存活对象,但该阶段可能会被 Minor GC 中断。
  • 最终标记 (Remark):这一步会触发 STW。完成堆内存中存活对象的标记。使用 snapshot-at-the-beginning(SATB, 起始快照)算法, 比 CMS 所用算法要快得多(空 Region 直接被移除并回收,并计算所有区域的活跃度)。
  • 筛选回收 (Cleanup):在含有存活对象和完全空闲的区域上进行统计(STW)、擦除 Remembered Sets(使用 Remembered Set 来避免扫描全堆,每个区都有对应一个 Set 用来记录引用信息、读写操作记录)(STW)、重置空 regions 并将他们返还给空闲列表(free list)(Concurrent)

垃圾收集算法

GC 最基础的算法有三种:标记-清除算法、复制算法、标记-压缩算法。

标记-清除算法(Mark-Sweep)

如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。

image-20220112162830254

  • 优点:实现简单,不需要进行对象进行移动。
  • 缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

复制算法(Copying)

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

image-20220112164922094

  • 优点:不用考虑碎片问题,方法简单高效。
  • 缺点:实际可使用的内存空间缩小为原来的一半,对象存活率高时会频繁进行复制。

标记-整理算法(Mark-Compact)

标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存

image-20220112165117533

  • 优点:解决了标记-清理算法存在的内存碎片问题。
  • 缺点:仍需要进行局部对象移动,一定程度上降低了效率。

分代收集算法

把Java堆分为新生代老年代,这样就可以根据各个年代的特点采用最适当的收集算法。一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收。

image-20220112170403915

JVM 调优

注:最优的方式一定是根据业务场景,系统的运行环境所调整的,没有固定的方案,只有大致的方向!!!

常用JVM性能调优方法

  • 设定堆内存大小:
    • Xmx:堆内存最大限制。
  • 设定新生代大小,新生代不宜太小,否则会有大量对象涌入老年代:
    • -XX:NewSize:新生代大小
    • -XX:NewRatio:新生代和老生代占比
    • -XX:SurvivorRatio:伊甸园空间和幸存者空间的占比
  • 设定垃圾回收器:
    • 年轻代用 -XX:+UseParNewGC
    • 年老代用 -XX:+UseConcMarkSweepGC

常用调优命令

Sun JDK 监控和故障处理命令有 jps jstat jmap jhat jstack jinfo

  • jps,JVM Process Status Tool:显示指定系统内所有的 HotSpot 虚拟机进程。
  • jstat,JVM statistics Monitoring:用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据。
  • jmap,JVM Memory Map:用于生成 heap dump 文件。
  • jhat,JVM Heap Analysis Tool:与 jmap 搭配使用,用来分析 jmap 生成的 dump,jhat 内置了一个微型的HTTP/HTML服务器,生成 dump 的分析结果后,可以在浏览器中查看。
  • jstack:用于生成 JVM 当前时刻的线程快照。
  • jinfo,JVM Configuration info:实时查看和调整虚拟机运行参数。

常用的调优工具

  • JConsole:Java Monitoring and Management Console 是从 Java 5开始,在 JDK 中自带的 Java 监控和管理控制台,用于对 JVM 中内存,线程和类等的监控。
  • JVisualVm:JDK 自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC 变化等。
  • JProfiler:由 EJ 技术有限公司针对 Java 应用程序开发的性能监控工具,可以对 JVM 进行精确的监控,其中堆遍历、CPU 剖析、线程剖析是定位当前系统瓶颈的有效手段。