引言
在Java虚拟机的自动内存管理体系中,垃圾回收(Garbage Collection, GC)是最核心的机制之一。它负责自动回收不再使用的对象内存,让开发者从繁琐的内存管理中解放出来。然而,如何高效地进行垃圾回收,一直是JVM设计的重中之重。
分代收集算法(Generational Collection Algorithm)作为现代商用JVM的事实标准,巧妙地解决了"如何兼顾回收效率与系统吞吐量"这一难题。本文将深入探讨分代收集的理论基础、内存分区设计、三种核心算法的演进,以及背后的实现细节。
一、分代收集的理论基石
分代收集并非凭空想象的算法,而是建立在对大量程序运行实践的观察之上。它主要基于三个经验法则:
1.1 弱分代假说(Weak Generational Hypothesis)
绝大多数对象都是朝生夕灭的。在Java应用程序中,大部分对象(如方法内的局部变量、临时创建的字符串等)生命周期极短,创建后不久就变成垃圾。
1.2 强分代假说(Strong Generational Hypothesis)
熬过越多次垃圾收集过程的对象就越难以消亡。那些经过多次GC仍然存活的对象(如缓存对象、单例、长期运行的会话等),往往会继续存活很长时间。
1.3 跨代引用假说(Intergenerational Reference Hypothesis)
跨代引用相对于同代引用来说仅占极少数。存在互相引用关系的两个对象,倾向于同时生存或同时消亡。例如,如果一个新生代对象被老年代对象引用,由于老年代对象难以消亡,这个引用会使得新生代对象也在GC中存活,最终晋升到老年代,跨代引用自然消除。
基于这三条假说,JVM的设计原则自然形成:将堆内存划分为不同区域,对每个区域采用最适合其对象特征的回收策略。
二、堆内存的分区结构
在HotSpot虚拟机中,堆内存被划分为以下几个逻辑区域:
2.1 新生代(Young Generation)
新生代占堆内存的较小部分(通常为1/3),用于存放新创建的对象。它又被细分为三个部分:
-
Eden区:占新生代的80%,绝大多数对象首先在这里分配。
-
Survivor区:两个大小相等的Survivor区(From和To),各占10%。
这种8:1:1的比例设计,使得新生代实际可用的内存空间达到90%(Eden + 一个Survivor),只有10%的空间会被"浪费"------这正是复制算法的精妙之处。
2.2 老年代(Old Generation)
老年代占堆内存的较大部分(通常为2/3),用于存放生命周期较长的对象。这些对象要么是从新生代多次GC后晋升而来,要么是直接分配的大对象。
2.3 元空间(Metaspace)
Java 8及以后,永久代(PermGen)被元空间取代,用于存储类的元数据、常量池、静态变量等。元空间使用本地内存,不再受限于堆大小。
三、对象的一生:在分区间的流转
3.1 对象的创建与初次分配
大多数对象在新生代的Eden区诞生。当Eden区空间不足时,会触发一次Minor GC(新生代垃圾回收)。
3.2 Minor GC的过程
Minor GC采用复制算法,其工作流程如下:
-
标记:标记Eden区和From Survivor区中的存活对象。
-
复制:将这些存活对象一次性复制到To Survivor区。
-
清除:清空Eden区和From Survivor区。
-
交换:将To Survivor区变为新的From Survivor区,原来的From区清空后成为新的To区(角色互换)。
3.3 对象晋升的条件
对象何时进入老年代?主要有以下几种情况:
-
年龄阈值 :对象每熬过一次Minor GC,年龄就增加1岁。当年龄超过
-XX:MaxTenuringThreshold(默认15)时,晋升到老年代。 -
动态年龄判定:如果Survivor区中相同年龄的所有对象大小总和超过Survivor区的一半,那么年龄大于等于该年龄的对象可以直接进入老年代,无需等待15岁。
-
大对象直接进入老年代 :通过
-XX:PretenureSizeThreshold参数设置阈值,大于该值的对象(如长字符串、大数组)直接在老年代分配,避免在Eden和两个Survivor之间发生大量内存复制。 -
分配担保:当Minor GC后存活对象过多,To Survivor区无法容纳时,这些对象会通过分配担保机制进入老年代。
3.4 老年代的回收
当老年代空间不足时,会触发Major GC 或Full GC。老年代的对象存活率高,不适合复制算法,因此采用标记-清除或标记-整理算法。
四、三大基础垃圾回收算法
分代收集的核心在于针对不同年代选择合适的算法。理解这三种算法,才能真正明白分代设计的精妙。
4.1 标记-清除算法(Mark-Sweep)
工作原理:分为"标记"和"清除"两个阶段。首先从GC Roots出发,标记所有存活对象;然后统一回收所有未被标记的对象。
优点 :实现简单,不移动对象。
缺点:
-
效率不稳定:标记和清除的效率随对象数量增长而降低。
-
内存碎片化:产生大量不连续的内存碎片,可能导致大对象无法分配而提前触发GC。
适用场景:老年代(CMS收集器的核心算法)。
4.2 复制算法(Copying)
工作原理:将内存划分为大小相等的两块,每次只使用其中一块。当这块用完时,将存活对象复制到另一块,然后一次性清空原块。
优点:
-
实现简单,运行高效
-
不会产生内存碎片
缺点:空间利用率只有50%(但通过Eden:Survivor的设计,实际只浪费10%)。
适用场景:新生代(存活对象少,复制成本低)。
4.3 标记-整理算法(Mark-Compact)
工作原理:标记阶段与标记-清除相同,但后续不是直接清理,而是将所有存活对象向内存一端移动,然后清理边界以外的内存。
优点:
-
避免内存碎片化
-
空间利用率高
缺点:移动对象和更新引用需要Stop-The-World,会增加停顿时间。
适用场景:老年代(Parallel Old收集器、G1的部分场景)。
五、为什么新生代和老年代选择不同算法?
这是面试中常见的问题,答案根植于两个年代的本质特征:
| 特征 | 新生代 | 老年代 |
|---|---|---|
| 对象数量 | 多 | 少 |
| 存活率 | 低(绝大部分对象死亡) | 高(大部分对象存活) |
| 回收频率 | 高(频繁Minor GC) | 低(偶尔Major GC) |
| 适用算法 | 复制算法 | 标记-整理/标记-清除 |
新生代选择复制算法的原因:由于大部分对象都会死亡,复制算法只需要复制少量存活对象,成本极低。虽然复制算法会浪费部分空间(10%的Survivor空闲),但换来的是极高的回收效率和连续的内存空间。
老年代选择标记-整理算法的原因:如果老年代也用复制算法,每次都要复制大量存活对象,成本极高。标记-整理算法虽然需要移动对象,但能避免内存碎片,且老年代GC频率低,移动开销可以接受。
六、分代收集的实现细节
6.1 跨代引用与记忆集(Remembered Set)
分代收集面临一个难题:当进行Minor GC时,如何快速找到老年代中引用新生代的对象?如果遍历整个老年代,性能开销太大。
解决方案是引入记忆集 ------一种用于记录跨代引用的数据结构。HotSpot使用**卡表(Card Table)**实现记忆集:将老年代划分为512字节的卡,通过一个字节数组记录每张卡是否有跨代引用。当发生Minor GC时,只需扫描卡表中标记为"脏"的卡,大大减少了扫描范围。
6.2 安全点(Safepoint)与安全区域(Safe Region)
GC需要所有线程暂停(Stop-The-World)才能准确枚举GC Roots。但线程不可能随时暂停,只有在特定的安全点才能暂停。
安全点的选择标准是"是否让程序长时间执行的特征",如方法调用、循环跳转、异常抛出等位置。当GC需要暂停时,虚拟机设置一个标志,线程主动轮询该标志,发现自己应该暂停时就挂起。
如果线程处于Sleep或Blocked状态,无法走到安全点,此时引入安全区域的概念。安全区域是指一段代码片段中,引用关系不会发生变化,GC可以安全地进行。线程进入安全区域时会标记自己,离开时需等待GC完成。
6.3 OopMap与准确式GC
为了快速准确地找到GC Roots,HotSpot使用OopMap数据结构记录栈上和寄存器中哪些位置是引用。在类加载完成时,HotSpot计算出对象内什么偏移量是什么类型的数据;在即时编译过程中,也会在特定的位置(安全点)记录栈和寄存器中哪些位置是引用。
有了OopMap,GC就可以直接扫描这些映射表,无需遍历整个栈内存,大大提高了GC Roots枚举的效率。
七、常见垃圾收集器与分代算法的结合
了解分代算法后,我们来看看主流收集器如何应用这些算法:
| 收集器 | 适用范围 | 采用的算法 | 特点 |
|---|---|---|---|
| Serial | 新生代 | 复制算法 | 单线程,STW |
| Serial Old | 老年代 | 标记-整理 | 单线程,STW |
| ParNew | 新生代 | 复制算法 | Serial的多线程版本 |
| Parallel Scavenge | 新生代 | 复制算法 | 关注吞吐量 |
| Parallel Old | 老年代 | 标记-整理 | 关注吞吐量 |
| CMS | 老年代 | 标记-清除 | 关注低延迟,产生碎片 |
| G1 | 全堆(分区) | 局部复制+标记-整理 | 可预测停顿,替代CMS |
G1收集器突破了传统的新生代/老年代物理分区,将堆划分为多个大小相等的Region,但在逻辑上仍然保留分代的概念。它根据不同Region中垃圾的多少(Garbage-First)优先回收垃圾最多的Region,实现了可预测的停顿时间。
八、优化实践与调优建议
8.1 常用JVM参数
bash
# 堆大小设置
-Xms2g -Xmx2g # 初始堆和最大堆均为2G
-Xmn1g # 新生代大小为1G
-XX:SurvivorRatio=8 # Eden:Survivor=8:1:1
# 晋升相关
-XX:MaxTenuringThreshold=15 # 最大晋升年龄
-XX:PretenureSizeThreshold=1m # 大于1M的对象直接进入老年代
# GC日志
-XX:+PrintGCDetails # 打印GC详细信息
-XX:+PrintGCDateStamps # 打印GC时间戳
-Xloggc:/path/gc.log # 输出GC日志到文件
# 发生OOM时自动dump堆内存
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/dump.hprof
8.2 调优思路
-
观察对象分配速率:如果Minor GC频繁,说明新生代过小或对象分配速率过高。
-
观察晋升情况:如果老年代增长过快,检查是否有大对象或Survivor空间不足。
-
根据应用类型选择收集器:
-
批处理、后台计算(高吞吐量):Parallel Scavenge + Parallel Old
-
Web服务、低延迟要求:G1 或 CMS
-
小内存、单核环境:Serial系列
-
九、总结
分代收集算法是Java虚拟机垃圾回收的基石,它巧妙地将"不同的对象有不同的生命周期"这一观察转化为工程实践。通过将堆划分为新生代和老年代,对每个年代采用最合适的回收算法,分代收集实现了:
-
高效的回收:新生代使用复制算法,快速回收大量死亡对象
-
稳定的内存布局:老年代使用标记-整理,避免内存碎片
-
可控的停顿:通过记忆集、安全点等机制,减少GC对应用的影响