垃圾回收算法与收集器详解

25 阅读10分钟

前言:从内存管理的本质说起

在Java程序运行过程中,对象不断被创建、使用和废弃。当一个对象完成使命后,它就成为占用内存的"垃圾"。如果这些垃圾不及时清理,会导致内存泄漏、程序性能下降甚至崩溃。

垃圾回收的核心意义在于自动化管理内存生命周期,让开发者从繁琐的手动内存管理中解放出来。但这看似简单的"自动清理"背后,是JVM在默默承担着复杂的内存管理工作

一、垃圾回收的基础概念

1.1 什么是垃圾?

在Java中,垃圾指的是:任何不再被程序使用且无法通过任何引用访问的内存数据

核心判断依据:可达性分析

简单来说,如果从一组根对象(GC Roots)出发,无法到达某个对象,那么这个对象就是垃圾。

常见的垃圾产生场景:

  1. 引用被显式设置为null
  2. 对象超出作用域范围
  3. 集合中的对象被移除
  4. 对象被重新赋值

1.2 可达性分析的原理

可达性分析是JVM判断对象是否为垃圾的核心算法。它的工作原理如下:

1.2.1 GC Roots集合

JVM从一组称为"GC Roots"的根对象开始,通过引用链遍历所有可达对象。GC Roots包括:

  • 栈帧中的局部变量:当前正在执行的方法中的局部变量引用
  • 静态变量:类的静态变量引用的对象
  • 活动线程:正在运行的线程对象
  • JNI引用:本地方法栈中JNI引用的对象
  • 系统类对象:由系统类加载器加载的类对象

1.2.2 对象可达性级别

Java定义了四种引用类型,对应不同的可达性级别:

  1. 强引用(Strong Reference)

    • 最常见的引用类型,如:Object obj = new Object();
    • 只要强引用存在,对象永远不会被回收
  2. 软引用(Soft Reference)

    • 描述一些还有用但非必需的对象
    • 内存不足时会被回收,适合做缓存
  3. 弱引用(Weak Reference)

    • 描述非必需对象
    • 无论内存是否充足,下次GC时都会被回收
  4. 虚引用(Phantom Reference)

    • 最弱的引用,无法通过它获取对象实例
    • 主要用于对象回收的跟踪

二、垃圾回收算法详解

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

工作流程

  1. 标记阶段:从GC Roots开始,标记所有可达对象
  2. 清除阶段:清除所有未被标记的对象

优点

  • 实现相对简单
  • 不需要移动对象,适合存活对象多的场景

缺点

  • 会产生内存碎片
  • 标记和清除效率都不高
  • 需要暂停应用程序(Stop-The-World)

2.2 复制算法(Copying)

工作流程

  1. 将内存划分为大小相等的两块
  2. 每次只使用其中一块
  3. 垃圾回收时,将存活对象复制到另一块
  4. 清空已使用的那块内存

优点

  • 不会产生内存碎片
  • 内存分配简单高效(顺序分配)
  • 适合对象存活率低的场景

缺点

  • 内存利用率只有50%
  • 复制存活对象有额外开销
  • 不适合存活对象多的场景

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

工作流程

  1. 标记阶段:标记所有可达对象(同标记-清除)
  2. 整理阶段:将所有存活对象向一端移动
  3. 清理阶段:清理边界外的内存

优点

  • 不会产生内存碎片
  • 内存利用率高
  • 适合存活对象多的场景

缺点

  • 移动对象成本高
  • 需要多次遍历内存
  • 实现相对复杂

2.4 分代收集算法(Generational)

这是现代JVM的主流算法,基于两个观察:

  1. 弱分代假说:大多数对象很快死亡
  2. 强分代假说:存活较久的对象倾向于继续存活

内存划分

  1. 新生代(Young Generation)

    • Eden区:新对象在此创建
    • Survivor区(From/To):存放Minor GC后存活的对象
    • 采用复制算法
  2. 老年代(Old Generation)

    • 存放长期存活的对象
    • 通常采用标记-整理或标记-清除算法
  3. 永久代/元空间(JDK 8+)

    • 存放类元数据、常量池等

收集类型

  1. Minor GC/Young GC:只回收新生代
  2. Major GC/Old GC:只回收老年代(CMS特有)
  3. Full GC:回收整个堆和方法区

三、现代垃圾回收器详解

3.1 垃圾回收器发展历程

时期回收器特点适用场景
JDK 1.3-Serial单线程,简单客户端应用
JDK 1.4-Parallel多线程,吞吐优先后端应用
JDK 1.5-CMS并发,低延迟响应敏感应用
JDK 1.7-G1可预测停顿,平衡大内存应用
JDK 11-ZGC/Shenandoah亚毫秒停顿超大内存应用

3.2 经典回收器深度解析

3.2.1 Serial收集器

  • 特点:单线程,简单高效
  • 工作区域:新生代(复制算法)、老年代(标记-整理)
  • 适用场景:客户端应用,几百MB内存

3.2.2 Parallel收集器(吞吐量优先)

  • 特点:多线程并行收集
  • 目标:最大化吞吐量(用户代码运行时间 / 总时间)
  • 适用场景:后台计算型应用

3.2.3 CMS收集器(响应时间优先)

工作流程

  1. 初始标记:暂停应用,标记GC Roots直接可达对象
  2. 并发标记:与应用程序并发,标记所有可达对象
  3. 重新标记:暂停应用,修正并发标记期间的变动
  4. 并发清除:与应用程序并发,清除垃圾对象

优点

  • 大部分工作并发执行,停顿时间短
  • 适合对响应时间敏感的应用

缺点

  • 对CPU资源敏感
  • 无法处理浮动垃圾
  • 会产生内存碎片
  • 执行过程不可预测

3.2.4 G1收集器(Garbage First)

核心设计

  • 将堆划分为多个大小相等的Region(1MB-32MB)
  • 每个Region都可以作为Eden、Survivor或Old区域
  • 跟踪每个Region的垃圾价值(回收空间/回收时间)

工作流程

  1. 初始标记:标记GC Roots直接可达的对象
  2. 并发标记:标记所有可达对象
  3. 最终标记:处理剩余标记工作
  4. 筛选回收:根据停顿时间目标,选择回收价值最高的Region

优势

  • 可预测的停顿时间
  • 不会产生明显的内存碎片
  • 适合6GB以上内存的应用

3.3 新一代回收器

3.3.1 ZGC(Z Garbage Collector)

核心特性

  • 着色指针:在指针中存储元数据,避免额外内存开销
  • 读屏障:动态修复指针,实现并发对象转移
  • 区域不分代:但支持分代优化的版本正在开发中

性能目标

  • 停顿时间不超过10ms
  • 处理TB级堆内存
  • 吞吐量下降不超过15%

适用场景

  • 超大堆内存应用(数百GB到TB级)
  • 对延迟极度敏感的应用

3.3.2 Shenandoah收集器

与ZGC对比

  • 使用Brooks指针而非着色指针
  • 更早的OpenJDK版本支持
  • 相似的低延迟目标

四、垃圾回收优化实践

4.1 内存分配策略

  1. 对象优先在Eden区分配

    • 大多数新对象在Eden区创建
    • Eden区满时触发Minor GC
  2. 大对象直接进入老年代

    • 避免在Eden和Survivor区之间大量复制
    • 通过-XX:PretenureSizeThreshold参数控制
  3. 长期存活对象进入老年代

    • 对象在Survivor区每"熬过"一次Minor GC,年龄增加1
    • 达到阈值(默认15)后晋升到老年代
    • 通过-XX:MaxTenuringThreshold调整

4.2 避免内存泄漏

常见内存泄漏场景

  1. 静态集合类持有引用

    java

    // 错误示例
    static List<Object> list = new ArrayList<>();
    public void addData(Object data) {
        list.add(data); // 数据永远不会被释放
    }
    
  2. 监听器未正确移除

    • 添加了事件监听器,但未在适当时候移除
  3. ThreadLocal使用不当

    • ThreadLocal中的数据在线程结束后应被清理

    java

    // 正确用法
    try {
        threadLocal.set(value);
        // 使用数据
    } finally {
        threadLocal.remove(); // 必须清理
    }
    

4.3 JVM参数调优指南

基础参数

bash

# 堆大小设置
-Xms4g -Xmx4g          # 初始堆=最大堆,避免动态调整
-Xmn2g                 # 新生代大小(建议是整个堆的1/3到1/2)

# 选择垃圾回收器
-XX:+UseG1GC           # 使用G1回收器
-XX:+UseZGC            # 使用ZGC(JDK 15+)
-XX:+UseShenandoahGC   # 使用Shenandoah(JDK 12+)

G1调优参数

bash

# 停顿时间目标
-XX:MaxGCPauseMillis=200  # 目标停顿200ms

# 并发GC线程数
-XX:ConcGCThreads=4      # 并发标记线程数
-XX:ParallelGCThreads=8  # 并行回收线程数

# 触发Mixed GC的阈值
-XX:InitiatingHeapOccupancyPercent=45  # 堆使用率45%时开始并发标记

CMS调优参数

bash

-XX:+UseConcMarkSweepGC    # 启用CMS
-XX:CMSInitiatingOccupancyFraction=70  # 老年代70%时触发CMS
-XX:+UseCMSCompactAtFullCollection    # Full GC时进行压缩
-XX:CMSFullGCsBeforeCompaction=0      # 每次Full GC都压缩

4.4 监控与诊断

GC日志分析

bash

# 开启详细GC日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/to/gc.log

# G1专用日志
-XX:+PrintAdaptiveSizePolicy  # 打印G1自适应策略

关键指标

  1. 吞吐量:应用程序运行时间占总时间的比例

    • 目标:90%以上(暂停时间不超过10%)
  2. 延迟:GC引起的最大停顿时间

    • 目标:根据应用需求,通常<200ms
  3. 内存占用:GC后堆内存使用情况

    • 健康状态:老年代使用率<70%

诊断工具

  1. jstat:监控JVM统计信息

    bash

    jstat -gc <pid> 1000  # 每1秒输出一次GC情况
    
  2. jmap:生成堆转储

    bash

    jmap -dump:live,format=b,file=heap.bin <pid>
    
  3. VisualVM/JConsole:图形化监控

  4. GCViewer:分析GC日志文件

五、未来趋势与选择建议

5.1 技术发展趋势

  1. 完全并发化

    • ZGC和Shenandoah已经实现了大部分并发操作
    • 未来目标是完全消除Stop-The-World
  2. AI辅助调优

    • 基于机器学习的自适应参数调整
    • 根据应用特征自动选择最优配置
  3. 云原生优化

    • 容器感知的内存管理
    • 弹性伸缩时的GC策略调整
  4. 异构内存支持

    • 对持久内存(PMEM)的更好支持
    • NUMA架构优化

5.2 选择建议

场景特征推荐回收器关键考虑
小内存客户端应用Serial/Parallel简单高效,资源占用少
大数据批处理Parallel最大化吞吐量
Web服务(4-32GB)G1平衡吞吐和延迟
实时交易系统ZGC/Shenandoah最低延迟优先
超大内存应用(>100GB)ZGC可扩展性,低延迟
混合型应用G1通用性好,调优成熟

5.3 最佳实践总结

  1. 理解应用特征

    • 对象分配速率
    • 对象存活时间分布
    • 堆内存使用模式
  2. 循序渐进调优

    • 先设置合理的基础参数
    • 基于监控数据逐步优化
    • 避免过度调优
  3. 关注根本问题

    • 优化代码减少对象创建
    • 避免内存泄漏
    • 合理设计数据结构
  4. 保持更新

    • 关注新版JDK的GC改进
    • 适时升级使用更先进的回收器

总结

Java垃圾回收机制经过二十多年的发展,已经形成了一套成熟而复杂的体系。从最初简单的标记-清除,到今天高度并发的ZGC,每一次演进都是为了解决特定场景下的内存管理问题。

核心启示

  1. 没有最好的回收器,只有最合适的回收器
  2. 理解原理比记住参数更重要
  3. 监控和诊断是性能优化的基础
  4. 代码优化是减少GC压力的根本

随着Java生态的不断发展,垃圾回收技术仍将继续演进,为开发者提供更强大、更智能的内存管理能力。理解这些底层原理,不仅能帮助我们写出更高效的代码,也能在面临性能挑战时做出更明智的决策。