JVM堆分代垃圾回收原理与线上调优实战 1. 从一次线上告警说起为什么堆要分代凌晨两点监控面板上一条老年代占用率的曲线突然抬头紧接着 Full GC 次数在十分钟内从 0 跳到 7接口 P99 从 80ms 飙到 2.3s。值班的同学第一反应是内存泄漏了第二反应是把堆调大一点。这两个反应都很自然但往往都不是正确答案。真正的问题通常藏在更底层的地方——对象在堆里是怎么被划分的垃圾回收器又是按什么规则去清理它们的。这就是 JVM 运行时数据区里堆分代垃圾回收要解决的核心问题。堆是 JVM 管理的内存中最大的一块所有线程共享几乎所有的对象实例都在这里分配内存。如果堆是一整块、不分区每次回收都得全量扫描那停顿时间会随着对象数量线性增长服务根本扛不住。分代回收的思路本质上是把一次全扫拆成多次局部扫 偶尔全扫用统计规律换取吞吐和延迟的平衡。这篇文章面向的是已经写过 Java、但对 JVM 内存模型只停留在堆和栈层面的开发者也适合正在准备性能调优、排查 GC 问题的同学。我会把堆分代的划分依据、对象晋升路径、各回收器在分代上的差异、以及实际调优时容易踩的坑一条条拆开讲清楚。读完你应该能回答三个问题为什么新生代要用复制算法、对象什么时候进老年代、以及线上遇到 GC 抖动时该从哪几个参数下手。2. 堆分代的底层依据弱分代假说与对象生命周期分布2.1 弱分代假说到底在说什么分代回收不是拍脑袋设计的它建立在一个被大量实测验证的统计规律上业界称为弱分代假说Weak Generational Hypothesis。这个假说包含两条核心论断第一绝大多数对象都是朝生夕死的第二熬过越多次垃圾回收的对象就越难以死亡。第一条说的是对象生命周期分布极度倾斜。在一个典型的 Web 服务里一次请求进来会创建大量临时对象——DTO、String、集合、包装类这些对象在请求处理完就变成垃圾了存活时间可能只有几毫秒。真正长期存活的对象比如缓存、连接池、配置对象占比非常小。第二条说的是越老越难死。一个对象如果已经扛过了好几轮 GC 还没被回收说明它大概率是被某个长生命周期引用持有的比如静态字段、单例、线程池里的任务队列。这类对象再被回收的概率就低很多。注意弱分代假说是统计规律不是物理定律。它描述的是大多数情况而不是所有情况。如果你的应用大量创建长生命周期对象分代策略的收益就会打折这也是后面要讲的调优切入点。2.2 分代划分的具体结构基于这个假说HotSpot 把堆逻辑上划分为两个区域新生代Young Generation和老年代Old Generation。在 JDK 8 及之前还有一块永久代Permanent Generation用来存放类元数据JDK 8 之后被元空间Metaspace取代移到了本地内存不再属于堆的一部分。这一点很多人会混淆永久代严格来说不属于堆分代回收的讨论范围但面试和排查时经常被一起问。新生代又进一步细分为三个区域Eden 区新对象优先在这里分配是对象诞生的地方。Survivor 区 FromS0上一次 Minor GC 存活下来的对象暂存区。Survivor 区 ToS1本次 Minor GC 存活对象的转移目标区。默认情况下Eden 和两个 Survivor 的比例是8:1:1。这个比例不是随便定的它直接决定了每次 Minor GC 后有多少对象能留在新生代。如果 Survivor 太小存活对象放不下就会提前触发分配担保把对象直接塞进老年代反而加重老年代负担。2.3 为什么是 8:1:1 而不是平均分假设 Eden 和 Survivor 平均分各占三分之一。Minor GC 时Eden 里的存活对象要复制到 Survivor如果存活对象超过 Survivor 容量就得走分配担保进老年代。而实测中绝大多数 Minor GC 后 Eden 的存活率很低通常不到 10%。把 Survivor 设小一点Eden 设大一点能让新对象有更充裕的分配空间减少 Minor GC 频率同时又不至于让存活对象无处可去。8:1:1 的本质是用统计上的低存活率换取 Eden 的最大化。你可以通过-XX:SurvivorRatio8来调整这个比例值表示 Eden 与单个 Survivor 的比值。比如设成 4就是 Eden 占 4/6每个 Survivor 占 1/6。调这个参数的前提是你清楚自己应用的存活率特征盲目调大 Survivor 会压缩 Eden导致 Minor GC 更频繁。3. 对象在分代堆里的完整生命周期3.1 从分配到第一次 Minor GC新对象创建时优先在 Eden 分配。如果 Eden 空间不够就触发一次Minor GC也叫 Young GC。这里有个细节如果对象太大超过-XX:PretenureSizeThreshold设定的阈值会直接在老年代分配避免在 Eden 和 Survivor 之间来回复制。这个参数默认是 0也就是不启用只有 Serial 和 ParNew 回收器支持。Minor GC 的过程是这样的先把 Eden 和 From Survivor 中存活的对象复制到 To Survivor然后清空 Eden 和 From最后把 From 和 To 的角色互换。注意Survivor 的两个区域总有一个是空的这是复制算法的要求也是为什么新生代实际可用空间只有 Eden 加一个 Survivor。3.2 对象年龄计数器与晋升阈值每经历一次 Minor GC 还存活的对象年龄计数器加 1。当年龄达到阈值默认 15通过-XX:MaxTenuringThreshold设置时对象晋升到老年代。这个阈值不是固定的JVM 会根据 Survivor 中同年龄对象的总大小动态调整这个机制叫动态年龄判定。动态年龄判定的规则是如果在 Survivor 中所有年龄小于等于某个年龄 N 的对象大小总和超过了 Survivor 空间的一半那么年龄大于等于 N 的对象就可以直接晋升不用等到 15。这个设计是为了避免 Survivor 被大量中等年龄对象占满导致后续复制失败。提示-XX:MaxTenuringThreshold的最大值是 15因为对象头里用来存年龄的位只有 4 位。设成 15 以上不会报错但实际生效的还是 15。3.3 空间分配担保老年代的最后一道防线Minor GC 之前JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总大小。如果大于Minor GC 是安全的如果不大于就看-XX:HandlePromotionFailure是否允许担保失败。允许的话继续检查老年代最大可用连续空间是否大于历次晋升对象的平均大小大于就尝试 Minor GC否则直接 Full GC。这套逻辑听起来绕但目的很明确尽量避免 Minor GC 后存活对象无处安放被迫触发 Full GC。JDK 6 Update 24 之后HandlePromotionFailure参数已经失效规则简化为只要老年代连续空间大于新生代对象总大小或历次晋升平均大小就允许 Minor GC否则 Full GC。3.4 大对象与长期存活对象的特殊路径除了年龄晋升还有两条路径会让对象提前进入老年代。一是大对象直接分配前面提过通过PretenureSizeThreshold控制。二是Survivor 空间不足时的分配担保如果 To Survivor 放不下存活对象这些对象会直接进老年代。这两条路径是很多 GC 问题的根源。比如你的应用频繁创建大数组又没设PretenureSizeThreshold这些大对象会在 Eden 和 Survivor 之间反复复制既浪费 CPU 又加速晋升。再比如 Survivor 设得太小每次 Minor GC 都有大量对象被迫进老年代老年代很快被填满Full GC 频繁触发。4. 分代回收器怎么配合从 Serial 到 G1 的演进4.1 新生代回收器与老年代回收器的组合关系HotSpot 的垃圾回收器不是单独工作的而是新生代和老年代各选一个组合使用。常见组合如下新生代回收器老年代回收器特点适用场景SerialSerial Old单线程STW客户端、小内存ParNewCMS多线程新生代 并发老年代低延迟 Web 服务Parallel ScavengeParallel Old多线程吞吐优先批处理、计算密集G1G1统一分区可预测停顿大堆、低延迟这里有个容易搞混的点CMS 是老年代回收器它必须搭配 Serial 或 ParNew 作为新生代回收器。你不能单独用 CMS 回收整个堆。而 G1 是统一回收器逻辑上仍然有分代概念但物理上把堆分成一个个 Region不再要求连续的新生代和老年代空间。4.2 Parallel Scavenge 的吞吐优先逻辑Parallel Scavenge 关注的是吞吐量也就是用户代码运行时间占总时间的比例。它提供了两个关键参数-XX:MaxGCPauseMillis控制最大停顿时间-XX:GCTimeRatio控制 GC 时间占比。注意MaxGCPauseMillis调小不一定是好事停顿时间短了GC 频率可能上升总吞吐反而下降。Parallel Scavenge 还有一个-XX:UseAdaptiveSizePolicy参数开启后 JVM 会根据运行时监控信息动态调整 Eden、Survivor 比例和晋升阈值。这个参数对不想手动调优的同学很友好但如果你在做精细调优建议关掉它避免参数被自动改掉导致结果不可复现。4.3 CMS 的并发标记与碎片问题CMSConcurrent Mark Sweep追求的是最短停顿时间它的回收过程分四个阶段初始标记、并发标记、重新标记、并发清除。初始标记和重新标记需要 STW但时间很短并发标记和并发清除可以和用户线程一起跑。CMS 最大的问题是内存碎片。因为它用的是标记-清除算法不整理内存运行久了老年代会出现大量不连续空间。当需要分配大对象时可能因为没有连续空间而触发 Full GC。CMS 提供了-XX:UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction来缓解但治标不治本。JDK 9 之后 CMS 被标记为废弃JDK 14 正式移除现在新项目基本都用 G1 或 ZGC。4.4 G1 的分区思想与分代回收的融合G1 把堆分成大小相等的 Region每个 Region 可以是 Eden、Survivor、Old 或 Humongous存放大对象。G1 的回收不再是全新生代或全老年代而是根据每个 Region 的回收价值垃圾占比排序优先回收价值高的 Region这就是Garbage First名字的由来。G1 仍然保留分代概念但它是逻辑分代、物理分区。新生代回收叫 Young GC会 STW混合回收叫 Mixed GC会回收所有新生代 Region 加上部分老年代 Region。G1 通过-XX:MaxGCPauseMillis设定目标停顿时间然后自动计算每次回收多少个 Region尽量逼近这个目标。注意G1 的MaxGCPauseMillis是软目标不是硬保证。如果堆太大或垃圾产生速度太快G1 也可能达不到目标这时候需要结合-XX:G1HeapRegionSize和-XX:InitiatingHeapOccupancyPercent一起调。5. 线上调优实战从 GC 日志定位分代问题5.1 先开日志再谈调优没有 GC 日志的调优都是瞎猜。JDK 8 用-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:gc.logJDK 9 之后统一用-Xlog:gc*:filegc.log:time,uptime,level,tags。日志里要重点关注几个指标Minor GC 频率、Full GC 频率、每次 GC 前后各代占用变化、晋升对象大小。举个例子如果日志显示 Minor GC 每 2 秒一次每次回收后 Eden 几乎清空Survivor 占用很低说明新生代偏小可以适当调大-Xmn或-XX:NewRatio。如果 Minor GC 后 Survivor 经常爆满对象大量晋升说明 Survivor 太小或晋升阈值太低可以调大-XX:SurvivorRatio或-XX:MaxTenuringThreshold。5.2 一个真实的晋升过快案例某服务堆设了 4G新生代 1GEden 800M两个 Survivor 各 100M。日志显示每次 Minor GC 后 Survivor 占用都在 90M 以上接近爆满大量对象年龄刚到 2 就晋升了。老年代增长很快半小时一次 Full GC。排查后发现这个服务有个批量任务每次会创建几十万个临时对象存活时间比普通请求长刚好卡在 Survivor 容量边缘。调整方案是把 SurvivorRatio 从 8 改成 4Eden 变成 500M每个 Survivor 变成 250M。调整后 Survivor 占用降到 60M 左右晋升年龄回到 8 以上Full GC 降到几小时一次。这个案例的关键不是参数本身而是先看日志确认瓶颈在 Survivor 容量再针对性调整。如果一上来就把堆调大可能只是把 Full GC 推迟没有解决晋升过快的问题。5.3 常见参数速查与调整方向参数作用调整方向-Xmn / -XX:NewRatio新生代大小Minor GC 频繁则调大-XX:SurvivorRatioEden 与 Survivor 比例Survivor 爆满则调小比值-XX:MaxTenuringThreshold晋升年龄阈值晋升过快则调大-XX:PretenureSizeThreshold大对象阈值大对象多则设置-XX:MaxGCPauseMillis目标停顿时间延迟敏感则调小-XX:InitiatingHeapOccupancyPercentG1 触发阈值Full GC 频繁则调小调参有个基本原则一次只改一个参数改完观察至少一个完整业务周期。同时改多个参数出了问题根本不知道是哪个引起的。另外调优前一定要有基线数据没有基线的调优就是碰运气。6. 那些文档里不写的分代回收坑6.1 动态年龄判定导致的意外晋升动态年龄判定是个双刃剑。它能在 Survivor 紧张时提前晋升避免复制失败但也可能在你没预期的时候把对象推进老年代。我遇到过一种情况应用里有一批年龄为 3 的对象总大小刚好超过 Survivor 一半结果所有年龄大于等于 3 的对象全部晋升老年代瞬间涨了一截。这个坑的隐蔽性在于它不报错、不告警只在 GC 日志里体现为晋升对象突然增多。排查方法是看每次 Minor GC 前后的老年代占用变化如果某次突然跳升就要怀疑动态年龄判定。缓解办法是适当调大 Survivor或者用-XX:-UseAdaptiveSizePolicy关掉自适应手动控制晋升节奏。6.2 TLAB 与分配竞争TLABThread Local Allocation Buffer是 Eden 里为每个线程预分配的一小块内存用来避免多线程分配对象时的指针碰撞竞争。默认开启通过-XX:UseTLAB控制。TLAB 太小会导致频繁申请新 TLAB太大则浪费空间。这个坑在低延迟场景下特别明显。如果 TLAB 设置不当线程分配对象时经常要同步申请新缓冲区延迟毛刺就出来了。可以通过-XX:TLABSize手动设置或者用-XX:ResizeTLAB让 JVM 自动调整。观察指标是 GC 日志里的 TLAB 浪费率如果浪费率超过 10%就值得调一调。6.3 元空间膨胀被误判为老年代问题JDK 8 之后元空间移到本地内存但很多人排查时看到老年代占用高就慌了其实可能是元空间在涨。元空间存放类元数据如果应用动态生成类比如反射、代理、脚本引擎元空间会持续增长最终触发 Full GC 或 OOM。区分方法很简单看 GC 日志里 Metaspace 的占用变化或者用jstat -gc看 MC/MU 两列。如果 MC 持续上涨就是元空间问题跟堆分代无关。解决办法是设置-XX:MaxMetaspaceSize限制上限并排查动态类生成的源头。6.4 Full GC 不等于老年代满了最后一个坑Full GC 的触发原因有很多老年代满只是其中之一。元空间不足、System.gc() 调用、CMS 并发失败、G1 的 Humongous 分配失败都会触发 Full GC。如果不看日志里的触发原因只盯着老年代占用很容易调错方向。JDK 8 的 GC 日志会打印[Full GC (Allocation Failure)或[Full GC (Metadata GC Threshold)这样的原因JDK 9 之后更详细。排查时先看原因再决定是调堆、调元空间还是排查代码里的显式 GC 调用。7. 分代回收的边界与未来分代回收不是万能的。它的前提是弱分代假说成立如果你的应用对象生命周期分布均匀或者大量对象都是长生命周期分代带来的收益就很有限。比如一些缓存型服务对象创建后长期存活新生代回收几乎回收不到东西反而增加了复制开销。另一个边界是大堆场景。堆越大Full GC 的停顿越长因为要扫描的对象更多。G1 和 ZGC 这类回收器就是为这个大堆低延迟场景设计的它们通过分区、并发标记、染色指针等技术把停顿控制在毫秒级。但代价是更高的 CPU 占用和更复杂的内存管理。从趋势上看分代回收正在从物理分代走向逻辑分代。G1 的 Region 可以动态扮演不同角色ZGC 干脆不分代靠并发标记和指针染色实现低延迟。但无论怎么演进理解分代回收的原理仍然是读懂这些新回收器的基础。因为它们的很多设计决策都是在解决分代回收暴露出来的问题——碎片、停顿、晋升失控。我个人在实际调优中的体会是先把分代结构和对象晋升路径搞清楚再去看 GC 日志很多问题会变得非常直观。参数是表象对象在堆里的流动才是本质。下次遇到 GC 告警别急着调堆大小先打开日志看看对象到底卡在了哪一代。