31

JVM 笔记:垃圾收集算法与垃圾收集器

 4 years ago
source link: https://mp.weixin.qq.com/s/XHwIslGWMd-8rQXLa4nl_g
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

1. 一些概念

1.1 垃圾&垃圾收集

  • 垃圾:在 JVM 语境下,“垃圾”指的是死亡的对象所占据的堆空间。

  • 垃圾收集:所谓“垃圾收集”,就是将已分配出去、但不再使用的内存回收回来,以便能再次分配。

1.2 对象是否死亡

如何判断一个对象是否死亡(即不可能再被任何途径使用)?通常有以下两种方法:

1.2.1 引用计数法

引用计数法(Reference Counting):为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。当有地方引用它时,计数器加一;引用失效时减一。当某个对象的引用计数为零时,说明该对象已死亡,便可以被回收了。

  • 主要优点:原理简单,判定效率高。

  • 主要缺点:无法解决循环依赖的问题(对象之间相互循环引用)。

1.2.2 可达性分析算法

可达性分析(Reachability Analysis)的基本思路如下:

通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链"(Reference Chain),若某个对象到 GC Roots 间没有任何引用链相连(或者用图论的话来说就是从 GC Roots 到这个对象不可达时)则证明此对象是不可能再被使用的。

示意图如下:

iqUzUbn.jpg!web

GC Roots 可理解为「堆外指向堆内的引用」。在 Java 技术体系中,固定可作为 GC Roots 的对象包括以下几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象

  2. 方法区中类静态属性引用的对象(比如引用类型的静态变量)

  3. 方法区中常量引用的对象(比如字符串常量池的引用)

  4. 本地方法栈中 Native 方法引用的对象

  5. JVM 内部的引用(例如:基本数据类型的 Class 对象、常驻异常、系统类加载器)

  6. 同步锁(synchronized 关键字)持有的对象

  7. 反应 JVM 内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等

1.3 引用的分类

无论是引用计数法还是可达性分析算法,二者都离不开「引用」。JDK 1.2 之后,Java 中「引用」的概念得以扩充,主要分为以下四种:

  • 强引用(Strongly Reference)

    • 例如: Object obj = new Object()

    • 特点:无论任何情况,只要强引用存在,垃圾收集器永不回收被引用的对象

  • 软引用(Soft Reference)

    • 场景:用于一些还有用、但非必须的对象

    • 时机:被软引用关联的对象,在系统将发生 OOM 前,回收这些内存

    • 实现:java.lang.ref.SoftReference

  • 弱引用(Weak Reference)

    • 场景:非必须对象,比软引用更弱

    • 时机:被弱引用关联的对象只能生存到下一次垃圾收集发生(无论内存是否充足都会回收)

    • 实现:java.lang.ref.WeakReference

  • 虚引用(Phantom Reference)

    • 又称“幽灵引用”或“幻影引用”

    • 特点:最弱的引用,是否存在完全不会影响其生存时间,无法通过它获取对象实例

    • 唯一目的:该对象被回收时收到一个系统通知

    • 实现:java.lang.ref.PhantomReference

1.4 回收方法区

《Java 虚拟机规范》并未要求虚拟机在方法区实现垃圾收集。方法区垃圾收集 “性价比 较低。

但在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 JVM 具备类型卸载的能力,以避免方法区内存压力过大。

方法区垃圾回收的主要内容包括:废弃的常量和不再使用的类型。它们的判定条件如下:

  • 废弃的常量

    • 无任何对象引用常量池中的常量

    • 虚拟机中无任何其他地方引用该字面量

  • 不再使用的类型

    • 该类所有的实例(包括子类)都已被回收

    • 该类的类加载器已被回收

    • 该类的 Class 对象任何地方未被引用,任何地方无法通过反射访问该类的方法

虚拟机参数:

# 是否对类型回收

-Xnoclassgc


# 查看类加载和卸载

-verbose:class -XX:+TraceClassLoading -XX:+TraceClassUnLoading

2. 垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集器可分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,也称“直接垃圾收集”和“间接垃圾收集”。

这里的垃圾回收算法都属于“追踪式垃圾收集”的范畴。

2.0 分代收集理论

当代商业虚拟机的垃圾收集器,多数都遵循了“分代收集”(Generational Collection)的理论。

分代收集理论,实质是一套符合大多数程序运行实际情况的经验法则,有如下三条:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

  3. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

根据以上几条规则,可以推测:

  • 若一个区域中大多数对象都是朝生夕灭,把它们集中在一起,每次回收只需关注如何保留少量存活的对象;

  • 若一个区域剩下的都是难以消亡的对象,把它们集中在一起,便可以较低的频率回收该区域。

如何解决跨代引用问题?

依据跨代引用假说,为了解决极少数跨代引用,只需在新生代建立一个“记忆集(Remembered Set)”,把老年代划分为若干小块,标识出哪一块内存会存在跨代引用,此后发生 Minor GC 时,只有包含跨代引用的小块内存中的对象才会被加入到 GC Roots 进行扫描(避免扫描整个老年代)。

一些 GC 概念:

  • 部分收集(Partial GC):目标不是完整收集整个 Java 堆的垃圾收集

    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集。

    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集。目前只有 CMS 收集器会有单独收集老年代的行为。

    • 混合收集(Mixed GC):是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。

  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

下面介绍常见的垃圾收集算法。

2.1 标记-清除算法

标记-清除(Mark-Sweep)算法:最早、最基础的算法,分为“标记”和“清除”两个阶段:

  1. 标记出所有需要回收的对象(或反之);

  2. 在标记完成后统一回收所有被标记的对象(或反之)。

后续的收集算法大多都是以“标记-清除”算法为基础,对其缺点进行改进而得。

该算法的示意图如下:

jqm2Mri.jpg!web

优缺点分析:

  • 主要优点:实现简单;

  • 主要缺点:

    • 执行效率不稳定,标记和清除过程效率都不高(标记对象较多时);

    • 内存空间碎片化问题(碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作)。

图片来自:http://www.cnblogs.com/dolphin0520/p/3783345.html

2.2 标记-复制算法

简称复制(Copying)算法。现在的商用 Java 虚拟机大都优先采用了这种收集算法回收新生代。

“半区复制”(Semispace Copying)算法将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块用完时,就将还存活的对象复制到另外一块上,然后再把已使用的内存空间一次清理掉。

该算法主要为了解决“标记-清除”算法面对大量可回收对象时执行效率低的问题。

示意图如下:

jQvaAve.jpg!web

“半区复制”算法的优缺点:

  • 优点:实现简单,运行高效且不易产生内存碎片;

  • 缺点:可用内存缩小为原来的一半,空间浪费太多。

一般不需要按照 1:1 的比例划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1 (即“浪费”了 10% 的新生代空间)。

由于无法保证每次每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(大多指老年代)进行分配担保(Handle Promotion),也就是直接进入老年代(相当于“兜底方案”)。

2.3 标记-整理算法

复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。

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

标记-清除算法与标记-整理算法区别:前者是一种非移动式的回收算法,后者是移动式的。

示意图:

baIjMbN.jpg!web

主要问题:

  • 移动存活对象:回收内存时会更复杂(Stop The World);

  • 不移动存活对象:分配内存时会更复杂(空间碎片问题)。

2.4 分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法(不是一种具体的算法实现,可以理解为「组合模式」):根据对象存活周期的不同,将内存划分为几块。

一般把 Java 堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法。

  • 新生代

每次垃圾收集时,都有大批对象死去,只有少量存活,采用复制算法(只需复制少量存活对象)。

  • 老年代

对象存活率较高,没有额外空间对它进行分配担保,必须使用“标记-清除”或“标记-整理”算法进行回收。

3. 垃圾收集器

前面的收集算法只是内存回收的方法论,而垃圾收集器才是内存回收的具体实现(可理解为“接口”与“实现类”的关系)。

3.1 Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器。特点:

  1. 单线程收集,且垃圾收集时,必须暂停其他所有的工作线程(Stop The World, STW),直到它收集结束。

  2. HotSpot 虚拟机运行在 Client 模式下默认新生代收集器。

  3. 优于其他收集器的地方:简单而高效(与其他收集器的单线程比)。对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

运行示意图如下:

FNZvyuI.jpg!web

3.2 ParNew 收集器

ParNew 收集器实质上是 Serial 收集器的多线程并行版本。

除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数(-XX:SurvivorRatio, -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure 等)、收集算法、Stop The World、对象分配原则、回收策略等都与 Serial 收集器完全一致。

运行示意图:

3U32mu7.jpg!web

  • 使用/禁用该收集器的 VM 参数

# 注:JDK 9 取消了 -XX:+UseParNewGC 参数

-XX:+/-UseParNewGC

3.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器是新生代收集器,也是使用标记-复制算法实现的、并行收集的多线程收集器,也称“吞吐量优先收集器”。

与 ParNew 类似,但关注点不同:

  • CMS 等收集器:尽可能地缩短垃圾收集时用户线程的停顿时间;

  • Parallel Scavenge 收集器:达到一个可控的吞吐量(Throughput)。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),即运行用户代码时间所占比重。

响应速度快能提升用户体验;而吞吐量高则能更高效地利用 CPU 资源,尽快完成程序的计算任务(主要适合在后台运算而不需要太多交互的任务)。

运行示意图如下:

jEN3qyQ.jpg!web

  • 虚拟机参数

# 最大垃圾收集停顿时间(毫秒)

-XX:MaxGCPauseMillis


# 设置吞吐量(0~100)

-XX:GCTimeRatio


# 自适应调节策略

-XX:UseAdaptiveSizePolicy

3.4 Serial Old 收集器

Serial 收集器的老年代版本,单线程,使用“标记-整理”算法。主要用于客户端模式下的 HotSpot 虚拟机。

运行示意图如下:

FNZvyuI.jpg!web

3.5 Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,使用多线程和“标记-整理”算法实现。

运行示意图如下:

jEN3qyQ.jpg!web

在注重吞吐量或者处理器资源较为稀缺的场合,都可以考虑 Parallel Scavenge + Parallel Old 收集器的组合。

3.6 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以「获取最短回收停顿时间」为目标的收集器。

它基于“标记-清除”算法实现,运作过程分为四步:

  1. 初步标记(CMS initial mark):只标记 GC Roots 能直接关联到的对象,速度很快;

  2. 并发标记(CMS concurrent mark):从 GC Roots 遍历整个对象图,耗时较长,但无需停顿用户线程(可与用户线程并发执行);

  3. 重新标记(CMS remark):修正并发标记期间,因用户线程导致标记产生变动的标记记录;

  4. 并发清除(CMS concurrent sweep):清理删除标记阶段判断的已经死亡的对象,可与用户线程并发执行。

运行示意图如下:

36JVFfN.jpg!web

目前很大一部分 Java 应用集中在互联网网站或者 B/S 系统的服务上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器非常符合这类应用的需求。

CMS 的主要优缺点

  • 主要优点: 发收集、 低停顿

  • 主要缺点

    • 对处理器资源非常敏感,降低吞吐量。

    • 无法处理“浮动垃圾”,有可能出现“Concurrent Mode Failure”失败而导致另一次完全 Stop The World 的 Full GC 的产生。

    • 内存空间碎片问题

浮动垃圾(Floating Garbage):在 CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这部分垃圾就是“浮动垃圾”。

  • 虚拟机参数

# 使用 CMS 收集器

-XX:+UseConcMarkSweepGC


# 老年代使用空间的比例(需根据实际情况权衡)

-XX:CMSInitiatingOccupancyFraction=80


# Full GC 时开启内存碎片的合并整理

-XX:+UseCMSCompactAtFullCollection

3.7 Garbage First 收集器

Garbage First(G1) 收集器是垃圾收集器发展史上里程碑式的成果,开创了「面向局部收集」的设计思路和「基于 Region」的内存布局形式。

G1 收集器的定位是「CMS 收集器的替代者和继承人」。由于实现较复杂,后文另行分析。这里只做简单描述:

  • JDK 7 Update 40 时,Oracle 认为它达到了足够成熟的商用程度;

  • JDK 8 Update 40 时;G1 收集器提供了并发的类卸载支持,被 Oracle 称为“全功能的垃圾收集器(Fully-Featured Garbage Collector)”。

此外,还有一些更为先进的低延迟收集器,比如 OracleJDK 11 加入的 ZGC,RedHat 公司的 Shenandoah 收集器。另外,还有一个有点“奇葩”的 Epsilon 收集器,等等。

衡量垃圾收集器优劣的指标主要有三个:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency)。此三者构成了一个「三元悖论」(类似分布式系统中的 CAP 原则),难以同时满足。

4. 小结

本文简要介绍了一些垃圾收集的相关概念,常用的垃圾收集算法以及经典的垃圾收集器。由于 G1 收集器实现稍复杂,因此后面单独分析。本文主要内容概括如下图:

BNzu6b7.jpg!web

(后台回复「垃圾收集」可获取高清图片链接)

fAV7JvM.png!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK