拓冰建站拓冰建站
首页 / 资讯中心 / 正文

JVM分代收集原理与调优:从GC日志看懂堆内存

Java 服务跑着跑着就卡顿监控一看 Minor GC 每秒好几次时不时再来一次 Full GC 直接让接口超时。这几乎是每个做服务端开发的人都会遇到的场景而所有这些问题最终都要回到 JVM 垃圾回收最基础的一条设计思路上来分代收集理论。可以说不理解分代收集你看到的 GC 日志就是一堆无意义的数字理解了之后日志里每一行都在告诉你堆内存里发生了什么。这篇文章不只是给你讲理论我会结合自己在实际项目里排查 GC 问题、调优参数的真实过程把分代收集的来龙去脉、每个区域的作用、Minor GC 和 Full GC 的完整流程以及那些“不写在文档里”的坑都拆开讲一遍。无论你是刚接触 JVM 的初学者还是正在被线上 GC 问题折磨的开发者这篇文章都应该能帮你把这块知识补完。1. 分代收集到底在解决什么问题1.1 强分代假说与弱分代假说分代收集不是凭空发明的它建立在两个被长期观察验证的统计假说之上。强分代假说说的是绝大多数对象都是“朝生夕死”的存活时间极短。IBM 曾做过专门研究发现超过 98% 的对象在第一次垃圾回收时就已经变成不可达的垃圾。你可以回想一下自己的代码大部分对象都是方法里的临时变量、循环里的中间结果用完一次就不再需要了。真正能活到最后的对象少之又少。弱分代假说说的是熬过越多次垃圾回收的对象越倾向于继续存活。这就好比一个新员工入职能不能干满一年往往在头几个月就能看出来——挺过了试用期后面留下来的概率就大很多。这两个假说听起来简单却直接决定了垃圾回收器的设计方向既然绝大多数对象活不长那我们可以把堆分成不同区域对不同区域采用不同策略。新生代里大部分对象都是垃圾回收时只需要复制那少量存活对象成本极低老年代里对象存活率高不适合频繁回收那就降低扫描频率用更适合长生命周期对象的算法。如果放弃分代每次 GC 都必须扫描整个堆的全部对象每次回收要承担全部的遍历成本还要对付全堆碎片化问题。这个成本对高并发服务来说是灾难性的。1.2 分代设计带来的核心收益分代收集最直接的收益是降低了每次 GC 的扫描范围和暂停时间。假设堆是 4GB新生代占 2GB。一次 Minor GC 只需要在新生代内部做可达性分析和对象复制扫描范围只有全堆的一半而且存活对象通常只有几个 MB复制开销非常小。全堆扫描可能在几十毫秒以上而新生代回收可以做到几毫秒内完成。第二个收益是算法选择的自由度。新生代存活率低适合用复制算法——把存活对象挪到另一块空间一次性清理整个区域没有碎片老年代存活率高用复制算法会复制大量对象、成本太高更适合标记-清除或标记-整理。分代让不同区域可以各自选择最合适的算法组合。第三个收益是分配效率。绝大多数对象直接分配在新生代的 Eden 区而 Eden 区的对象分配只需要指针碰撞就行不需要遍历空闲链表找内存块。这也是为什么 Java 对象分配速度极快、几乎免费的原因之一——它在 Eden 里就是挪一下指针的事。实话讲分代收集的设计思路在几十年后的今天依然没有被推翻只是实现粒度发生了变化。从 Serial 到 CMS再到 G1、ZGC分代的思想一直都在只不过“代”的物理边界越来越模糊了。2. 堆内存区域划分与参数配置2.1 新生代的内部三块区域分代收集中的“代”不是笼统的一分为二新生代内部又划分成了三块一个 Eden 区和两个 Survivor 区From 和 To。默认比例是 8:1:1。这个结构很关键。Eden 区是所有新对象分配的入口绝大多数对象在这里诞生也在这里被回收。两块 Survivor 区则承担了“暂存幸存者”的角色。为什么要两块而不是一块因为复制算法要求一个安全的空区域作为目标。Minor GC 时把 Eden 和 From Survivor 中存活的对象一起复制到 To Survivor复制结束后Eden 和 From 被清空然后 From 和 To 互换身份保证下一次 GC 时依然有一个空的 To 区。很多初学者不理解为什么不能让存活对象直接留在原地非要复制来复制去你要记住一点——复制算法是为了换取内存的连续性。把存活对象集中复制到连续空间回收后的 Eden 和 From 天然是一整块干净区域后续分配对象直接指针碰撞就行不需要处理碎片。付出的代价是需要浪费一小块 To 区作为临时周转空间这完全是值得的。实际调优中Survivor 区太小是一个高频问题。默认 8:1:1 的比例如果新生代只有 200MB每个 Survivor 只有 20MB。一旦某个瞬时流量高峰导致存活对象超过 20MB多出来的对象就会直接晋升到老年代——哪怕它们只活了几秒钟。长此以往老年代被垃圾填满Full GC 越来越频繁。这块内容后面常见问题部分我再细讲。2.2 老年代与空间分配担保能熬过多次 Minor GC 的对象最终会进入老年代。但除了正常晋升还有两条特殊通道第一条是大对象直接进入老年代。JVM 有个参数-XX:PretenureSizeThreshold超过阈值的大对象直接在老年代分配。这么做的原因是大对象在新生代里来回复制开销太大而且大对象在 Eden 里占空间容易触发 Minor GC。对于特别大的对象直接在老年代分配反而更省。这个阈值默认是 0表示不启用很多场景下其实需要显式设置。第二条是空间分配担保机制。Minor GC 之前JVM 会检查老年代最大可用连续空间是否大于新生代全部对象总大小。如果大于说明这次 Minor GC 即使全部对象都存活也能放下可以直接做 Minor GC如果小于则会检查老年代可用连续空间是否大于历次晋升到老年代对象的平均大小。这就涉及一个历史参数HandlePromotionFailure在 JDK 6u24 之后这个开关的影响已经被简化——只要老年代连续空间大于新生代总大小或历次晋升的平均水平就允许 Minor GC否则会改为进行一次 Full GC。这个机制的核心目的是让垃圾回收器在不确定存活对象数量的情况下敢于冒险做一次高效的 Minor GC。但“冒险”是有代价的一旦实际存活对象超过预期就会触发promotion failed这个后面会专门说。GC Roots 我简单提一句——所有可达性分析都要从一组根对象出发。根包括虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、JNI 引用等。理解了 Roots你就知道为什么 Minor GC 虽然只回收新生代但依然需要扫描一部分老年代对象——万一老年代里的对象引用了新生代对象呢这正是跨代引用问题的由来第 3 部分会展开。2.3 关键参数速查与调优建议这里把堆内存相关的最核心参数整理出来方便你对照检查自己线上服务的配置。参数作用默认值建议-Xms初始堆大小物理内存 1/64与-Xmx保持一致-Xmx最大堆大小物理内存 1/4根据服务内存预算设置-Xmn新生代大小视收集器而定堆的 1/3 到 1/2 之间-XX:NewRatio老年代/新生代比值2一般用默认即可-XX:SurvivorRatioEden/Survivor 比值8观察晋升量后再调整-XX:MaxTenuringThreshold晋升年龄阈值15CMS 是 6配合 Survivor 大小调整-XX:PretenureSizeThreshold大对象阈值0 表示不启用根据业务对象大小设置几个我踩过坑之后的经验第一-Xms和-Xmx务必设成一样。如果初始堆远小于最大堆JVM 就会在运行过程中不断扩容扩容过程伴随一次 Full GC而且堆容量变化会影响 GC 行为的一致性导致你调参时看到的日志数据不可复现。第二新生代不要贪大。虽然新生代越大 Minor GC 次数越少但老年代相应地被压缩了频繁 Full GC 的代价比频繁 Minor GC 大得多。我见过有人把 8GB 堆分给新生代 5GB结果老年代只剩 3GBFull GC 每几分钟一次接口响应时间惨不忍睹。第三-Xmn和-XX:NewRatio不要同时设置二者会互相覆盖配置混乱时排查问题很痛苦。团队里最好统一口径要么都用绝对大小要么都用比值。3. 分代收集的工作原理与算法拆解3.1 Minor GC复制算法的完整流程Minor GC也叫 Young GC是分代收集里发生频率最高的回收过程它的完整流程可以拆成六步新对象创建时统一分配在 Eden 区。Eden 空间耗尽触发 Minor GC此时从 GC Roots 出发做可达性分析。从 Eden 区和 From Survivor 区扫描所有存活对象一次性复制到 To Survivor 区。复制过程中对象的年龄 1。如果年龄达到阈值MaxTenuringThreshold或者 To Survivor 区空间不足对象直接晋升老年代。清空 Eden 区和 From Survivor 区。交换 From 和 To 的角色保证下一次 GC 时依然有空的 To 区。整个过程看起来简单但有几个细节容易被忽略。一是**“复制”和“晋升”是一体决策的**。复制对象之前JVM 要判断 To Survivor 剩余空间是否容得下这个对象容不下就走晋升逻辑。这个判断发生在每次复制前而不是先复制完再统一检查因为复制过程中随时可能发现空间不够。二是Minor GC 的“Stop The World”时间主要花在可达性分析和对象复制上。复制算法的特点是存活对象越少GC 越快。新生代 98% 对象都是垃圾复制量极小所以 Minor GC 才敢频繁做。如果哪天发现 Minor GC 时间突然变长大概率是存活对象暴涨——比如有人把大 List 或缓存对象放在方法里没释放又或者线程局部变量忘了解除引用。三是复制后对象的地址变了所有指向它的引用都要更新。这意味着遍历整个 Java 线程栈、方法区、老年代里可能引用该对象的区域。这里就引入了跨代问题——为了不扫描整个堆JVM 必须维护跨代引用的索引也就是后面要讲的卡表和写屏障。3.2 Major GC 与 Full GC标记阶段的差异先说一个概念区分Major GC一般是清理老年代Full GC则是清理整个堆外加方法区元空间。很多文章把二者混着用但严格意义上 Full GC 影响范围更大暂停时间也长得多。老年代不能像新生代那样用复制算法因为存活对象太多复制成本太高。主流的老年代 GC 有两个方向标记-清除Mark-Sweep先标记所有存活对象然后统一清除未被标记的对象。优点是实现简单、不需要移动对象缺点是会产生大量内存碎片。碎片化严重时即使老年代还有几百 MB 空闲也可能找不到一块连续空间装下一个大对象直接诱发一次 Full GC——这就是碎片害人的典型场景。CMS 用的就是这个思路所以 CMS 有个参数-XX:UseCMSCompactAtFullCollection用于在 Full GC 时做碎片整理。标记-整理Mark-Compact标记存活对象后不直接清除而是将所有存活对象向一端移动然后清理边界外的空间。移动对象意味着要更新所有引用过程中必须全程 STW暂停时间更长但整理后内存是连续的后续分配高效。Parallel Old 收集器用的就是标记-整理。这里要理解一个本质矛盾要么忍受碎片要么承受移动对象的开销。新生代适合复制移动是因为存活对象少移动成本低老年代适合清除或整理则是因为对象多移动成本高所以宁可让 GC 频率低一点、每次多花点时间扫全一点也不要频繁做。3.3 卡表与写屏障——跨代引用的关键机制这是分代收集里最容易被忽略、却又最精巧的一部分。问题是这样的Minor GC 回收新生代时必须知道老年代里有哪些对象引用了新生代对象。如果不做任何处理就只能全堆扫描那分代的意义就消失了——每次 Minor GC 都要遍历老年代所有对象跟 Full GC 还有什么区别JVM 的解决方案是卡表Card Table 写屏障Write Barrier。原理是这样把老年代内存按 512 字节划分成一个个卡片对应一个卡表字节数组。每当发生引用赋值比如oldObj.field youngObjJVM 会通过写屏障在卡表里把老年代对象所在区域对应的卡标记为“脏”dirty。Minor GC 时回收器只需要扫描卡表中标记为 dirty 的区域就能找到那些引用了新生代的老年代对象而不用扫描整个老年代。用生活场景类比卡表就像一个小区的栋号索引牌。平时你不需要知道每家每户住了谁只需要知道哪栋楼里有人可能找楼下的快递站帮忙。快递站收快递时只要把那栋楼标记一下之后整理时只去标记过的楼敲门即可。写屏障的时机也很有讲究。JVM 通常采用“写后”屏障——即引用赋值完成后再标记卡表。这样做有个隐患标记动作发生在一条热路径上频繁的引用赋值会产生大量卡表写操作。为了优化JVM 会先判断卡片是否已经是 dirty 状态如果是就不再重复标记。但这又引出了**伪共享False Sharing**问题——多个线程同时标记相邻的卡片时可能因为 CPU 缓存行冲突而互相拖慢。HotSpot 通过-XX:UseCondCardMark参数来缓解这个参数会先检查卡是否已脏再写牺牲一点性能换取并发下的缓存友好性。理解卡表机制后你会明白为什么“老年代引用新生代对象”是分代收集的关键瓶颈之一。大量跨代引用会导致 Minor GC 需要扫描的老年代区域变大GC 时间也会变长。如果你的代码有大量把新生代对象塞进老年代缓存/全局 Map 的行为观察 GC 日志时要注意 Minor GC 时间的异常上涨。3.4 几种代表性分代收集器的取舍分代理论虽然是公共基础但不同收集器对分代的实现方式差异很大。我按使用场景给你梳理一遍收集器新生代回收方式老年代回收方式适用场景Serial复制算法标记-整理单 CPU、Client 模式、小堆ParNew复制算法并行配合 CMS多 CPU 服务端、追求低延迟Parallel Scavenge复制算法并行标记-整理并行多 CPU、追求吞吐量CMS复制算法标记-清除并发低延迟场景如 Web 服务G1基于 Region 的复制基于 Region 的标记-整理大堆、可预测暂停时间我把 ParNew 和 Parallel 单独拎出来说。ParNew 是 Serial 的多线程版本只做了新生代并行回收老年代可以搭配 Serial Old 或者 CMS。Parallel 收集器更看重吞吐量可以自适应调节是 JDK 8 之前的默认组合Parallel Scavenge Parallel Old。CMS 在很长一段时间里是低延迟服务的主流选择但它有三个硬伤CPU 资源敏感、无法处理浮动垃圾、碎片化严重——每个问题都足以把线上服务拖垮。我经历过一次 CMS 并发模式失败Concurrent Mode Failure老年代碎片化到插入一个大对象直接触发 Full GC全程 STW 好几秒钟。后来才理解CMS 追求并发收集但它的老年代回收从来没有真正做到“不停机”而是在并发阶段和 STW 阶段之间反复切换。G1 则在 JDK 9 之后成为默认收集器它的核心思路是把堆划分为多个 Region逻辑上继续保留新生代和老年代的概念但物理上不再要求老年代必须连续。所以 G1 本质上还在贯彻分代理论只是用更细粒度的 Region 来平衡回收效率和暂停时间。4. 实操GC 日志分析、参数调整与真实案例4.1 怎么用 GC 日志把现场还原出来纸上谈兵没有用直接上日志。我建议线上服务至少开启这几个日志参数-Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC如果你用的是 JDK 11 及以上版本建议统一用统一日志体系-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags下面是我从真实线上环境摘的一段 Minor GC 日志JDK 8 格式2024-05-12T15:30:01.1230800: 28453.123: [GC (Allocation Failure) [ParNew: 2097152K-335598K(2359296K), 0.0421234 secs] 4234096K-2465390K(8298496K), 0.0424321 secs] [Times: user0.15 sys0.01, real0.04 secs]逐段解读ParNew表示新生代用的多线程回收2097152K-335598K(2359296K)意思是 GC 前新生代占用 2GBGC 后剩余 335MB新生代总容量 2.25GB4234096K-2465390K(8298496K)是对整个堆而言GC 前 4.2GBGC 后 2.4GB堆总容量 8.3GB0.0424321 secs是整个 Minor GC 的耗时。值得关注的核心指标是新生代回收后的存活量和整个堆的回收净收益。如果每次 Minor GC 后堆内存不减反增说明分配速率超过回收速率——这是要出事的信号。另外注意Allocation Failure这个原因是 Eden 区不够分配新对象了属于正常触发。如果看到的是System.gc()调用触发的 Full GC那就得检查是不是代码里显式调用了。再给一段 Full GC 日志做对比2024-05-12T16:00:00.4560800: 30412.456: [Full GC (Ergonomics) [PSYoungGen: 10240K-0K(2359296K)] [ParOldGen: 5925889K-5242880K(6291456K)] 8288496K-5242880K(8298496K), [Metaspace: 18453K-18453K(1064960K)] 0.3123456 secs]Full GC 会把新生代清成 0老年代内存大幅下降。这里Ergonomics表示是由 JVM 自适应调节策略触发的 Full GC——通常意味着老年代使用率超过了-XX:GCTimeLimit等阈值JVM 认为再不回收系统就撑不住了。分析日志一定要抓两个维度频率和单次耗时。Minor GC 每秒好几次但单次 5ms 以内说明分配压力大Minor GC 一次 100ms说明存活对象太多或跨代引用扫描范围太大。两种情况的调优方向完全相反。4.2 一次线上 Full GC 频发的排查调优实录分享一个真实案例。之前我负责的一个订单服务突然出现 Full GC 频繁告警群里每隔几分钟就报一次老年代使用率超过 90%。第一件事是拉 GC 日志发现有大量这种记录[ParNew: 2097152K-2097152K(2359296K), 0.1234567 secs]新生代回收前和回收后占用几乎没变——97% 的对象没被回收掉。这说明新生代里的对象全是存活的。当时第一反应是怀疑有全局缓存或者 ThreadLocal 泄漏但查了一遍代码发现并没有明显的长生命周期对象。后来又看了一行日志让我恍然大悟[Full GC (Allocation Failure) 4G-4G(4G), 2.3456789 secs]Full GC 后堆内存占用完全没有下降这已经不是 GC 效率问题而是分配速率超过了回收速率。于是我加了-XX:PrintTenuringDistribution参数重启服务后再观察发现年龄为 1 的对象大量直接进入老年代Desired survivor size 33554432 bytes, new threshold 15 (max 15) - age 1: 19658712 bytes, 19658712 total - age 2: 10234567 bytes, 29893279 total问题比预想的更严重年龄 1 的对象就占用了近 20MB而整个 Survivor 区只有 32MB。这意味着每次 Minor GC 都有大量只活了一轮的对象无法留在 Survivor直接晋升老年代——老年代每天都在被短期对象填满自然频繁 Full GC。根因找到了Survivor 空间设置太小叠加业务高峰期有大量大对象短生命周期对象产生。最终调整方案如下-Xms8g -Xmx8g -Xmn5g -XX:SurvivorRatio6 -XX:MaxTenuringThreshold10 -XX:UseConcMarkSweepGC把新生代从默认的 2.7GB 加大到 5GBSurvivorRatio 从 8 改为 6每个 Survivor 从 300MB 左右增加到约 700MB。这样即使高峰期有 100MB 的存活对象也能在 Survivor 周转两周以上。调整后 Full GC 频率从每 10 分钟一次降到一天多一次接口 99 分位耗时从 800ms 降到 120ms。这个案例标准地展示了一个调参链路看日志 - 找异常指标 - 分析根因 - 调整参数 - 压测验证。不要一上来就改参数一定要先搞清楚到底是什么对象占用了内存存活时间多长再决定调大哪块区域。4.3 调参示范从默认参数到定制方案调参不能纸上谈兵我按两类典型服务给出配置参考。第一类IO 密集型 Web 服务。特点是请求量大、对象创建销毁频繁、请求响应要求低延迟。这种服务适合用偏向低延迟的组合同时新生代要足够大来承接高分配速率-Xms6g -Xmx6g -Xmn3g -XX:SurvivorRatio8 -XX:UseParNewGC -XX:UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction70 -XX:UseCMSInitiatingOccupancyOnlyCMSInitiatingOccupancyFraction70表示老年代使用率达到 70% 就开始并发 CMS 回收而不是等到几乎满了才开始这样能减少 Concurrent Mode Failure 的风险。第二类计算密集型的批处理服务。特点是任务周期长、对象存活久、追求最大化吞吐量。这种服务适合用 Parallel 收集器-Xms8g -Xmx8g -Xmn4g -XX:SurvivorRatio8 -XX:MaxTenuringThreshold15 -XX:UseParallelGC -XX:UseParallelOldGC不管哪类服务调参之后一定要做压测对比。我习惯的做法是把压测分为三档正常流量的 50%、100%、200%观察每一档下 Minor GC 频率、单次 GC 耗时、Full GC 次数和应用的 T99 延迟。有几个信号可以判断参数是否合适Minor GC 频率不超过每秒 2 次单次 Minor GC 平均耗时不大于 20msFull GC 频率不高于每 10 分钟 1 次。如果超出这些范围说明参数还没调到点子上。另外强烈建议把 GC 日志接入你的监控体系。线上调优最怕的是“事后再分析”GC 日志文件保留时间有限等发现问题时日志已经滚动没了。用日志采集工具把 gc.log 实时收集到日志中心再做关键词告警才能避免重复踩坑。5. 常见问题与避坑技巧实录5.1 对象晋升失败与 HandlePromotionFailurepromotion failed是分代收集下最经典的问题之一。它发生在 Minor GC 过程中要晋升对象到老年代但老年代没有足够的连续空间时。这个错误虽然发生在 Minor GC 过程中结果却往往以一次 Full GC 收场。日志典型长这样[ParNew (promotion failed): 2097152K-2097152K(2359296K), 0.1534567 secs]晋升失败的根源有两类。第一种是老年代真的不够用了——-Xmx太小或者老年代占比太低。这种情况最简单直接把堆调大或调整新生代比例。第二种是老年代有空间但不是连续空间碎片化严重。CMS 收集器下碎片化尤其常见因为标记-清除天生会产生碎片。应对方案分三个层次排查是不是MaxTenuringThreshold设置过高导致一堆本该早晋升的对象堵在 Survivor 里来回复制拖到某一次 Minor GC 集体晋升瞬间撑爆老年代。检查SurvivorRatioSurvivor 太小会迫使存活对象提前晋升老年代被短期对象污染。这是最常见的原因。确认老年代收集是 CMS 时开启-XX:UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction比如 5 次 Full GC 后做一次碎片整理降低碎片影响。最重要的一条经验晋升失败从来不是单一参数的问题而是新生代、老年代、晋升阈值三个要素共同失配。调参时一定要整体看不要只改一个。5.2 动态年龄判定的“隐形坑”很多文章只提MaxTenuringThreshold但实际决定对象是否晋升的还有一条动态规则如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半年龄大于等于该年龄的对象就直接进入老年代无需等到最大阈值。这条规则的本意是当 Survivor 空间已经装不下足够多活对象时与其让它们反复复制浪费资源不如直接晋升到老年代。它是一个自我保护机制。但问题恰恰出在这里——如果你设置的MaxTenuringThreshold15以为对象会在新生代多呆一阵子实际上动态判定可能在第 3 次 GC 就把大部分对象赶到老年代。排查方法就是前面案例里用到的-XX:PrintTenuringDistribution。这个参数会输出每个年龄的对象占用字节数看到类似age 1: 19658712 bytes这种数据再对照 Survivor 区总容量你就能算出动态判定什么时候生效。我给你的实操建议是不要把晋升阈值设得太大尤其是 CMS 下默认只有 6。高分配速率的服务15 的阈值只会让对象在 Survivor 里反复复制更多次平白增加 Minor GC 耗时最后该晋升的还是要晋升。主流服务把阈值设在 5~8 之间往往就够了。关键是让短命对象有时间死掉而不是让它们赖在新生代里占用 Survivor 空间。5.3 分代收集在 G1 之后思路并未过时G1 成为默认收集器之后有不少人误以为“分代收集已经是老古董了”。其实 G1 还是分代的——它只是在实现上换了思路。G1 把整个堆划分成大小相等的 Region通过-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent动态决定新生代占用的 Region 数量新生代和老年代不再是一块连续的物理内存。ZGC 刚出来时确实做了不分代的设计因为它的回收阶段平均耗时极短不需要靠分代来压缩扫描范围。但有意思的是后来的 ZGC 在 JDK 21 里又加入了分代支持分代 ZGC原因很简单——大部分对象本来就是短命的你让长命对象和短命对象一起经过并发标记、迁移纯粹是浪费 CPU。分代思想绕了一圈又回来了。所以站在开发者的角度我的建议是不管你的 JDK 版本默认收集器是谁理解分代收集理论始终是分析一切 GC 问题的基础。无论是看 GC 日志里的PSYoungGen、ParOldGen还是分析堆转储里的对象分布你脑子的坐标系依然是新生代、老年代、存活区这三层。G1 的日志里依然有young、old的标记ZGC 分代版日志里也依然有young gen、old gen的概念。另外一个实操提醒JDK 8 升级到 JDK 11 或 17 时不要直接把旧的 GC 参数原样搬过去。G1 的很多参数名字跟 CMS 完全不同CMSInitiatingOccupancyFraction在 G1 下对应的是-XX:InitiatingHeapOccupancyPercent而-XX:MaxGCPauseMillis是 G1 特有的目标暂停时间参数。迁移之前先跑一轮对比压测收集器和参数一起评估而不是只换 JDK 版本。分代收集理论最核心的价值就是你用再多的现代垃圾回收器最后发现分析问题时的思考路径还是“对象活多久、该留在哪、什么时候晋升”。我自己排查过的绝大多数 GC 问题最终都落在 Survivor 空间不足、晋升阈值不当、老年代碎片化这三个坑里而这三个坑的底层逻辑全部来自分代设计。把这块基础打牢你在遇到 GC 相关问题时就不会再一头雾水而是能顺着日志一步步定位到真正的瓶颈。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门