1. 从“垃圾”到“回收”:一个被误解的自动化过程
聊到JVM的垃圾回收,很多人的第一反应是“哦,那个自动清理内存的机制”。这个理解没错,但太浅了。在我过去十多年的Java开发与调优经历里,我发现一个有趣的现象:越是把GC(Garbage Collection)当作一个“黑盒”,只知其然不知其所以然的项目,后期在面临性能瓶颈、服务卡顿甚至“内存泄漏”时,排查成本就越高,修复也越痛苦。GC远不止是“自动清理”那么简单,它是一套精密的、权衡了吞吐量、延迟和内存使用效率的复杂系统。理解它,不是为了让你去手写一个回收器,而是为了让你写的代码能与这套系统和谐共处,写出对GC友好的高性能应用。
在第一部分,我们可能聊了GC的基本概念、对象存活的判断(如引用计数、可达性分析)以及几种基础的垃圾收集算法(标记-清除、复制、标记-整理)。今天,我们深入到更核心的层面:JVM是如何将这些算法落地,并封装成我们耳熟能详的收集器的?为什么会有Young GC和Full GC?它们触发的时机和背后的逻辑是什么?更重要的是,当监控图表上出现GC时间飙升时,我们该如何一步步抽丝剥茧,定位到代码层面的根因?这篇文章,我就结合自己踩过的坑和调优案例,把这些“黑盒”里的齿轮一个个拆开给你看。
2. 分代假说:一切收集器设计的基石
在深入具体收集器之前,必须彻底理解“分代收集”这个核心思想。这不是JVM的发明,而是基于对绝大多数应用运行时对象行为规律的观察总结,即“弱分代假说”和“强分代假说”。
弱分代假说指出:绝大多数对象都是朝生夕死的。这意味着,在应用运行过程中,大部分对象的生命周期极短,在分配出来后很快就不再被引用。这个现象在你我写的代码中无处不在,例如方法内的局部变量、临时计算的中间对象、一次请求处理中生成的DTO等。
强分代假说则指出:熬过越多次垃圾收集过程的对象,就越难以消亡。换句话说,如果一个对象在一次GC后仍然存活,那么它很可能在未来还会继续存活很久,比如缓存对象、Spring容器的单例Bean、数据库连接池对象等。
基于这两个假说,JVM的管理内存(主要指Java堆)被逻辑上划分为两个(或更多)区域:新生代(Young Generation)和老年代(Old Generation)。这种划分带来了一个巨大的好处:可以针对不同区域对象的特点,采用最合适的收集算法,从而以较低的代价回收大部分内存。
2.1 新生代:复制算法的舞台
新生代是对象诞生的地方。几乎所有新创建的对象都会首先分配在新生代(有一些特例,比如大对象可能直接进入老年代)。因为这里面的对象“死亡率”极高,所以适合采用复制算法。
复制算法的核心思想是将内存一分为二(例如Eden区和两个Survivor区),每次只使用其中一块。当进行垃圾回收时,将正在使用的内存中存活的对象复制到另一块空闲的内存上,然后直接清空已使用的那块内存。这种方式简单高效,没有内存碎片,但代价是可用内存缩小了一半。
在HotSpot JVM的新生代实现中,它被进一步细分为一个Eden区和两个Survivor区(通常称为S0和S1,或者From和To)。其工作流程是:
- 新对象优先在Eden区分配。
- 当Eden区空间不足时,会触发一次Minor GC(或称为Young GC)。注意,Survivor区满不会直接触发Minor GC,但会影响回收过程。
- Minor GC时,会同时回收Eden区和其中一个Survivor区(比如From区)。首先通过可达性分析标记出存活对象。
- 将Eden区和From区中所有存活的对象,一次性复制到另一个空的Survivor区(To区)。同时,这些对象的“年龄”会增加1岁。
- 清空Eden区和刚才使用的From区。此时,From区和To区的角色互换(原来的To区变成下一次GC时的From区)。
注意:这里有一个关键细节。对象并非一定要在Survivor区之间来回折腾。如果存活对象的总大小超过了To区的容量,这些对象会通过“分配担保”机制直接进入老年代。这是导致预期外的对象提前进入老年代的常见原因之一。
2.2 对象晋升与老年代:标记-整理算法的用武之地
在Survivor区中,对象每熬过一次Minor GC,年龄就增加1岁。当它的年龄增加到一定程度(默认阈值是15,可通过-XX:MaxTenuringThreshold调整),在下一次Minor GC时,它就会被晋升到老年代。
老年代中存放的都是“长寿”对象,其特点是存活率高、没有那么多“垃圾”。因此,在这里使用复制算法就不划算了(复制成本高,且空闲空间要求大)。老年代通常采用标记-整理或标记-清除算法。
- 标记-清除:先标记所有存活对象,然后直接回收未标记的区域。速度快,但会产生内存碎片。
- 标记-整理:在标记存活对象后,将所有存活对象向内存空间的一端移动,然后直接清理掉边界以外的内存。解决了碎片问题,但移动对象带来了额外开销。
老年代空间不足时,会触发Major GC,通常它会伴随至少一次Minor GC(因为对象晋升可能导致老年代不足),所以很多时候也把Major GC和Full GC等同看待。但严格来说,Full GC指的是针对整个堆(包括新生代、老年代,以及方法区/元空间等)的垃圾收集,其停顿时间通常是最长的,是我们调优中要尽力避免的“坏家伙”。
3. 经典垃圾收集器:七种武器的实战选择
理解了分代和算法,我们来看JVM是如何将它们封装成具体的收集器的。HotSpot JVM提供了多种选择,它们的关系并非迭代替代,而是针对不同场景的权衡。下图清晰地展示了在JDK 8-11这个主流区间内,新生代与老年代收集器的常见组合关系:
flowchart TD A[“新生代收集器”] --> B[“Serial”] A --> C[“ParNew”] A --> D[“Parallel Scavenge”] E[“老年代收集器”] --> F[“Serial Old”] E --> G[“Parallel Old”] E --> H[“CMS”] B -- 可与 --> F C -- 唯一搭配 --> H D -- 推荐搭配 --> G3.1 串行收集器:Serial & Serial Old
这是最古老、最基础的收集器。它是一个单线程工作的收集器,在进行垃圾回收时,必须暂停所有用户线程(Stop The World, STW),直到收集结束。
- Serial:用于新生代,采用复制算法。
- Serial Old:用于老年代,采用标记-整理算法。
使用场景:它简单而高效,没有线程交互开销。在客户端模式(Client VM)或资源极其受限(如嵌入式)、对停顿时间不敏感的微服务场景下,它可能是一个不错的选择。但在当今主流的服务器端多核环境下,其漫长的STW时间通常是不可接受的。通过JVM参数-XX:+UseSerialGC可以启用此组合。
3.2 并行收集器:ParNew & Parallel Scavenge/Old
这类收集器核心是利用多核CPU优势,使用多线程并行进行垃圾回收,以缩短STW时间。
- ParNew:本质上是Serial的多线程并行版本,用于新生代。它是在互联网早期,为了配合CMS收集器而诞生的,因为CMS只能与ParNew或Serial配合工作。
- Parallel Scavenge:也被称为“吞吐量优先”收集器,用于新生代。它的目标与ParNew不同:ParNew等收集器的目标是尽可能缩短单次GC的停顿时间,而Parallel Scavenge的目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。它提供了精确控制吞吐量和最大停顿时间的参数。
- Parallel Old:Parallel Scavenge的老年代版本,采用多线程标记-整理算法。
使用场景:
- ParNew + CMS:在JDK 9之前,这是互联网后端服务追求低延迟的经典组合。适用于对响应时间敏感的应用,如Web服务器、API网关。
- Parallel Scavenge + Parallel Old:这是JDK 8的默认收集器组合。适用于后台运算、科学计算、批处理任务等,这类应用对高吞吐量的需求高于对短暂停顿的敏感度。参数
-XX:+UseParallelGC或-XX:+UseParallelOldGC可以启用。
3.3 并发收集器:CMS
CMS(Concurrent Mark-Sweep)收集器是一款以获取最短回收停顿时间为目标的里程碑式收集器。它允许垃圾收集线程与用户线程并发工作,从而大幅减少STW时间。
它的收集过程相对复杂,分为四个主要步骤:
- 初始标记:STW。仅标记GC Roots能直接关联到的对象,速度极快。
- 并发标记:并发。从GC Roots直接关联对象开始,遍历整个对象图。此过程与用户线程并发,耗时较长。
- 重新标记:STW。修正并发标记期间,因用户线程继续运行而导致标记产生变动的那一部分对象。停顿时间通常比初始标记稍长,但远短于并发标记。
- 并发清除:并发。清理删除已标记死亡的对象。
优点:低停顿,用户体验好。缺点:
- 对CPU资源敏感:并发阶段会占用一部分线程资源,导致应用吞吐量降低。
- 无法处理“浮动垃圾”:并发清理阶段用户线程还在运行,会产生新的垃圾,只能留到下次GC处理。
- 内存碎片:采用标记-清除算法,长时间运行后会产生大量内存碎片。当无法找到足够大连续空间分配大对象时,会提前触发Full GC。
- 不确定性:由于并发,无法像并行收集器那样精确控制一次GC在什么时候结束。
使用场景:在G1成熟之前,CMS是许多对延迟有严格要求的服务的标配。但随着硬件发展和大内存成为常态,其碎片化问题愈发突出。参数-XX:+UseConcMarkSweepGC启用。
3.4 G1收集器:面向局部的设计
G1(Garbage-First)是JDK 7 Update 4引入,并在JDK 9中成为服务端模式默认收集器的划时代产品。它放弃了传统连续物理分代的设计,将整个Java堆划分为多个大小相等的独立区域(Region)。虽然仍然保留新生代、老年代的概念,但它们是这些Region的逻辑集合,且不再固定。
G1的核心思想是:化整为零,优先处理垃圾最多的区域。它维护一个优先级列表,每次根据允许的收集时间,优先回收价值最大(即垃圾最多)的Region,这也是其名称的由来。
其工作过程大致分为:
- 年轻代收集:依然采用STW方式的复制算法,将Eden区和Survivor区的存活对象复制到新的Survivor区或老年代Region。
- 混合收集:除了回收整个新生代,还会根据“最大收益”原则,选择一部分老年代Region进行回收。这是G1的核心。
- Full GC:当对象分配速度过快,混合收集跟不上垃圾产生的速度,导致堆内存耗尽时,G1会退化为单线程的Serial Old进行Full GC,停顿时间会非常长。
优点:
- 可预测的停顿时间模型:通过参数
-XX:MaxGCPauseMillis设置目标停顿时间,G1会尽力在这个时间内完成垃圾收集。 - 整体采用标记-整理,局部采用复制:从整体看不会产生内存碎片,从Region局部看是复制算法,高效无碎片。
- 能更有效地处理大内存。
使用场景:从JDK 9起,G1是默认选择。它适用于需要低延迟且堆内存较大(通常建议6GB以上)的应用。它平衡了吞吐量和延迟,是当前最通用的服务端收集器。参数-XX:+UseG1GC启用。
3.5 ZGC与Shenandoah:前沿的低延迟利器
这是两款在JDK 11及以后版本中引入的、以超低停顿时间(目标在10ms以内)为核心目标的革命性收集器。它们都采用了染色指针、读屏障等先进技术,几乎在整个收集过程中都只会有极短暂的STW(通常只在初始标记和重新标记阶段)。
- ZGC:由Oracle开发,其停顿时间与堆大小无关,即使堆大到数TB,停顿时间也能保持在10ms以下。
- Shenandoah:由RedHat开发,目标与ZGC类似,其特点是并发回收阶段与用户线程的同步开销更小。
使用场景:适用于对延迟有极端要求的场景,如实时交易系统、高频量化交易、大型互联网应用的底层基础设施。它们尚处于持续优化阶段,生产环境采用前需要充分的测试。参数分别为-XX:+UseZGC和-XX:+UseShenandoahGC。
4. GC日志:解读JVM健康状况的“体检报告”
光知道原理和收集器不够,你必须学会看GC日志。它是线上问题排查最直接、最重要的依据。通过JVM参数-Xlog:gc*(JDK 9+ Unified Logging)或-XX:+PrintGCDetails -XX:+PrintGCDateStamps(JDK 8)可以开启详细GC日志。
我们来看一段典型的G1 Young GC日志:
[2024-05-20T10:23:45.123+0800][info][gc,start ] GC(12) Pause Young (Normal) (G1 Evacuation Pause) [2024-05-20T10:23:45.124+0800][info][gc,task ] GC(12) Using 8 workers [2024-05-20T10:23:45.129+0800][info][gc,phases ] GC(12) Evacuate Collection Set: 4.5ms [2024-05-20T10:23:45.129+0800][info][gc,phases ] GC(12) Post Evacuate Collection Set: 0.2ms [2024-05-20T10:23:45.129+0800][info][gc,phases ] GC(12) Other: 0.1ms [2024-05-20T10:23:45.129+0800][info][gc,heap ] GC(12) Eden regions: 100->0(100) [2024-05-20T10:23:45.129+0800][info][gc,heap ] GC(12) Survivor regions: 10->15(15) [2024-05-20T10:23:45.129+0800][info][gc,heap ] GC(12) Old regions: 150->155 [2024-05-20T10:23:45.129+0800][info][gc,heap ] GC(12) Humongous regions: 2->2 [2024-05-20T10:23:45.129+0800][info][gc,metaspace] GC(12) Metaspace: 45012K->45012K(1081344K) [2024-05-20T10:23:45.129+0800][info][gc ] GC(12) Pause Young (Normal) 1024M->256M(2048M) 5.8ms关键信息解读:
GC(12):第12次GC。Pause Young (Normal):这是一次年轻代正常回收。Using 8 workers:使用了8个GC线程并行工作。Evacuate Collection Set: 4.5ms:转移存活对象耗时4.5毫秒,这是STW时间的主要部分。Eden regions: 100->0(100):Eden区从100个Region回收到0个,括号内是回收前的总数。Survivor regions: 10->15(15):Survivor区从10个Region增加到15个(上限15个)。Old regions: 150->155:老年代Region从150个增加到155个,说明有5个Region的对象晋升到了老年代。1024M->256M(2048M):堆使用量从1024M下降到256M,括号内是当前堆的总容量。5.8ms:本次GC总停顿时间。
需要警惕的异常模式:
- Full GC频率过高:频繁出现
Pause Full日志,说明老年代回收压力大,或存在内存泄漏、晋升过快等问题。 - GC时间过长:单次Young GC超过50ms,或Full GC超过1秒,都需要关注。
- 内存回收效果差:每次GC后堆内存下降不明显,例如
1024M->1000M,说明大部分对象都存活,可能是生命周期长的缓存过多,或者存在内存泄漏。 - 晋升峰值:单次Young GC后,老年代增长异常迅速(
Old regions激增),可能是Survivor区过小或-XX:MaxTenuringThreshold设置不合理。
5. 实战调优:从现象到代码的排查链路
理论最终要服务于实践。当监控系统告警“GC时间过长”或“服务延迟飙升”时,我们该如何行动?下面是一个我常用的排查链路。
5.1 第一步:现象确认与数据收集
不要急于修改JVM参数。首先,通过监控平台(如Prometheus+Grafana, SkyWalking)或服务器命令,确认问题的现象和范围。
- 是单个实例还是集群普遍问题?如果是单个,可能是该实例的特定负载或代码分支导致。
- GC频率和耗时?使用
jstat -gcutil <pid> 1000每秒打印一次GC统计,观察各区使用率和GC时间。 - 系统资源?使用
top命令查看CPU、内存整体使用情况,排除宿主机资源竞争。
5.2 第二步:获取并分析GC日志
如果未开启,立即通过jcmd <pid> VM.log动态开启,或给JVM进程发送信号(如kill -3 <pid>生成线程和堆转储摘要)。分析最近几次问题时间点的GC日志,重点关注:
- 触发原因:是
Allocation Failure(分配失败)还是System.gc()调用? - 类型与耗时:是Young GC还是Full GC?耗时是否符合预期?
- 内存变化:回收前后各代容量变化,晋升到老年代的对象量是否正常?
5.3 第三步:生成与分析堆转储文件
如果怀疑是内存泄漏或对象堆积,需要生成堆转储(Heap Dump)进行离线分析。
- 生成Dump:
- 主动生成:
jmap -dump:live,format=b,file=heap.hprof <pid>。加live参数会触发一次Full GC,只dump存活对象,文件更小但会引发停顿。 - OOM时自动生成:JVM参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump。
- 主动生成:
- 分析工具:使用MAT(Eclipse Memory Analyzer)或JProfiler打开Dump文件。
- 直方图:查看哪个类的实例数量最多、占用内存最大。
- 支配树:找到持有这些对象引用的GC Root路径,定位是谁在“保护”这些本该被回收的对象。
- 查找重复字符串:意外的字符串重复是常见的内存浪费源。
5.4 第四步:代码级根因定位与修复
结合堆分析结果,回到代码。常见问题模式有:
- 静态集合滥用:静态的
Map、List不断往里放数据(如缓存没上限、本地缓存),且无清理逻辑。 - 线程局部变量未清理:
ThreadLocal使用后未调用remove(),在线程池场景下会导致线程复用,从而内存泄漏。 - 监听器/回调未注销:注册了事件监听器或回调函数,但在对象销毁时忘记注销。
- 不合理的对象作用域:将本该在方法内创建使用的对象提升为类成员变量,无意中延长了其生命周期。
- 大对象/流未关闭:如数据库连接、文件流、HTTP连接等。
找到可疑代码后,通过代码审查、添加日志或使用APM工具进行方法级追踪,确认问题。
5.5 第五步:JVM参数调优(谨慎操作)
在确认代码层面无法快速优化,或优化后仍需微调时,才考虑JVM参数。这是最后的手段,而非首选。
- 调整堆大小:
-Xms和-Xmx设为相同值,避免堆动态调整带来的额外GC。大小应根据系统物理内存和应用实际使用情况设定,通常不超过物理内存的50%-70%。 - 调整新生代比例:
-XX:NewRatio设置老年代与新生代的比例。对于大量临时对象的应用,可以适当增大新生代(减小该比值)。 - 调整Survivor区:
-XX:SurvivorRatio设置Eden与一个Survivor区的比例。如果发现对象晋升年龄很小就进入了老年代,可以尝试调大Survivor区(减小该比值)。 - 调整晋升阈值:
-XX:MaxTenuringThreshold提高此值可以让对象在新生代多“待”几次,减少直接进入老年代的可能。 - 选择收集器:根据应用特性(吞吐量优先还是低延迟优先)和JDK版本,选择合适的收集器组合。
重要心得:JVM参数调优没有银弹。任何参数的调整都应以监控数据和压力测试为依据。调整后,必须进行至少一轮完整的压测,对比调整前后的GC日志和性能指标(吞吐量、P99延迟等)。盲目套用“网上最优参数”往往是灾难的开始。
6. 面向未来的思考:如何编写对GC友好的代码?
了解了这么多GC的机制,最终要回归到编码本身。写出对GC友好的代码,是从根源上减少问题的最佳实践。
- 减少对象创建:这是最根本的。在热点代码路径(如循环、高频调用方法)中,审视是否有不必要的对象创建。例如,使用原生类型而非包装类、重用对象(通过对象池需谨慎评估)、小心字符串拼接(使用
StringBuilder)。 - 让对象死得“干脆”:及时将不再需要的对象引用置为
null在现代JVM中通常不是必须的(局部变量会随栈帧销毁),但在某些场景下有助于逻辑清晰。对于容器类(如Map、List),及时调用clear()方法释放元素引用。 - 谨慎使用大型、长生命周期对象:大对象(如大数组)直接进入老年代,对GC不友好。长生命周期的对象(如全局缓存)应放在老年代,但要控制其大小和淘汰策略。
- 理解并善用软引用、弱引用和虚引用:对于缓存场景,使用
SoftReference可以让对象在内存紧张时被GC自动回收,是实现内存敏感缓存的好方法。WeakReference常用于实现无关对象生命周期的关联,如WeakHashMap。 - 避免使用
finalize()方法:该方法执行时机不确定,且会严重影响对象回收效率,可能导致对象在Finalizer队列中长时间等待,引发内存问题。JDK 9中已被标记为废弃。
GC是Java生态的基石,它的自动化让我们免于手动管理内存的繁琐与风险。但自动化不代表我们可以完全无视它。深入理解其工作原理,能让我们在享受便利的同时,具备驾驭和优化它的能力。当你能从容地从一段GC日志分析出代码层面的优化点时,你就真正从一个Java使用者,进阶为Java系统的管理者了。