Java 虚拟机(JVM)的垃圾回收(Garbage Collection,GC)机制是自动管理内存的核心,其核心目标是识别并回收不再被使用的对象所占用的内存,避免内存泄漏和溢出。以下从垃圾判断方法、垃圾回收算法和具体垃圾收集器三个层面详细说明:
一、垃圾判断方法:如何识别 "垃圾" 对象
垃圾回收的前提是判断哪些对象已经不再被使用(即 "垃圾")。JVM 主要采用两种判断方式:
1. 引用计数法(Reference Counting)
- 原理:每个对象维护一个 "引用计数器",当对象被引用时计数器 + 1,引用失效时 - 1;当计数器为 0 时,认为对象是垃圾。
- 优点:实现简单,判断效率高。
- 缺点:无法解决循环引用问题(如 A 引用 B,B 引用 A,两者计数器均不为 0,但实际已无外部引用)。
- 现状:Java 虚拟机未采用这种方式(因循环引用问题),Python 等语言使用。
2. 可达性分析算法(Reachability Analysis)
- 原理:以 "GC Roots" 为起点,通过引用链遍历对象;若对象无法通过任何引用链连接到 GC Roots,则被判定为垃圾。
- GC Roots:指一系列 "根对象",包括:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中 JNI(Native 方法)引用的对象。
- 优点:解决了循环引用问题,是 Java 虚拟机的核心判断方式。
- 扩展:Java 中的 "引用" 被细分为 4 种类型(强引用、软引用、弱引用、虚引用),不同引用类型影响对象被回收的时机(如软引用在内存不足时才会被回收)。
二、垃圾回收算法:如何回收垃圾
确定垃圾对象后,需要通过具体算法回收其内存。常见的基础算法包括:
1. 标记 - 清除算法(Mark-Sweep)
- 步骤:
- 标记:通过可达性分析标记所有存活对象(非垃圾);
- 清除:遍历堆内存,回收所有未被标记的对象(垃圾)。
- 优点:实现简单,无需移动对象。
- 缺点:
- 效率低:标记和清除过程都需要遍历整个堆,耗时较长;
- 内存碎片:回收后会产生大量不连续的内存碎片,可能导致大对象无法分配内存。
2. 标记 - 复制算法(Mark-Copy)
- 步骤:
- 将堆内存分为大小相等的两块(如 A 和 B),仅使用 A 块;
- 标记:标记 A 块中的存活对象;
- 复制:将 A 块中所有存活对象复制到 B 块(按顺序连续放置);
- 清除:清空 A 块,后续内存分配仅使用 B 块(下次回收时交换角色)。
- 优点:
- 效率高:复制存活对象的成本低于清除大量垃圾;
- 无内存碎片:存活对象连续放置,内存分配简单(指针碰撞即可)。
- 缺点:
- 内存利用率低:仅能使用一半内存;
- 不适合存活对象多的场景(复制成本高)。
- 适用场景:Java 新生代(因新生代对象存活时间短,存活对象少)。
3. 标记 - 整理算法(Mark-Compact)
- 步骤:
- 标记:标记所有存活对象;
- 整理:将所有存活对象向内存一端移动,按顺序排列;
- 清除:直接清除边界外的所有内存(垃圾)。
- 优点:解决了标记 - 清除的内存碎片问题,且内存利用率 100%。
- 缺点:整理阶段需要移动对象,成本较高(尤其是老年代对象存活时间长,移动成本大)。
- 适用场景:Java 老年代(因老年代对象存活时间长,存活对象多,需避免碎片)。
4. 分代收集算法(Generational Collection)
- 原理:根据对象存活周期将堆内存分为新生代和老年代,针对不同区域采用不同算法(结合上述基础算法的优势):
- 新生代:对象存活时间短(朝生夕死),适合标记 - 复制算法(只需复制少量存活对象);
- 老年代:对象存活时间长(存活概率高),适合标记 - 清除或标记 - 整理算法(避免频繁移动对象)。
- 细节:新生代进一步分为 Eden 区(80%)和两个 Survivor 区(From、To 各 10%),分配对象时先在 Eden 区,回收时将存活对象复制到 Survivor 区,多次存活后进入老年代。
- 现状:几乎所有 Java 虚拟机都采用分代收集算法作为基础框架。
三、垃圾收集器:算法的具体实现
垃圾收集器是垃圾回收算法的具体实现,不同收集器针对不同场景(如吞吐量、延迟)优化。Java 虚拟机中常见的收集器包括:
1. Serial GC(串行收集器)
- 特点:单线程执行垃圾回收,回收时暂停所有用户线程("Stop The World",STW)。
- 算法:
- 新生代:标记 - 复制;
- 老年代:标记 - 整理。
- 优点:实现简单,内存占用少,适合单核 CPU 环境。
- 缺点:STW 时间长,不适合多线程、大堆内存应用。
- 适用场景:客户端应用(如桌面程序),JVM 默认客户端模式下的收集器。
2. ParNew GC(并行新生代收集器)
- 特点:Serial GC 的多线程版本,仅作用于新生代,老年代仍需配合 Serial Old 或 CMS。
- 算法:新生代采用标记 - 复制(多线程并行标记和复制)。
- 优点:利用多 CPU 加速新生代回收,减少 STW 时间。
- 缺点:仍有 STW,老年代若配合 Serial Old 会导致长停顿。
- 适用场景:多 CPU 环境下的服务端应用,常作为 CMS 收集器的新生代搭档。
3. Parallel Scavenge GC(并行清除收集器)
- 特点:注重吞吐量(吞吐量 = 用户代码执行时间 /(用户代码时间 + GC 时间)),属于 "吞吐量优先" 收集器。
- 算法:
- 新生代:标记 - 复制(多线程并行);
- 老年代:Parallel Old(标记 - 整理,多线程并行)。
- 优点:可自动调节 GC 参数(如新生代大小、晋升老年代阈值)以追求最高吞吐量。
- 缺点:STW 时间可能较长,不适合对延迟敏感的应用。
- 适用场景:后台计算(如数据分析)等对吞吐量要求高、可接受一定停顿的场景。
4. CMS(Concurrent Mark Sweep,并发标记清除)
- 特点:以低延迟为目标,尽可能减少 STW 时间,老年代收集器(需配合 ParNew 作为新生代收集器)。
- 步骤(核心是 "并发",即 GC 线程与用户线程同时执行):
- 初始标记:标记 GC Roots 直接关联的对象(STW,时间短);
- 并发标记:从初始标记的对象出发,遍历引用链(与用户线程并发,无 STW);
- 重新标记:修正并发标记期间因用户线程操作导致的引用变化(STW,时间较短);
- 并发清除:回收所有未标记的对象(与用户线程并发,无 STW)。
- 优点:并发收集,STW 时间短,适合对延迟敏感的应用(如 Web 服务)。
- 缺点:
- CPU 敏感:并发阶段会占用 CPU 资源,影响用户线程;
- 内存碎片:基于标记 - 清除算法,老年代易产生碎片;
- 需预留内存:并发清除时用户线程仍在分配内存,需保证内存不耗尽。
- 现状:JDK 9 中被标记为 deprecated,JDK 14 中移除,被 G1 等收集器替代。
5. G1(Garbage-First)
- 特点:区域化分代式收集器,兼顾吞吐量和延迟,适用于大堆内存(如 4GB 以上)。
- 内存布局:将堆分为多个大小相等的 Region(1MB~32MB),每个 Region 可动态标记为新生代(Eden/Survivor)或老年代,无需物理隔离。
- 核心思想:优先回收 "垃圾最多的 Region"(Garbage-First),减少 GC 时间。
- 步骤:
- 初始标记:标记 GC Roots 直接关联的对象(STW);
- 并发标记:遍历引用链,计算每个 Region 的垃圾占比(与用户线程并发);
- 最终标记:修正并发标记的偏差(STW,使用 SATB 算法高效处理);
- 筛选回收:根据 Region 的垃圾占比排序,优先回收垃圾多的 Region(多线程并行,STW,采用标记 - 复制算法避免碎片)。
- 优点:
- 灵活处理大堆内存,延迟可控(可设置最大 STW 时间);
- 无内存碎片(筛选回收时采用复制算法)。
- 适用场景:JDK 9 及以上默认收集器,适合中大型应用(如服务器、云原生应用)。
6. 低延迟收集器(ZGC、Shenandoah)
ZGC(JDK 11 引入):
- 目标:STW 时间不超过 10ms,支持 TB 级堆内存。
- 特点:基于 Region,采用 "着色指针" 和 "读屏障" 技术,几乎全程并发(仅初始标记和最终标记有极短 STW)。
Shenandoah(OpenJDK 引入,非 Oracle JDK 默认):
- 目标:低延迟,支持大堆。
- 特点:采用 "并发整理" 算法,在并发阶段移动对象(通过转发指针和写屏障实现),几乎无 STW。
适用场景:对延迟要求极高的应用(如高频交易、实时数据处理)。
总结
Java 虚拟机的垃圾回收机制是一个 "判断垃圾 - 选择算法 - 具体实现" 的完整体系:
- 通过可达性分析判断垃圾对象;
- 基于分代思想,结合标记 - 清除、复制、整理等基础算法;
- 不同垃圾收集器(如 Serial、CMS、G1、ZGC)针对吞吐量、延迟等不同目标优化,需根据应用场景选择。
实际开发中,需通过监控工具(如 JConsole、VisualVM)分析 GC 日志,选择合适的收集器并调优参数(如堆大小、新生代比例),以平衡性能需求。