跳到主要内容

垃圾回收机制

垃圾回收(Garbage Collection,GC)是JVM自动内存管理的核心机制。它负责自动回收不再使用的对象所占用的内存,让开发者从繁琐的内存管理中解放出来。理解GC机制对于编写高性能Java应用和排查内存问题至关重要。

为什么需要垃圾回收?

手动内存管理的问题

在C/C++等语言中,开发者需要手动管理内存:

// C语言手动内存管理
void example() {
int* ptr = (int*)malloc(sizeof(int) * 100);
// 使用ptr...
free(ptr); // 必须手动释放,否则内存泄漏

// 忘记释放?内存泄漏!
// 重复释放?程序崩溃!
// 释放后继续使用?未定义行为!
}

Java的自动内存管理

Java通过垃圾回收机制自动管理内存:

public class GCExample {
public void example() {
Object obj = new Object();
// 使用obj...
// 无需手动释放,GC会自动回收
}
}

如何判断对象可以被回收?

引用计数法

引用计数法通过记录对象被引用的次数来判断是否可以回收:

对象A(引用计数=2)
├── 引用 → 对象B(引用计数=1)
└── 引用 → 对象C(引用计数=0) ← 可回收

引用计数法的工作原理

  • 每当有一个地方引用对象,计数+1
  • 每当引用失效,计数-1
  • 计数为0时,表示没有引用,可以回收

优点:实现简单,效率高

缺点:无法解决循环引用问题

// 循环引用示例
public class ReferenceCountingGC {
public Object instance = null;

public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();

objA.instance = objB; // A引用B
objB.instance = objA; // B引用A

objA = null;
objB = null;
// 循环引用,引用计数都不为0,无法回收!
// objA计数=1, objB计数=1
}
}

问题说明:尽管objA和objB都不再被外部引用,但由于它们相互引用,引用计数都是1,引用计数法无法识别出这是垃圾。这是引用计数法的致命缺陷。

public class ReferenceCountingGC {
public Object instance = null;

public static void main(String[] args) {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();

objA.instance = objB; // A引用B
objB.instance = objA; // B引用A

objA = null;
objB = null;
// 循环引用,引用计数法无法回收
}
}

可达性分析算法

JVM使用可达性分析算法来判断对象是否存活。从"GC Roots"出发,搜索所有可达的对象,不可达的对象即为垃圾。

GC Roots(垃圾回收根节点)

├── 栈中引用 → 对象1(存活)→ 对象2(存活)→ 对象3(存活)
│ ↘ 对象4(存活)

├── 方法区静态属性 → 对象1(存活)

├── 方法区常量引用 → ...

└── 本地方法栈JNI引用 → ...

对象5 → 对象6(循环引用,但不可从GC Roots到达,对象5和6可回收)

可达性分析算法的工作原理

  1. 从GC Roots出发,通过引用链遍历所有对象
  2. 能够被GC Roots访问到的对象称为"可达对象"(存活)
  3. 无法被GC Roots访问到的对象称为"不可达对象"(可回收)
  4. 即使对象之间有循环引用,只要从GC Roots无法到达,同样会被回收

GC Roots包括

  1. 虚拟机栈中引用的对象:方法中的局部变量、参数
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI引用的对象
  5. 同步锁持有的对象
  6. JVM内部引用:基本类型的Class对象、常驻异常对象等

Java中的引用类型

JDK1.2之后,Java将引用分为四种类型,更灵活地管理对象生命周期:

1. 强引用(Strong Reference)

最常见的引用类型,只要强引用存在,垃圾回收器永远不会回收。

Object obj = new Object();  // 强引用
obj = null; // 解除引用,对象才可能被回收

2. 软引用(Soft Reference)

内存不足时会被回收,适合实现缓存。

import java.lang.ref.SoftReference;

public class SoftReferenceExample {
public static void main(String[] args) {
Object obj = new Object();
SoftReference<Object> softRef = new SoftReference<>(obj);

obj = null; // 解除强引用

// 内存不足时,softRef.get()可能返回null
System.out.println(softRef.get());
}
}

3. 弱引用(Weak Reference)

无论内存是否充足,GC时都会被回收。

import java.lang.ref.WeakReference;

public class WeakReferenceExample {
public static void main(String[] args) {
Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(obj);

obj = null;
System.gc(); // GC后,weakRef.get()返回null
System.out.println(weakRef.get());
}
}

4. 虚引用(Phantom Reference)

无法通过虚引用获取对象,用于跟踪对象被垃圾回收的活动。

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceExample {
public static void main(String[] args) {
Object obj = new Object();
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);

obj = null;
System.out.println(phantomRef.get()); // 始终返回null

// 当对象被回收后,可以从queue中获取到PhantomReference
}
}

垃圾回收算法

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

最基础的收集算法,分为"标记"和"清除"两个阶段:

阶段一:标记

标记前:  [存活对象] [存活对象] [垃圾对象] [存活对象] [垃圾对象]
↓标记
标记后: [存活对象] [存活对象] [X标记] [存活对象] [X标记]

阶段二:清除

清除后:  [存活对象] [存活对象] [空闲空间] [存活对象] [空闲空间]

算法执行过程

  1. 标记阶段:遍历所有对象,标记存活的对象(标记为"存活")
  2. 清除阶段:遍历堆中所有对象,回收未被标记的对象(垃圾)的内存空间

优点:实现简单

缺点

  • 效率问题:标记和清除效率都不高,需要遍历整个堆
  • 空间问题:产生大量不连续的内存碎片,导致后续分配大对象时可能触发新的GC

复制算法(Copying)

将内存分为两块,每次只使用一块。当一块用完,将存活对象复制到另一块,然后清空当前块。

复制算法执行过程

复制前:
┌─────────────────────┐ ┌─────────────────────┐
│ From区 │ │ To区 │
│ (正在使用) │ │ (空闲) │
│ │ │ │
│ [对象1] 存活 │ │ │
│ [垃圾] │ │ │
│ [对象2] 存活 │ │ │
│ [垃圾] │ │ │
│ [对象3] 存活 │ │ │
└─────────────────────┘ └─────────────────────┘

复制后:将存活对象复制到To区,然后清空From区
┌─────────────────────┐ ┌─────────────────────┐
│ From区 │ │ To区 │
│ (空闲) │ │ (正在使用) │
│ │ │ │
│ │ │ [对象1] [对象2] │
│ │ │ [对象3] │
└─────────────────────┘ └─────────────────────┘

优点

  • 没有碎片问题:复制后内存连续
  • 实现简单,运行高效:只复制存活对象

缺点

  • 内存利用率低:只能使用一半的内存空间
  • 复制开销与存活对象数量成正比:存活对象越多,复制成本越高

优化:HotSpot JVM将新生代分为Eden:Survivor:Survivor = 8:1:1的比例

新生代内存布局:
┌───────────────────────────────┬────────────┬────────────┐
│ Eden区 │ Survivor0 │ Survivor1 │
│ (80%) │ (10%) │ (10%) │
└───────────────────────────────┴────────────┴────────────┘
↑ 新对象分配位置 │ │
复制交换位置 │
↓ ↓
下次GC时的交换

每次Minor GC时,将Eden区和From区中存活的对象复制到To区,然后交换From区和To区的角色。

// 新生代内存布局
// |---- Eden ----|-- S0 --|-- S1 --|
// 80% 10% 10%

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

标记后,将存活对象向一端移动,然后清理边界外的内存:

标记-整理算法执行过程

标记后(标记为存活的对象):
[存活对象] [存活对象] [X垃圾] [存活对象] [X垃圾] [存活对象]
↓ 移动(整理)
整理后:
[存活对象] [存活对象] [存活对象] [空闲] [空闲] [空闲]

算法执行过程

  1. 标记阶段:与标记-清除算法相同,标记所有存活对象
  2. 整理阶段:将所有存活对象向一端移动,紧凑排列
  3. 清除阶段:清理边界外的内存

优点

  • 没有碎片问题:存活对象连续排放
  • 内存利用率高:没有内存碎片

缺点

  • 移动对象成本高:需要移动大量存活对象
  • 需要更新引用:对象地址改变,需要更新所有引用

与标记-清除算法的对比

特性标记-清除标记-整理
内存碎片
分配速度慢(需找空闲位置)快(连续可用)
执行速度较快较慢(需移动对象)
适用场景存活对象少存活对象多

分代收集算法

根据对象存活周期的不同,将内存划分为几块,采用不同的收集算法:

JVM堆内存
┌─────────────────────────────────────────────────┐
│ 堆内存 │
│ ┌─────────────────────────┬─────────────────┐ │
│ │ 新生代 │ 老年代 │
│ │ ┌─────┬─────┬─────┐ │ │ │
│ │ │Eden │ S0 │ S1 │ │ (标记-整理/ │ │
│ │ │ │ │ │ │ 标记-清除) │ │
│ │ └─────┴─────┴─────┘ │ │ │
│ │ 复制算法 │ │ │
│ └─────────────────────────┴─────────────────┘ │
└─────────────────────────────────────────────────┘

对象流向:
1. 新对象 → Eden区
2. Eden区满 → Minor GC → 存活对象复制到S0/S1
3. 对象每经历一次Minor GC,年龄+1
4. 年龄达到阈值(默认15) → 老年代
5. 老年代满 → Full GC

分代理论依据

  1. 弱分代假说:绝大多数对象都是朝生夕灭的

    • 新创建的对象很快就不再使用,应该被快速回收
    • 重点关注新生代的回收效率
  2. 强分代假说:熬过越多次垃圾收集的对象越难消亡

    • 存活越久的对象,继续存活的可能性越大
    • 减少对老年代的回收频率

新生代(Young Generation)

  • 存放新创建的对象
  • 采用复制算法(效率高)
  • 回收频率高,回收速度快
  • 大部分对象"朝生夕灭"

老年代(Old Generation)

  • 存放长期存活的对象
  • 采用标记-整理或标记-清除算法
  • 回收频率低,回收速度慢
  • 存活对象较多,复制算法成本太高

垃圾收集器

JVM提供了多种垃圾收集器,各有特点:

Serial收集器

单线程收集器,进行垃圾回收时必须暂停所有工作线程。

工作原理

时间轴:
──────────────────────────────────────────────────────────→
用户线程运行 ──→ [STW暂停] ──→ GC线程执行 ──→ [STW暂停] ──→ 用户线程恢复
↑清除垃圾 清除完成

用户线程(红色):必须暂停等待GC完成
GC线程(蓝色):单独执行垃圾回收

执行过程

  1. STW(Stop-The-World):暂停所有用户线程
  2. 单线程GC:使用单个GC线程进行垃圾回收(复制/标记-整理)
  3. 恢复执行:GC完成后,恢复用户线程运行

特点

  • 简单高效:没有线程切换开销,单线程效率高
  • 客户端模式:适合在客户端模式下运行
  • 小内存应用:堆内存较小的情况下表现良好
  • 参数-XX:+UseSerialGC

使用场景

  • 客户端应用(如桌面GUI应用)
  • 堆内存较小的应用(<100MB
  • 单核处理器环境

新生代Serial收集器:使用复制算法 老年代Serial收集器:使用标记-整理算法

Parallel Scavenge收集器

多线程收集器,关注吞吐量(运行用户代码时间/总时间)。

特点

  • 吞吐量优先
  • 适合后台计算任务
  • 参数:-XX:+UseParallelGC

相关参数

  • -XX:MaxGCPauseMillis:最大垃圾收集停顿时间
  • -XX:GCTimeRatio:吞吐量大小(0-100)
  • -XX:+UseAdaptiveSizePolicy:自适应调节策略

CMS收集器(Concurrent Mark Sweep)

以获取最短回收停顿时间为目标的收集器。

四个阶段

时间轴:
─────────────────────────────────────────────────────────────────────────────→

阶段1: 初始标记(STW) 阶段2: 并发标记 阶段3: 重新标记(STW) 阶段4: 并发清除
[暂停] ───────────────────────────── [暂停] ─────────────────────────────
↓ ↓
标记GC Roots 修正标记变化 标记新增的 清除垃圾
直接关联的对象 的对象 垃圾对象 对象

用户线程: ─────────── 运行 ─────────────────────────────── 运行 ───────────
↑ ↑ ↑
并发执行(不暂停) 并发执行(不暂停) 并发执行(不暂停)

各阶段详细说明

  1. 初始标记(Initial Mark):[STW暂停]

    • 标记GC Roots直接关联的对象
    • 速度很快,但需要暂停所有用户线程
    • 停顿时间:通常很短(几十毫秒)
  2. 并发标记(Concurrent Mark)

    • 从GC Roots出发,遍历整个对象图
    • 与用户线程并发执行,不需要暂停
    • 耗时较长,但不影响用户响应
  3. 重新标记(Remark):[STW暂停]

    • 修正并发标记期间因用户线程运行产生的变化
    • 停顿时间比初始标记长,但比串行收集器短
  4. 并发清除(Concurrent Sweep)

    • 并发清除未被标记的垃圾对象
    • 与用户线程并发执行

特点

  • 低停顿:大部分时间与用户线程并发执行
  • 并发收集:GC线程和用户线程同时运行
  • 参数-XX:+UseConcMarkSweepGC

缺点

  • 对CPU资源敏感:会占用CPU资源,影响用户线程
  • 无法处理浮动垃圾:并发标记期间产生的垃圾称为"浮动垃圾",只能等下次GC
  • 产生内存碎片:使用标记-清除算法,会产生内存碎片

CMS配置示例

# 启用CMS收集器
-XX:+UseConcMarkSweepGC

# 设置GC停顿时间目标(毫秒)
-XX:MaxGCPauseMillis=100

# 触发CMS老年代占比阈值
-XX:CMSInitiatingOccupancyFraction=70

G1收集器(Garbage First)

面向服务端的垃圾收集器,将堆内存划分为多个大小相等的Region:

G1堆内存布局

G1堆由多个大小相等的Region组成(每个Region约1MB-32MB)

┌────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│ E区 │ E区 │ S区 │ O区 │ H区 │ E区 │ S区 │
│(Eden) │(Eden) │(Survivor)│(Old) │(Humongous)│(Eden)│(Survivor)│
└────────┴────────┴────────┴────────┴────────┴────────┴────────┘
↑ 新对象分配 ↑ 复制到 ↑ 大对象 ↑ 复制到
↓ 区域 ↓ Survivor ↓ (超过Region ↓ Survivor
了一半大小) 晋升老年代

图例:
■ E (Eden) - 新生代区域 浅蓝色
■ S (Survivor) - 存活区 浅绿色
■ O (Old) - 老年代区域 浅粉色
■ H (Humongous) - 大对象区域 浅红色

Region说明

  • E (Eden):新创建的对象分配区域
  • S (Survivor):Minor GC后存活对象的区域
  • O (Old):长期存活的对象区域
  • H (Humongous):大对象区域(超过Region一半大小的对象)

G1的特点

  • 可预测停顿时间:可以设置期望的停顿时间目标
  • 无内存碎片:使用复制算法,Region内无碎片
  • 并行与并发:GC线程并行执行,部分阶段与用户线程并发
  • 参数-XX:+UseG1GC

工作模式

  1. Young GC:Eden区满时触发
  2. Mixed GC:老年代空间达到阈值时触发
  3. Full GC:内存不足时的兜底

ZGC收集器

JDK11引入的低延迟垃圾收集器,目标是将停顿时间控制在10ms以内。

特点

  • 停顿时间不超过10ms
  • 支持TB级大内存
  • 参数:-XX:+UseZGC

Shenandoah收集器

OpenJDK的低延迟收集器,目标与ZGC类似。

特点

  • 停顿时间与堆大小无关
  • 参数:-XX:+UseShenandoahGC

垃圾收集器选择

收集器适用场景特点
Serial客户端、小内存简单、单线程
Parallel吞吐量优先多线程、适合后台计算
CMS低延迟并发收集、有碎片
G1服务端通用可预测停顿、无碎片
ZGC大内存、低延迟10ms内停顿

选择建议

// 小型应用
-XX:+UseSerialGC

// 吞吐量优先(批处理、后台计算)
-XX:+UseParallelGC

// 低延迟优先(Web服务、实时系统)
-XX:+UseG1GC
// 或 JDK11+
-XX:+UseZGC

内存分配与回收策略

对象优先在Eden分配

大多数情况下,对象在新生代Eden区分配:

public class EdenAllocation {
public static void main(String[] args) {
byte[] allocation1 = new byte[2 * 1024 * 1024]; // 2MB
byte[] allocation2 = new byte[2 * 1024 * 1024]; // 2MB
byte[] allocation3 = new byte[2 * 1024 * 1024]; // 2MB
// Eden区不够,触发Minor GC
}
}

大对象直接进入老年代

大对象(需要大量连续内存的对象)直接在老年代分配:

// 参数:-XX:PretenureSizeThreshold=3145728(3MB)
byte[] largeObject = new byte[4 * 1024 * 1024]; // 直接进入老年代

长期存活对象进入老年代

对象在Survivor区每熬过一次Minor GC,年龄增加1,达到阈值后进入老年代:

// 参数:-XX:MaxTenuringThreshold=15(默认)
// 对象年龄达到15后进入老年代

动态对象年龄判定

如果Survivor区中相同年龄所有对象大小总和超过Survivor区的一半,年龄大于等于该年龄的对象直接进入老年代。

空间担保

Minor GC前,检查老年代最大可用连续空间是否大于新生代所有对象总大小:

  • 大于:安全进行Minor GC
  • 小于:检查是否允许担保失败
    • 允许:检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小
    • 不允许:进行Full GC

GC日志分析

开启GC日志

# JDK8及之前
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log

# JDK9及之后
-Xlog:gc*:file=gc.log:time,level,tags

日志示例分析

[GC (Allocation Failure) [PSYoungGen: 6144K->808K(7168K)] 6144K->4888K(25600K), 0.0034567 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]

解读

  • GC:垃圾收集类型(GC或Full GC)
  • Allocation Failure:触发原因
  • PSYoungGen:收集器名称和区域
  • 6144K->808K:收集前后该区域使用量
  • (7168K):该区域总大小
  • 6144K->4888K(25600K):堆内存使用情况
  • 0.0034567 secs:收集耗时

常见GC问题排查

内存泄漏

对象不再使用但无法被回收:

public class MemoryLeak {
private static final List<Object> cache = new ArrayList<>();

public void addToCache(Object obj) {
cache.add(obj); // 对象无法被回收
}
}

解决方法:使用WeakHashMap或定期清理

内存溢出(OOM)

// java.lang.OutOfMemoryError: Java heap space
byte[] hugeArray = new byte[Integer.MAX_VALUE];

// java.lang.OutOfMemoryError: Metaspace
// 大量动态生成类

排查步骤

  1. 开启堆转储:-XX:+HeapDumpOnOutOfMemoryError
  2. 分析dump文件:使用MAT、VisualVM等工具
  3. 定位内存泄漏或调整堆大小

Full GC频繁

可能原因

  • 老年代空间不足
  • 方法区/元空间不足
  • System.gc()调用
  • 堆转储或死锁检测

解决方法

  • 调整堆大小
  • 优化代码减少对象创建
  • 禁用显式GC:-XX:+DisableExplicitGC

小结

垃圾回收机制是JVM的核心功能:

  1. 判断对象存活:可达性分析算法
  2. 引用类型:强、软、弱、虚四种引用
  3. 垃圾收集算法:标记-清除、复制、标记-整理、分代收集
  4. 垃圾收集器:Serial、Parallel、CMS、G1、ZGC等
  5. 内存分配策略:Eden优先、大对象老年代、年龄晋升
  6. 问题排查:内存泄漏、OOM、Full GC频繁

理解GC机制有助于优化应用性能和排查内存问题。

参考资料