垃圾回收(Garbage Collection,简称GC)是Java语言最重要的特性之一。在没有GC的语言中,比如C或C++,开发者必须手动调用free或delete来释放内存,一旦忘记释放就会产生内存泄漏,一旦重复释放就会导致程序崩溃。Java通过虚拟机自动管理内存,把判定对象是否存活、回收无用内存这些工作交给了垃圾回收器,开发者只需要关注业务逻辑本身。不过GC并不是万能的,理解它的基本原理,对于排查内存泄漏、优化程序性能至关重要。

一、GC回收的区域在哪里:先弄懂JVM内存划分
要理解垃圾回收,首先要明白GC回收的是哪块内存。JVM运行时数据区主要分为堆、虚拟机栈、本地方法栈、方法区和程序计数器几个部分。其中虚拟机栈、本地方法栈和程序计数器是线程私有的,随线程生随线程灭,不需要垃圾回收器操心。真正需要GC介入的是堆和方法区(在JDK 8之后由元空间实现)。
堆是对象实例的主要存放地,也是GC工作的主战场。为了提高回收效率,堆通常被划分为新生代和老年代两块。新生代又分为一个Eden区和两个Survivor区,默认比例是8:1:1。绝大多数新创建的对象首先在Eden区分配,经过一次Minor GC后存活的对象会被移动到Survivor区,每熬过一次GC年龄就加一,当年龄达到阈值(默认15)时晋升到老年代。这种分代设计基于一个经验规律:大多数对象的生命周期都很短,朝生夕死,针对不同年龄段的对象采用不同的回收策略能显著提升效率。
老年代存放的是长期存活的大对象和从新生代晋升过来的对象。当老年代空间不足时会触发Major GC或Full GC,这类回收通常涉及整个堆和方法区,耗时长、停顿明显,是性能调优时重点关注的对象。
二、如何判断对象已经死亡:两种判定算法
垃圾回收器要回收内存,第一步就是判断哪些对象是无用的。目前主要有两种判定思路。
第一种是引用计数算法,做法是给每个对象维护一个引用计数器,被引用一次加一,引用失效减一,计数为零就判定可回收。这种方法实现简单、判定效率高,但存在一个致命缺陷:无法处理循环引用。假设对象A引用对象B,对象B又引用对象A,即使它们都不再被外界使用,各自的计数器都不为零,永远不会被回收。Python语言采用的是这种算法并配合额外手段解决循环引用,而Java虚拟机并没有选择它。
第二种是可达性分析算法,这是HotSpot虚拟机采用的主流方案。基本思路是从一系列被称为GC Roots的根对象出发,沿着引用链向下搜索,能到达的对象就是存活的,不可达的对象则判定为可回收。可作为GC Roots的对象包括:虚拟机栈中引用的对象、方法区中类的静态变量引用的对象、本地方法栈中JNI引用的对象,以及被同步锁持有的对象等。举个直观的例子,把对象之间的引用关系想象成一张图,GC Roots就是这张图的入口,从入口无法走到的节点就是孤立的无用对象,即使这些节点之间互相引用也无济于事。
public class GCDemo {
public static void main(String[] args) {
Object a = new Object(); // a作为局部变量,被虚拟机栈引用,是GC Roots可达对象
Object b = new Object();
a = null; // 原对象失去引用,下次GC时会被回收
b = null;
System.gc(); // 建议虚拟机执行垃圾回收,但不保证立即执行
}
}除了判定算法,Java还提供了四种引用强度不同的类型:强引用、软引用、弱引用和虚引用。强引用只要存在就永远不会被回收;软引用在内存不足时才会被回收,常用于缓存;弱引用只要发生GC就会被回收;虚引用则纯粹为了跟踪对象被回收的过程。灵活运用这些引用类型,可以在缓存、内存敏感场景下实现精细化的内存管理。
三、主流垃圾收集器对比:从Serial到G1
确定了要回收的对象,接下来就是怎么回收的问题。不同的垃圾收集器在回收算法和停顿时间上各有取舍,理解它们的特点才能做出合适的选择。
Serial收集器是最古老的单线程收集器,进行垃圾回收时会暂停所有用户线程(Stop The World),但它简单高效,没有线程交互开销,适合单核CPU或客户端模式下的应用。Parallel Scavenge收集器(并行收集器)是Serial的多线程版本,JDK 8的默认收集器,注重吞吐量,适合后台运算型任务,停顿时间相对较长但整体吞吐量高。
CMS(Concurrent Mark Sweep)收集器以最短停顿时间为目标,它的标记过程与用户线程并发执行,分为初始标记、并发标记、重新标记和并发清除四个阶段,其中只有初始标记和重新标记会短暂停顿。CMS的代价是消耗CPU资源、无法处理浮动垃圾,并且基于标记清除算法会产生内存碎片。需要注意的是,CMS在JDK 9之后被标记为废弃,JDK 14中已被正式移除。
G1(Garbage First)收集器是JDK 9之后的默认收集器,它把堆划分为多个大小相等的Region,不再物理隔离新生代和老年代,回收时优先处理垃圾最多的区域(这正是Garbage First名字的由来),从而在可控的停顿时间内获得最高的回收效率。G1兼顾了吞吐量和停顿时间,适合大内存、多核服务器的应用场景。JDK 11之后还出现了实验性的ZGC和Shenandoah,能够把停顿时间控制在毫秒甚至亚毫秒级别。下面的表格对常见收集器做了简单对比:
| 收集器 | 线程模型 | 算法特点 | 适用场景 |
|---|---|---|---|
| Serial | 单线程 | 复制算法,停顿明显 | 客户端小应用 |
| Parallel Scavenge | 多线程并行 | 复制算法,注重吞吐量 | 后台计算任务 |
| CMS | 并发 | 标记清除,低停顿 | 对响应时间敏感(已废弃) |
| G1 | 并发加并行 | 标记整理,Region分区 | 大堆服务器应用 |
四、GC调优的实用思路
理解原理之后,实际开发中遇到GC频繁、停顿过长等问题时,可以从几个方面入手。首先学会分析GC日志,通过JVM参数-XX:+PrintGCDetails(JDK 9之后推荐使用-Xlog:gc*)可以输出详细的回收信息,观察Minor GC和Full GC的频率与耗时是判断问题的第一步。如果Full GC频繁发生,通常要检查是否存在内存泄漏、大对象过早进入老年代,或者堆空间设置不合理。
其次合理设置堆大小。新生代太小会导致对象过早晋升到老年代,引发频繁的Full GC;太大则会增加单次Minor GC的时间。一般建议通过压测找到平衡点,而不是盲目调大内存。最后,警惕代码层面的内存泄漏,比如静态集合持有对象引用不释放、未关闭的资源、不当的ThreadLocal使用等,这些才是GC无能为力的根源。垃圾回收再智能,也只能回收不可达的对象,被引用着的垃圾它永远无法清理。