深入解析JVM垃圾回收:从基础原理到G1、ZGC调优实战 1. 项目概述为什么我们绕不开垃圾回收这个话题如果你写过Java或者任何运行在JVM上的语言比如Scala、Kotlin那么“垃圾回收”这四个字对你来说绝对不陌生。它就像你家里的保洁阿姨平时默默无闻地打扫你甚至感觉不到她的存在。但一旦她“罢工”或者“效率低下”你的家也就是你的应用很快就会变得一团糟——内存溢出、服务卡顿、响应延迟各种问题接踵而至。我见过太多线上事故根源都指向了垃圾回收配置不当或理解不深。所以无论你是为了通过面试毕竟“JVM垃圾回收机制”是高频面试题还是为了真正解决生产环境的性能瓶颈深入理解垃圾回收都是Java开发者从“会用”到“精通”的必经之路。这次的学习笔记我们就来彻底拆解JVM的垃圾回收。我不会只讲那些教科书上的定义而是结合我这些年调优和排查问题的实际经验带你看看垃圾回收到底是怎么工作的不同的收集器有什么脾气以及当你的应用出现GC overhead limit exceeded或者Full GC频繁时你该怎么下手。我们会从最基础的内存区域和对象生死判定说起一直深入到G1、ZGC这些现代收集器的核心原理和调优实战。目标是让你读完不仅能回答常见的jvm面试题更能拥有实际分析和解决垃圾回收相关问题的能力。2. 垃圾回收的基石对象生死与内存布局在保洁阿姨GC开始工作前她得先知道哪些是垃圾哪些不是。同时她也得清楚房子的布局知道客厅、卧室、厨房分别在哪打扫策略可能完全不同。对应到JVM这就是对象存活的判定和内存区域的划分。2.1 对象生死判定的“可达性分析算法”JVM判断一个对象是否存活用的不是简单的“引用计数”虽然Python等语言用这个而是可达性分析算法。这个算法的思路非常直观以一系列称为“GC Roots”的对象作为起始点从这些节点开始向下搜索搜索所走过的路径称为“引用链”。当一个对象到GC Roots没有任何引用链相连时则证明此对象是不可用的可以被回收。那么哪些对象可以作为GC Roots呢主要有以下几类虚拟机栈栈帧中的本地变量表中引用的对象比如当前正在运行的方法中的局部变量。方法区中类静态属性引用的对象比如类的静态变量。方法区中常量引用的对象比如字符串常量池里的引用。本地方法栈中JNI即Native方法引用的对象。Java虚拟机内部的引用如基本数据类型对应的Class对象一些常驻的异常对象NullPointerException等系统类加载器。所有被同步锁synchronized关键字持有的对象。反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。注意可达性分析的过程必须在一个能保证一致性的快照中进行。这就像拍照定格不能在打扫时还有人在乱扔垃圾或移动家具。因此GC进行时必须“Stop The World”暂停所有用户线程这是所有垃圾收集器都无法完全避免的。2.2 JVM内存模型的“房间划分”知道了怎么找垃圾我们还得知道垃圾分布在哪里。这就是常说的jvm内存模型。对于HotSpot虚拟机运行时的内存区域划分如下程序计数器线程私有可以看作是当前线程所执行的字节码的行号指示器。此区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。Java虚拟机栈线程私有生命周期与线程相同。每个方法执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。我们常说的堆栈溢出StackOverflowError就发生在这里。本地方法栈为Native方法服务。Java堆这是垃圾回收发生的主战场。几乎所有对象实例和数组都在这里分配内存。堆是线程共享的也是我们调优的重点区域。堆内存不足时会抛出OutOfMemoryError。方法区线程共享用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。JDK 8之前它被称为“永久代”PermGen之后被“元空间”Metaspace取代使用的是本地内存。如果动态生成类过多比如大量使用CGLib元空间膨胀也会导致内存溢出。为了高效地进行垃圾回收收集器会对Java堆进行更细致的划分。下图清晰地展示了这种关系Java堆是整体而新生代、老年代等是垃圾收集器视角下的逻辑分区。pie title HotSpot JVM 运行时内存区域 (逻辑视角) “Java 堆 (GC 主战场)” : 70 “方法区 (元空间)” : 15 “Java 虚拟机栈” : 10 “本地方法栈” : 2 “程序计数器” : 3从垃圾回收的角度看Java堆通常分为新生代和老年代。新生代存放新创建的对象。绝大多数对象都是“朝生夕死”的所以新生代是GC特别是Minor GC非常频繁的区域。新生代内部又分为Eden区和两个Survivor区通常称为S0和S1或者From和To。老年代存放长期存活的对象经过多次新生代GC后依然存活的对象以及一些大对象可能直接进入老年代。方法区元空间的垃圾回收主要针对两部分废弃的常量和不再使用的类型。条件苛刻回收效率低但一旦发生Full GC通常会触发对性能影响很大。3. 垃圾收集算法保洁阿姨的打扫方法论确定了垃圾和房间布局接下来就是打扫方法。经典的垃圾收集算法是理解所有收集器的基础。3.1 标记-清除算法最基础的“找出来扔掉”这是最基础的算法分为“标记”和“清除”两个阶段。标记首先标记出所有需要回收的对象。清除统一回收所有被标记的对象。缺点非常明显效率问题标记和清除两个过程的效率都不高。空间问题清除后会产生大量不连续的内存碎片。碎片太多会导致以后需要分配较大对象时无法找到足够的连续内存从而不得不提前触发另一次垃圾收集。3.2 复制算法一分为二来回倒腾为了解决效率问题复制算法出现了。它将可用内存按容量划分为大小相等的两块每次只使用其中的一块。当这一块的内存用完了就将还存活着的对象复制到另外一块上面然后再把已使用过的内存空间一次清理掉。优点实现简单运行高效没有内存碎片。缺点代价是将可用内存缩小为了原来的一半空间利用率低。现在的商业虚拟机都采用这种算法来回收新生代。但并不是按照1:1的比例来划分内存。实际上HotSpot虚拟机将新生代分为一个较大的Eden空间和两个较小的Survivor空间默认比例是8:1:1。每次使用Eden和其中一个Survivor。当回收时将Eden和Survivor中仍然存活的对象一次性复制到另外一个Survivor空间上最后清理掉Eden和用过的那个Survivor。这样只有10%的内存是“闲置”的而不是50%。实操心得你可以通过JVM参数-XX:SurvivorRatio8来调整Eden和一个Survivor区的比例。如果Survivor区经常满导致对象提前进入老年代可以适当调大这个比例比如设为-XX:SurvivorRatio6但会增加Minor GC的频率需要权衡。3.3 标记-整理算法给老年代用的“压缩整理”复制算法在对象存活率较高时复制操作的效率会降低。更关键的是如果不想浪费50%的空间就需要有额外的空间进行分配担保以应对所有对象都存活的极端情况。所以老年代一般不直接使用复制算法。针对老年代的特点提出了“标记-整理”算法。标记过程与“标记-清除”一样但后续步骤不是直接清除而是让所有存活的对象都向内存空间的一端移动然后直接清理掉边界以外的内存。优点没有内存碎片。缺点移动存活对象并更新所有引用这些对象的地方是一种负重操作而且这种移动操作必须全程暂停用户线程Stop The World。3.4 分代收集理论因地制宜的打扫策略当前商业虚拟机的垃圾收集器都采用了分代收集理论。它建立在两个分代假说之上弱分代假说绝大多数对象都是朝生夕死的。强分代假说熬过越多次垃圾收集过程的对象就越难以消亡。基于这两个假说收集器将Java堆划分出不同的区域新生代、老年代然后根据各个区域的特点采用不同的垃圾收集算法。新生代每次回收都有大量对象死去存活对象少所以选用复制算法只需要复制少量存活对象效率高。老年代对象存活率高没有额外的空间做分配担保所以选用标记-清除或标记-整理算法。4. 经典垃圾收集器各具特色的保洁团队算法是方法论收集器就是具体的实施团队。HotSpot虚拟机提供了多种收集器适用于不同的场景。下图梳理了JDK 8及之前版本中这些经典收集器的工作区域与组合关系flowchart TD A[“垃圾收集器 (JDK 8及之前)”] A -- B[“新生代收集器”] A -- C[“老年代收集器”] B -- B1[“Serial”] B -- B2[“ParNew”] B -- B3[“Parallel Scavenge”] C -- C1[“Serial Old”] C -- C2[“Parallel Old”] C -- C3[“CMS”] B1 -- “可搭配” -- C1 B2 -- “常搭配” -- C3[“CMS”] B3 -- “唯一官方搭配” -- C24.1 Serial / Serial Old 收集器单线程的“老师傅”这是一个单线程的收集器。它的“单线程”意义并不仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作更重要的是它在进行垃圾收集时必须暂停其他所有工作线程直到它收集结束。Serial新生代收集器采用复制算法。Serial Old老年代收集器采用标记-整理算法。优点简单而高效。对于单核CPU或内存很小的场景比如几十兆到一两百兆由于没有线程交互的开销专心做垃圾收集可以获得最高的单线程收集效率。它是Client模式下的默认新生代收集器。缺点Stop The World的时间可能较长。注意事项千万别在服务端应用特别是多核服务器上使用这个组合。它的长时间停顿对用户体验是灾难性的。4.2 ParNew 收集器Serial的多线程版本ParNew收集器实质上是Serial收集器的多线程并行版本。除了使用多条线程进行垃圾收集之外其余行为包括控制参数、收集算法、对象分配规则、回收策略等都与Serial收集器完全一样。优点在多核CPU环境下能有效减少GC停顿时间。缺点在单核CPU环境下效果不一定比Serial好因为存在线程交互的开销。关键点在JDK 7之前它是与CMS收集器搭配工作的新生代首选。启用参数-XX:UseParNewGC。4.3 Parallel Scavenge / Parallel Old 收集器吞吐量优先的“效率专家”这个组合是吞吐量优先的收集器。Parallel Scavenge新生代收集器采用复制算法并行多线程。Parallel Old老年代收集器采用标记-整理算法并行多线程。吞吐量 运行用户代码时间 / (运行用户代码时间 垃圾收集时间)。比如虚拟机总共运行了100分钟其中垃圾收集花了1分钟那吞吐量就是99%。特点Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。它提供了两个参数用于精确控制吞吐量-XX:MaxGCPauseMillis最大垃圾收集停顿时间毫秒。这是一个期望值收集器会尽力达成但可能会以降低吞吐量为代价。-XX:GCTimeRatio吞吐量大小0到100的整数默认99。计算公式1 / (1 GCTimeRatio)。比如99表示GC时间占总时间的1%。适用场景适合后台运算、科学计算、批量任务处理等不太追求单个任务的响应速度而是希望尽快完成整个任务。在JDK 8中它是默认的垃圾收集器。4.4 CMS 收集器低停顿的“清洁工”CMS收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合互联网站或B/S系统的服务端需求希望系统停顿时间最短给用户带来良好的体验。CMS收集器基于标记-清除算法它的运作过程分为四个步骤初始标记仅仅标记一下GC Roots能直接关联到的对象速度很快。需要“Stop The World”。并发标记进行GC Roots Tracing的过程与用户线程并发执行。重新标记修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。需要“Stop The World”但时间远比并发标记短。并发清除清除死亡对象与用户线程并发执行。优点并发收集低停顿。缺点对CPU资源非常敏感在并发阶段虽然不会导致用户线程停顿但会因为占用一部分线程CPU资源而导致应用程序变慢总吞吐量会降低。无法处理“浮动垃圾”在并发清理阶段用户线程还在运行可能会产生新的垃圾这部分垃圾只能留到下一次GC再清理。内存碎片基于标记-清除算法会产生内存碎片。当碎片过多时会给大对象分配带来麻烦往往不得不提前触发一次Full GC。CMS提供了一个参数-XX:UseCMSCompactAtFullCollection默认开启用于在Full GC时开启内存碎片的合并整理。不确定性由于并发执行无法像其他收集器那样在内存几乎用尽时再收集。CMS需要预留一部分空间供并发收集时的程序运行使用。如果预留空间无法满足程序需要就会出现一次“Concurrent Mode Failure”失败这时虚拟机将启动后备预案临时启用Serial Old收集器来重新进行老年代的垃圾收集这样停顿时间就很长了。常用参数-XX:UseConcMarkSweepGC启用CMS收集器。-XX:CMSInitiatingOccupancyFraction设置老年代空间使用率阈值触发CMS收集JDK 5默认68%JDK 6默认92%。可以调低以避免“Concurrent Mode Failure”。-XX:UseCMSInitiatingOccupancyOnly强制使用上面设置的阈值不让JVM自行调整。5. 现代垃圾收集器面向未来的解决方案随着硬件发展和大内存应用普及G1、ZGC等收集器成为了新时代的主流。5.1 G1 收集器区域化分代式收集器G1是一款面向服务端应用的垃圾收集器在JDK 9中成为了默认收集器。它的设计目标是“在延迟可控的情况下获得尽可能高的吞吐量”。核心思想将整个Java堆划分为多个大小相等的独立区域Region虽然还保留新生代和老年代的概念但不再是物理隔离它们都是一部分Region的集合。G1跟踪各个Region里面的垃圾堆积的“价值”大小回收所获得的空间大小以及回收所需时间的经验值在后台维护一个优先列表每次根据允许的收集时间优先回收价值最大的Region。这种方式保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。运作步骤初始标记标记一下GC Roots能直接关联到的对象修改TAMS指针让下一阶段用户线程并发运行时能正确地在可用的Region中分配新对象。需要STW但耗时很短。并发标记从GC Root开始对堆中对象进行可达性分析递归扫描整个堆里的对象图。与用户线程并发执行。最终标记处理并发标记阶段结束后仍遗留下来的少量SATB记录。需要STW。筛选回收对各个Region的回收价值和成本进行排序根据用户期望的停顿时间来制定回收计划选择任意多个Region构成回收集。然后将回集中存活的对象复制到空的Region中再清理掉整个旧Region的全部空间。需要STW。优点可预测的停顿时间模型能建立可预测的停顿时间模型使用者可以明确指定在一个长度为M毫秒的时间片段内消耗在垃圾收集上的时间不超过N毫秒。空间整合从整体看是基于“标记-整理”算法从局部两个Region之间看是基于“复制”算法意味着不会产生内存碎片。对大堆友好将堆划分为多个Region能更好地处理非常大的堆数十GB乃至更大。常用参数-XX:UseG1GC启用G1收集器。-XX:MaxGCPauseMillis设置期望的最大GC停顿时间毫秒默认200ms。G1会尽力达成但不保证。-XX:InitiatingHeapOccupancyPercent设置触发并发标记周期的Java堆占用率阈值。默认45%。5.2 ZGC 与 Shenandoah超低延迟的追求者对于延迟极其敏感的应用如金融交易、实时游戏G1的停顿时间几十到两百毫秒可能仍然过高。ZGC和Shenandoah应运而生它们的目标是将停顿时间控制在10毫秒以内甚至更低。ZGCZ Garbage Collector是Oracle在JDK 11中引入的实验性功能在JDK 15中正式发布。它的核心特点是并发其停顿时间几乎与堆大小无关无论是2GB还是2TB停顿时间都能在10ms以内。它通过染色指针和读屏障技术实现了并发标记、并发转移和并发重定位极大地减少了STW时间。Shenandoah最初由Red Hat开发后来贡献给OpenJDK。它的目标与ZGC类似也是低停顿时间。实现原理与ZGC有相似之处但具体技术路径不同例如它使用了** Brooks指针**。使用建议如果你的应用对延迟有极致要求P99响应时间要求极低并且运行在JDK 11上可以尝试使用ZGC参数-XX:UseZGC。对于JDK 8用户如果追求低延迟可以尝试升级或使用Shenandoah需要特定的OpenJDK版本如Red Hat的OpenJDK。6. 垃圾回收日志分析与实战调优理论懂了最终还是要落到实战。看懂GC日志是调优的第一步。6.1 读懂GC日志在JVM启动参数中加入-XX:PrintGCDetails就可以在控制台或日志文件中看到详细的GC信息。下面是一段典型的Parallel Scavenge收集器的日志[GC (Allocation Failure) [PSYoungGen: 65536K-10720K(76288K)] 65536K-11216K(251392K), 0.0123456 secs] [Times: user0.05 sys0.01, real0.01 secs][GC (Allocation Failure)这是一次Minor GC触发原因是分配内存失败。[PSYoungGen: 65536K-10720K(76288K)]新生代Parallel ScavengeGC前占用65536KGC后占用10720K该区域总容量为76288K。65536K-11216K(251392K)整个堆或该次GC涉及的部分GC前占用65536KGC后占用11216K当前堆总容量为251392K。0.0123456 secs本次GC耗时。[Times: user0.05 sys0.01, real0.01 secs]用户态CPU时间、内核态CPU时间和墙钟时间实际经过的时间。在多核环境下usersys时间可能大于real时间。对于CMS、G1等收集器日志格式会更复杂但核心信息类似哪个区域、回收前后大小、耗时。6.2 常用JVM参数与调优思路调优没有银弹必须结合具体应用。以下是一些核心思路和常用参数1. 堆大小设置-Xms初始堆大小。通常设置为与-Xmx相同避免堆动态调整带来的额外开销。-Xmx最大堆大小。这是最重要的参数之一。设置太小容易OOM设置太大会导致GC停顿时间变长。通常建议设置为系统可用内存的70%-80%。-Xmn新生代大小。Sun官方推荐为整个堆的3/8。设置过大会导致老年代变小增加Full GC频率设置过小会导致Minor GC频繁。2. 垃圾收集器选择吞吐量优先后台计算、数据处理-XX:UseParallelGC或-XX:UseParallelOldGCJDK 8默认。响应时间优先Web服务、API-XX:UseConcMarkSweepGCJDK 8或-XX:UseG1GCJDK 9。极致低延迟金融、实时系统-XX:UseZGCJDK 11大堆或-XX:UseShenandoahGC。3. GC日志与监控-XX:PrintGCDetails打印详细GC日志。-XX:PrintGCDateStamps/-XX:PrintGCTimeStamps在GC日志中增加时间戳。-Xloggc:file将GC日志输出到文件。使用jstat、jvisualvm、GCViewer、Arthas等工具进行实时监控和分析。6.3 常见问题排查实录问题一java.lang.OutOfMemoryError: GC overhead limit exceeded现象JVM花费了98%以上的时间进行GC但只恢复了不到2%的堆空间。排查检查是否有内存泄漏使用jmap -histo:live pid查看对象实例数或者堆空间设置是否过小。也可能是代码中存在大量短命小对象导致GC频繁。解决增大堆空间-Xmx优化代码逻辑检查并修复内存泄漏。如果是大量临时对象可以考虑使用对象池。问题二频繁的Full GC现象通过GC日志或监控发现Full GC非常频繁如几分钟一次且每次回收后老年代空间释放很少。排查老年代空间不足可能是新生代对象过早晋升。检查Survivor区是否太小-XX:SurvivorRatio或者有大量大对象直接进入老年代-XX:PretenureSizeThreshold。内存泄漏老年代中的对象无法被回收。使用jmap -dump:formatb,fileheap.bin pid导出堆转储用MAT或JProfiler分析。CMS收集器并发模式失败如果使用CMS检查是否因-XX:CMSInitiatingOccupancyFraction设置过高导致并发收集启动太晚。可以适当调低此值如70并加上-XX:UseCMSInitiatingOccupancyOnly。System.gc()调用检查代码或第三方库是否显式调用了System.gc()可以通过-XX:DisableExplicitGC禁用。问题三应用暂停时间过长现象服务间歇性卡顿GC日志显示单次STW时间很长如超过1秒。排查堆太大且使用传统收集器Serial、Parallel、CMS的Full GC时间与堆大小成正比。对于大堆8GB考虑切换到G1或ZGC。老年代对象过多标记和整理/复制大量存活对象耗时。优化应用减少长生命周期对象的数量。G1的Evacuation Pause过长G1的筛选回收阶段需要STW。可以尝试降低-XX:MaxGCPauseMillis目标值G1会通过增加回收频率来尝试达成更短停顿但可能降低吞吐量。问题四Metaspace (元空间) 溢出现象java.lang.OutOfMemoryError: Metaspace排查常见于大量使用动态代理如Spring AOP、反射、字节码增强如CGLib的应用。这些操作会动态生成大量类。解决增加元空间大小-XX:MaxMetaspaceSize256m如果是Spring应用对于不需要代理的Bean可以设置proxyBeanMethods falseSpring Boot。检查是否有类加载器泄漏。调优是一个“观察-假设-验证”的循环过程。切忌盲目修改参数。一定要先收集足够的监控数据GC日志、堆直方图、线程栈等分析出瓶颈所在再有针对性地调整每次只修改一个参数观察效果。