深入理解JVM垃圾回收:从算法到G1收集器
深入理解JVM垃圾回收:从算法到G1收集器
JVM垃圾回收机制是Java平台的重要特性,它自动管理内存,释放程序员从手动内存管理的负担中解脱出来。本文将从垃圾回收的基本概念、算法、收集器以及G1收集器的深度解析等方面,帮助读者深入理解JVM垃圾回收机制。
1. JVM垃圾回收简介
Java虚拟机(JVM)的垃圾回收(Garbage Collection,GC)是Java平台的一个重要特性。它自动管理内存,释放程序员从手动内存管理的负担中解脱出来。在 Java 中垃圾回收线程是特殊的守护线程。(守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件)
1.1 什么是垃圾回收?
垃圾回收是一种自动内存管理机制。它的主要任务是:
- 管理内存
- 确保有引用的对象继续保留在内存中即存活对象
- 回收不再被引用的对象(即"垃圾")占用的内存
1.2 垃圾回收的重要性
- 防止内存泄漏
- 提高内存使用效率
- 简化程序开发,程序员无需手动管理内存
1.3 如何判断一个对象是不是垃圾
引用计数法:引用计数法就是让每个对象都去记录一下自己被引用的情况,有引用关系建立时数量就+1,引用失效时数量-1
存在循环引用问题:A对象引用B对象,B对象也引用A对象,没有其他对象在引用A、B对象,此时A、B记录数均不为0,就无法进行回收。可达性分析:该算法通过一系列称为“GC Roots”的对象作为起始点,向下搜索引用链。如果一个对象到GC Root没有任何引用链相连,就证明该对象不可用,是应该被回收的垃圾对象。
GC Roots:
- 虚拟机栈中的引用:
- 当前线程的局部变量、方法参数等引用的对象。
- 方法区中的类静态属性:
- 被类的静态属性所引用的对象。
- 方法区中的常量:
- 被常量池中的常量引用的对象。
- JNI引用:
- 通过Java Native Interface(JNI)创建的引用。
- 活动线程:
- 任何当前正在运行的线程都被视为一个GC Root。
2. 垃圾回收算法
JVM中主要使用以下几种垃圾回收算法:
2.1 标记-清除算法(Mark-Sweep)
这是最基础的垃圾回收算法,分为两个阶段:
- 标记阶段:遍历所有的对象,标记所有可达(即仍被引用的,还存活的)对象。
- 清除阶段:清除所有未被标记的对象。
优点:实现简单
缺点:效率低,会产生内存碎片
2.2 三色标记法
三色标记法使用于标记清除这种模式的回收机制。包括在后面的G1、CMS回收器中的标记阶段都是采用该方法标记。
它通过将对象分为三种颜色(白色、灰色和黑色)来管理对象的状态,从而有效地识别和回收不再使用的对象。
三色:
- 黑色:该对象已经检查过,并且他的所有成员对象也被检查过了
- 灰色:该对象已经检查过,但他的成员对象还没有全部检查完毕
- 白色:该对象还没有被检查或者该对象是不可达的(没有对象引用,即垃圾)
示例:
它存在一些漏标问题:
- 一个黑色对象在并发标记时引用了一个新的白色对象(标记和用户线程是并发的),引入的新对象在标记阶段没有被标记,可能会被认为属于垃圾;
- 某个对象在灰色标记之后放弃了对子对象的引用,如果该子对象没有被其他对象引用,那他应该被判定为垃圾,而实际是被标记了。多标不影响程序的正确性,在下一次回收器会被回收掉
在CMS和G1回收器中针对漏标问题采用了不同的解决方案,见后文 垃圾回收器的比较。
2.3 复制算法(Copying)
将内存分为两个相等的区域,每次只使用其中一个区域。当这个区域用完时,将存活的对象复制到另一个区域,然后清空当前区域。
优点:效率高,无内存碎片
缺点:内存利用率低,只使用了一半的内存
2.4 标记-整理算法(Mark-Compact)
类似于标记-清除算法,但在清除阶段不是直接清理未标记对象,而是将所有存活的对象向一端移动,然后清理边界以外的内存。
优点:无内存碎片,内存利用率高
缺点:效率较低,需要移动对象
2.5 分代收集算法(Generational Collection)
基于大多数对象都是短暂的这一经验,将堆内存划分为新生代和老年代,对不同代采用不同的收集算法。
- 新生代:使用复制算法
- 老年代:使用标记-清除或标记-整理算法
3. 垃圾收集器
JVM提供了多种垃圾收集器,每种都有其特点和适用场景:
3.1 Serial(单线程)收集器
- 单线程收集器,使用复制算法,作用于新生代
- 适用于客户端环境
- 简单高效,但会造成较长的停顿时间
3.2 Serial Old收集器
- Serial收集器的老年代版本
- 使用标记-整理算法
- 主要用于客户端环境
3.3 ParNew收集器
- Serial收集器的多线程版本,复制算法
- 适用于服务器环境
- 可以与CMS收集器配合使用
3.4 Parallel Scavenge收集器
- 新生代收集器,采用复制算法
- 并行多线程收集
- 注重吞吐量,适合需要高效批量处理的应用
3.5 Parallel Old收集器
- Parallel Scavenge收集器的老年代版本
- 使用标记-整理算法
- 注重吞吐量,适合需要高效批量处理的应用
3.6 CMS(Concurrent Mark Sweep 并发)收集器
- 以获取最短回收停顿时间为目标
- 采用标记-清除算法
- 并发收集,低停顿
- 适用于对响应时间要求较高的应用
CMS收集器在JDK5时开始使用,直至JDK9时被G1回收器替代
CMS收集器的工作过程:
- 初始标记(CMS initial mark):标记GC Roots能直接关联到的对象,速度很快,需要"Stop The World"(STW)。
- 并发标记(CMS concurrent mark):进行GC Roots Tracing,与用户线程并发执行。
- 重新标记(CMS remark):修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要"Stop The World"。
- 并发清除(CMS concurrent sweep):清除不再使用的对象,与用户线程并发执行。
3.7 G1(Garbage-First)收集器
G1收集器是当前最前沿的垃圾收集器,我们接下来详细介绍。
4. G1垃圾收集器深度解析
G1(Garbage-First)收集器是一种服务器端的垃圾收集器,适用于多处理器和大内存环境。它的设计目标是取代CMS收集器,成为具有高吞吐量和低延迟的通用收集器。
4.1 G1的主要特点
- 并行与并发:充分利用多核CPU,缩短STW时间
- 分代收集:仍然保留新生代和老年代的概念,但物理上不再隔离
- 空间整合:整体上基于“标记-整理”算法,不会产生内存碎片
- 可预测的停顿:能够明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
4.2 G1的内存布局
G1将整个堆内存划分为多个大小相等的独立区域(Region),每个Region的大小从1MB到32MB不等,可以在JVM启动时指定。每个Region都可以是任意区域:
- Eden区:存放新分配的对象
- Survivor区:存放在Minor GC中存活下来的对象
- Old区:存放长期存活的对象
- Humongous区:存放大对象(大小超过Region一半的对象)
4.3 G1的工作过程
G1收集器的工作过程可以分为以下几个主要阶段:
4.3.1 年轻代GC(Young GC)
- 当Eden区被占满时触发
- 存活的对象被复制到Survivor区或者老年代
- 整个过程需要STW(Stop The World),但由于只处理年轻代,停顿时间通常较短
4.3.2 并发标记周期(Concurrent Marking Cycle)
当老年代占用内存超过阈值(45%)后,触发并发标记
- 初始标记(Initial Mark):标记GC Roots能直接关联到的对象,需要短暂的STW。
- 根区域扫描(Root Region Scanning):扫描Survivor区直接可达的老年代对象(移动次数达到阈值)。
- 并发标记(Concurrent Marking):对堆中的对象进行标记,与应用程序并发执行。
- 重新标记(Remark):完成标记过程,需要短暂的STW。
- 清理(Cleanup):清点和重置标记状态,统计存活对象信息,需要STW。
4.3.3 混合收集(Mixed GC)
Eden区和幸存者区复制到一个新的幸存者区,幸存者区达到阈值的对象和老年代区存活对象复制到新的老年代区
- 混合收集不仅进行年轻代收集,还会选取一些老年代的Region一起收集
- 这个过程也需要STW,但可以控制在预期的停顿时间内
4.4 G1的关键技术
4.4.1 Remember Set(RSet)
- 用于记录其他Region中的对象引用本Region中对象的关系
- 避免全堆扫描,提高GC效率
4.4.2 Collection Set(CSet)
- 一组可被回收的Region的集合
- 在GC的时候,对CSet中的所有Region进行垃圾回收
4.4.3 Snapshot-At-The-Beginning(SATB)
- 并发标记的算法
- 在标记阶段开始时记录下整个堆的对象图
- 确保在并发标记过程中,不会漏标任何可能存活的对象
4.5 G1的优化技术
- 自适应堆占用(Adaptive Heap Occupancy)
- 动态调整Region大小
- 预测模型:根据历史数据预测GC的停顿时间
- 并行Full GC:在需要进行Full GC时,G1会使用并行的标记-清理-整理算法
5. 垃圾收集器的比较
收集器 | 串行/并行/并发 | 新生代/老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制 | 响应速度 | 客户端、小内存 |
ParNew | 并行 | 新生代 | 复制 | 响应速度 | 多CPU、服务器 |
Parallel Scavenge | 并行 | 新生代 | 复制 | 吞吐量 | 后台运算 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度 | 客户端、小内存 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量 | 后台运算 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度 | 互联网、低延迟 |
G1 | 并行、并发 | both | 复制+标记-整理 | 响应速度 | 大内存、低延迟 |
CMS、G1回收器针对三色标记漏标问题的解决:
- CMS使用增量更新:将并发标记阶段插入的新对象直接标记成灰色对象,那么在重新标记阶段就能进一步对其继续扫描
- G1使用写屏障(SATB):在标记开始时会生成一个快照,后续该快照中存活的对象就认为依旧存活(宁可多标,不可漏标),同时在并发标记的开始时刻和重新标记的开始时刻都会记录一下最新分配对象的地址,那么这两个记录的差值就是并发标记阶段新插入的对象(分配的对象地址是依次单调的),一律将其视为存活对象并在重新标记阶段进行扫描即可
6. 最佳实践和调优
6.1 选择合适的收集器
- 客户端应用:Serial + Serial Old
- 服务器应用,注重吞吐量:Parallel Scavenge + Parallel Old
- 服务器应用,注重响应时间:ParNew + CMS
- 大内存、低延迟要求:G1
6.2 调优参数
-Xms
和-Xmx
:设置堆的初始大小和最大大小-XX:NewRatio
:新生代和老年代的比例-XX:SurvivorRatio
:Eden区和Survivor区的比例-XX:MaxGCPauseMillis
:设置最大GC停顿时间(G1)-XX:InitiatingHeapOccupancyPercent
:设置触发标记周期的堆占用阈值(G1)-XX:ConcGCThreads
:设置并发标记的线程数(G1)
6.3 监控和分析
- 使用JVM自带的工具:
- jstat:查看GC统计信息
- jconsole:图形化监控工具
- jvisualvm:更强大的图形化工具,可以进行堆转储分析
- 使用第三方工具:
- Eclipse Memory Analyzer (MAT):分析堆转储文件
- YourKit Java Profiler:全面的Java应用性能分析工具
- 日志分析:
- 开启GC日志:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
- 使用GCViewer等工具分析GC日志
6.4 调优策略
- 减少对象生成:
- 使用对象池
- 避免频繁创建临时对象
- 增大/减小内存:
- 如果Full GC频繁,考虑增大堆内存
- 如果内存利用率低,可以适当减小堆内存
- 调整代空间比例:
- 如果Young GC频繁,考虑增大新生代比例
- 如果老年代增长过快,考虑减小新生代比例
- 选择合适的GC算法:
- 对于大内存应用,考虑使用G1收集器
- 对于小内存应用,使用Serial收集器可能更合适
- 及时处理垃圾:
- 注意及时将对象置为null
- 使用WeakReference(弱引用,一旦触发回收机制就会被回收)等引用类型