JVM 笔记--分代工程以及分代的算法

引言

在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采用复制算法,其工作流程如下:

  1. 标记:标记Eden区和From Survivor区中的存活对象。

  2. 复制:将这些存活对象一次性复制到To Survivor区。

  3. 清除:清空Eden区和From Survivor区。

  4. 交换:将To Survivor区变为新的From Survivor区,原来的From区清空后成为新的To区(角色互换)。

3.3 对象晋升的条件

对象何时进入老年代?主要有以下几种情况:

  1. 年龄阈值 :对象每熬过一次Minor GC,年龄就增加1岁。当年龄超过-XX:MaxTenuringThreshold(默认15)时,晋升到老年代。

  2. 动态年龄判定:如果Survivor区中相同年龄的所有对象大小总和超过Survivor区的一半,那么年龄大于等于该年龄的对象可以直接进入老年代,无需等待15岁。

  3. 大对象直接进入老年代 :通过-XX:PretenureSizeThreshold参数设置阈值,大于该值的对象(如长字符串、大数组)直接在老年代分配,避免在Eden和两个Survivor之间发生大量内存复制。

  4. 分配担保:当Minor GC后存活对象过多,To Survivor区无法容纳时,这些对象会通过分配担保机制进入老年代。

3.4 老年代的回收

当老年代空间不足时,会触发Major GCFull 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 调优思路

  1. 观察对象分配速率:如果Minor GC频繁,说明新生代过小或对象分配速率过高。

  2. 观察晋升情况:如果老年代增长过快,检查是否有大对象或Survivor空间不足。

  3. 根据应用类型选择收集器

    • 批处理、后台计算(高吞吐量):Parallel Scavenge + Parallel Old

    • Web服务、低延迟要求:G1 或 CMS

    • 小内存、单核环境:Serial系列

九、总结

分代收集算法是Java虚拟机垃圾回收的基石,它巧妙地将"不同的对象有不同的生命周期"这一观察转化为工程实践。通过将堆划分为新生代和老年代,对每个年代采用最合适的回收算法,分代收集实现了:

  • 高效的回收:新生代使用复制算法,快速回收大量死亡对象

  • 稳定的内存布局:老年代使用标记-整理,避免内存碎片

  • 可控的停顿:通过记忆集、安全点等机制,减少GC对应用的影响

相关推荐
-Springer-2 小时前
STM32 学习 —— 个人学习笔记9-3(FlyMcu 串口下载)
笔记·stm32·学习
中屹指纹浏览器3 小时前
2026指纹浏览器与代理IP协同安全体系构建——从特征匹配到行为风控的全链路防护
经验分享·笔记
2401_884563243 小时前
Python Lambda(匿名函数):简洁之道
jvm·数据库·python
لا معنى له3 小时前
什么是Active Inference(主动推理)? ——学习笔记
笔记·学习
庞轩px3 小时前
MinorGC的完整流程与复制算法深度解析
java·jvm·算法·性能优化
zhouping@3 小时前
JAVA学习笔记day06
java·笔记·学习
Jack.Jia3 小时前
GPS原理笔记三——GPS卫星轨道理论和计算
笔记
庞轩px4 小时前
内存区域的演进与直接内存——JVM性能优化的权衡艺术
java·jvm·笔记·性能优化
m0_730115114 小时前
自动化机器学习(AutoML)库TPOT使用指南
jvm·数据库·python