ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM垃圾回收机制详解:从分代算法到GC调优实战

JVM垃圾回收机制详解:从分代算法到GC调优实战 1. 垃圾回收机制到底在解决什么问题很多人一提到 JVM 垃圾回收第一反应就是“自动管理内存、不用手动 free”这话没错但不够本质。做后端开发这么多年我越来越觉得垃圾回收本质上是在解决一个矛盾内存是稀缺资源而对象的生命周期是不确定的。你写一行new User()到底这个 User 对象什么时候可以被安全回收传统 C/C 里要程序员自己判断判断错了就是内存泄漏或者悬空指针。Java 的选择是把“判断对象是否可回收”这件事交给 JVM 统一做。但问题来了——JVM 怎么知道一个对象“没用”了怎么回收才不影响正在运行的程序怎么保证回收过程够快、停顿够短这一连串问题就是垃圾回收机制的全部核心。我们说“垃圾回收”是 JVM 自动做的但自动不等于没有代价。GC 发生时会占用 CPU、会移动对象、会暂停业务线程这些代价如果控制不好轻则接口超时重则整个服务雪崩。所以真正理解垃圾回收不只是为了面试背题而是为了在线上遇到 Full GC 频繁、CPU 飙高、内存溢出的时候能一眼看出问题出在哪知道该调什么参数、换什么回收器。这篇文章我会从内存分代布局讲起逐步拆解可达性分析、三种基本清除算法、常见垃圾回收器的工作机制最后落到参数调优和真实问题排查上。内容尽量用我实际踩坑的经历来讲少说虚的。2. 先搞懂 JVM 内存布局才知道垃圾是从哪来的2.1 堆内存的分代设计是理解 GC 的起点JVM 的堆内存默认是分代的一般分成新生代Young Generation和老年代Old Generation新生代里又细分为 Eden 区和两个 Survivor 区S0、S1。为什么要分代这是基于一个经过大量统计得出的经验法则大部分对象“朝生夕灭”活不过几次 GC。如果没有分代每次 GC 都要扫描整个堆成本极高。分代之后新生代里放短命对象用复制算法快速清理老年代里放长命对象用标记整理或标记清除算法处理。两个区域用不同策略整体效率就上来了。新生代默认占比是堆的 1/3老年代占 2/3。新生代里 Eden 和两个 Survivor 的默认比例是 8:1:1。也就是说你new出来的对象大部分先进 Eden 区Eden 满了触发 Minor GC存活对象被移到 Survivor 区。每熬过一次 Minor GC对象年龄加一默认到 15 岁就会晋升到老年代这个阈值可以通过-XX:MaxTenuringThreshold调整。注意对象晋升规则并不是只看年龄。如果 Survivor 区里相同年龄所有对象大小总和大于 Survivor 空间的一半年龄大于等于该值的对象也会直接晋升老年代这个叫动态年龄判定。我刚学 JVM 时有个误区以为 Eden 区满才触发 Minor GC。其实只要 Eden 区没有足够空间分配新对象就会触发 Minor GC。而且 Minor GC 的触发频率和停顿时间直接受 Eden 区大小影响后面调优时会重点说。2.2 方法区/元空间也是 GC 的管辖范围很多人以为 GC 只管堆其实方法区JDK 8 之后叫元空间Metaspace也有回收需求只是场景很少。元空间主要存类的元信息、常量池、静态变量等。当一个类加载器不再被引用它加载的类就应该被卸载对应的元空间内存也能被回收。但类卸载的前提很苛刻该类所有的实例都已被回收、加载该类的 ClassLoader 已被回收、该类对应的 java.lang.Class 对象没有任何地方引用。所以在框架热部署场景下如果频繁抛OutOfMemoryError: Metaspace大概率是类加载器泄漏而不是正常 GC 能解决的问题。2.3 直接内存不归堆管但会拖垮 GC除了堆内存JVM 还允许使用直接内存Direct Memory典型的就是 NIO 里的ByteBuffer.allocateDirect()。直接内存不受堆大小限制默认最大值等于-XX:MaxDirectMemorySize如果不设置取堆最大值。直接内存的回收依赖Cleaner机制它会在关联的堆对象被 GC 回收后触发 Cleaner 的clean()方法去释放直接内存。也就是说直接内存的释放是“间接依赖”堆 GC 的。如果你在代码里大量分配直接内存又忘了做池化复用很容易出现堆内存还挺宽裕但直接内存先把进程内存打满的情况表现就是进程直接被操作系统杀掉或者抛OutOfMemoryError: Direct buffer memory。2.4 对象什么时候真正“死亡”可达性分析GC 判断对象是否存活标准算法是可达性分析Reachability Analysis。思路是从一组称为 GC Roots 的根节点出发沿着引用链往下遍历能到达的对象就是存活对象不能到达的就是垃圾。那哪些对象能当 GC Roots主要有这么几类虚拟机栈栈帧中的本地变量表中引用的对象方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中 JNI 引用的对象线程存活状态对应的 Thread 对象等理解 GC Roots 特别重要因为很多内存泄漏的根源就是某个本该释放的对象被不经意地挂在了 GC Roots 上。比如一个静态集合一直在 add 数据这些数据永远能被静态变量引用到GC 永远不会清理它们时间一长就 OOM 了。这里还要提一下引用类型Java 提供了强引用、软引用、弱引用、虚引用四种。强引用就是普通的newGC 绝不回收软引用在内存不足时回收弱引用每次 GC 都回收虚引用主要用于跟踪对象的回收时机。在实际开发中软引用适合做缓存弱引用适合做规范化映射比如 WeakHashMap但要注意它们都不是“免死金牌”该 OOM 还是会 OOM。3. 三种基本回收算法每一种都是取舍3.1 标记-清除最简单的思路却有两个硬伤标记-清除算法分两步先遍历所有对象把可达对象标记出来再遍历整个区域把没标记的对象清掉。思路很直白但问题也直白碎片化。清除过的内存是不连续的大对象分配时找不到连续空间会提前触发 GC导致明明内存总量够用却因为碎片太多而性能下降。另一个问题是效率不稳定标记和清除都要全区域扫描对象越多越慢。所以现代垃圾回收器很少单独用它但它的变种思想仍然在 CMS 等回收器里应用只是配合了并发标记等手段。我在实际项目里看到过因为碎片化导致老年代空间充足、却反复 Full GC 的现象最后靠改用 G1 或开启-XX:UseCMSCompactAtFullCollectionCMS 时代才缓解。3.2 标记-复制堆内存不够就用空间换时间既然清除会产生碎片那干脆把存活对象搬运到另一块干净区域然后把原区域整体清空。这就是标记-复制算法把内存分成两块只使用其中一块。GC 时把存活对象复制到另一块再一次性清理原来的整块区域。复制算法没有碎片分配顺序连续效率很高特别适合“垃圾多、存活少”的新生代场景。代价就是内存利用率低因为始终有一块是空闲的。所以 HotSpot 设计出一个 Eden 两个 Survivor 的结构让幸存者区只占 10% 的空间而不是把堆一分为二。但复制对于“存活对象多”的老年代就不合适了一次 GC 要复制大量对象耗时和对象存活率成正比而且需要额外空间存放复制结果。所以老年代不能直接用复制算法。3.3 标记-整理老年代的妥协方案老年代对象存活率高又没有额外空间做复制就得用标记-整理。标记阶段和标记-清除一样但处理时不是简单地清掉垃圾而是把所有存活对象向内存一端移动然后直接清理掉边界以外的内存。好处是内存连续避免了碎片问题代价是移动对象需要更新引用如果老年代对象很多移动成本很高。CMS 曾经为了避免移动对象的停顿而使用标记-清除结果留下碎片反而在并发失败时触发更严重的 Full GC这个教训后文会细说。3.4 这几个算法和实际回收器的对应关系新手经常会问你讲半天算法我到底用得上吗其实所有垃圾回收器都是在这些算法之上做工程化改进。比如新生代回收器Serial、ParNew、Parallel Scavenge用的是复制算法老年代回收器Serial Old、Parallel Old用的是标记-整理CMS 老年代用的是标记-清除G1 局部看是复制整体看是标记-整理理解了这层对应关系后面看回收器行为就不会懵了。4. 主流的垃圾回收器各有各的脾气4.1 Serial / Serial Old单线程的“老古董”但并非一无是处Serial 回收器是 Client 模式下的默认新生代回收器单线程工作GC 时会暂停所有业务线程Stop The WorldSTW。单线程听起来很落后但它的优点是没有线程切换开销在单核 CPU 或内存很小的环境下效率反而比多线程回收器高。Serial Old 是它的老年代版本一般配合 Serial 使用。现在服务器基本都是多核多线程了Serial 很少单独出现在生产环境。但在某些极端场景——比如容器只有 1 核 CPU、内存 256MB——Serial 反而是最稳的选择。我在一个边缘网关设备上就遇到过一次JDK 8 默认回收器在 1 核环境下 GC 频繁抖动换回-XX:UseSerialGC后停顿还变小了。4.2 ParNew / CMS曾经的应用首选组合后来被替代ParNew 是 Serial 的多线程版本专门配合老年代的 CMS 使用。CMSConcurrent Mark Sweep是第一款真正意义上的并发回收器目标是减少 STW 时间它把 GC 过程拆成四个阶段初始标记只标记 GC Roots 直接引用的对象停顿短并发标记从 GC Roots 开始遍历对象图和业务线程并发执行重新标记修正并发标记期间因业务线程运行而变化的引用停顿略长并发清除清除垃圾和业务线程并发执行CMS 的并发设计让它在响应优先的应用里火了很多年但问题也很明显。一是并发清除阶段会占用 CPU对 CPU 核数敏感二是它会产生浮动垃圾并发阶段产生的垃圾只能留到下次 GC三是标记-清除带来的碎片问题四是并发模式失败后会退化为 Serial Old 做 Full GC停顿可能长达几十秒。在 JDK 8 时代绝大多数互联网后端默认是ParallelGC追求吞吐量而低延迟应用会用-XX:UseConcMarkSweepGC启用 CMS。后来 JDK 9 开始废弃 CMSJDK 14 正式移除现在新项目基本不会再考虑它了。4.3 Parallel Scavenge / Parallel Old吞吐量优先的“隐形冠军”Parallel 回收器的目标是吞吐量就是“GC 总耗时占比尽量低”。它支持一个关键参数-XX:GCTimeRation表示希望 GC 时间占整体时间的比例不超过 1/(1n)。默认 n99也就是 GC 占比不超过 1%。还有一个配合参数-XX:UseAdaptiveSizePolicy开启后 JVM 会自动调整 Eden、Survivor 大小和晋升阈值不需要你手动指定。很多人吐槽 Parallel 是“吞吐优先、响应拉胯”但严格说Parallel 的 Full GC 停顿在新老回收器对比中并不算最差。如果你跑的是离线任务、批处理、不需要太关注单次延迟Parallel 往往是最好的选择。我从 JDK 8 开始做线上调优最常用的就是 Parallel 调大新生代、降低 GC 频率效果立竿见影。G1 发布后大家一窝蜂切 G1但很多批处理服务在 G1 下表现反而没有 Parallel 好这事后文还会细说。4.4 G1把堆分成 Region 后一切都变了G1Garbage First从 JDK 7 开始实验JDK 9 后成为默认回收器。它的核心设计是放弃物理上的“新生代/老年代”分区把堆划分成很多个大小相等的 Region默认最多 2048 个每个 Region 大小是 1MB~32MB由堆大小决定。每个 Region 的逻辑角色可以动态变化新生代需要更多空间时就把空闲 Region 变成 Eden 或 Survivor老年代对象多时就用老年代 Region 存储。G1 还有一个重要的概念叫 Humongous Region专门存超过 Region 大小 50% 的大对象。G1 的回收策略叫“回收集合Collection SetCSet”它会优先回收垃圾最多的 Region所以叫 Garbage First。这带来一个优势你可以给 G1 设定一个 GC 停顿预测目标-XX:MaxGCPauseMillis默认 200msG1 会通过调整新生代大小、回收 Region 数量来尽量满足这个目标。但 G1 不是万能的。它维护 Remembered Set 来记录 Region 间的引用关系内存开销不小。如果应用对象分配速率特别高G1 需要频繁做 Young GC再加上 Mixed GC混合回收老年代 Region整体 CPU 开销可能比 Parallel 高。很多中小型应用从 Parallel 切到 G1 后吞吐量反而下降就是这个原因。4.5 ZGC / Shenandoah毫秒级停顿的未来方向ZGC 从 JDK 11 开始实验JDK 15 正式转正JDK 17 之后已经相当成熟。它的目标是“无论堆多大GC 停顿都不超过 10ms”核心手段是读屏障 染色指针。ZGC 大部分阶段都是并发执行的甚至连对象重定位都是并发的STW 时间几乎只停留在根节点扫描和少量同步阶段。Shenandoah 是 RedHat 主导的回收器设计思路和 ZGC 类似也是近乎并发地完成所有阶段。它和 ZGC 的差异主要在实现上ZGC 依赖染色指针Shenandoah 则依赖转发指针 读屏障。说实话对于绝大多数互联网应用堆内存没大到需要 ZGC 的地步时G1 就够用。但如果你的服务堆内存超过 32GB、暂停时间要求严格比如高频交易、大促系统ZGC 的收益会非常明显。我在压测一个 64GB 堆的服务时ZGC 的 P99 延迟比 G1 稳定很多GC 日志里基本看不到超过 20ms 的暂停。注意ZGC 需要 JDK 11且要留意显式 System.gc() 的处理早期版本 ZGC 不回收 ZGC 管理的堆外内存需要配合参数处理。5. 关键参数和 JVM 调优实战5.1 核心参数一次讲清楚JVM 调优参数的记忆和使用我建议你分两类一类是规定“内存布局”的一类是规定“回收器行为和目标”的。先看内存布局-Xms初始堆大小-Xmx最大堆大小-Xmn新生代大小-XX:NewRatio老年代和新生代比值默认 2 表示老年代是新生代 2 倍-XX:SurvivorRatioEden 和单个 Survivor 的比值默认 8-XX:MaxMetaspaceSize元空间最大值-XX:MaxDirectMemorySize直接内存最大值再看回收器-XX:UseSerialGCSerial Serial Old-XX:UseParNewGCParNew CMS需配合 UseConcMarkSweepGC-XX:UseConcMarkSweepGCCMS-XX:UseParallelGC/-XX:UseParallelOldGC吞吐量优先-XX:UseG1GCG1JDK 9 默认-XX:UseZGCZGCJDK 15还有几个通用参数-XX:MaxGCPauseMillisGC 目标停顿时间G1 和 Parallel 可参考-XX:GCTimeRatioGC 时间占比目标Parallel-XX:MaxTenuringThreshold对象晋升老年代年龄阈值-XX:PretenureSizeThreshold大于该值的对象直接在老年代分配避免在新生代反复复制还有一个容易被忽略的参数-XX:DisableExplicitGC。生产环境建议加上防止框架或业务代码里调用System.gc()触发不必要的 Full GC。但如果你在用 NIO 并依赖Cleaner释放直接内存禁用显式 GC 可能导致直接内存堆积需要评估后决定。5.2 调优第一步先定目标再动参数我见过太多人一上来就改一堆参数最后连问题是什么都没搞清楚。调优的正确姿势是先明确指标是吞吐量优先还是延迟优先参考标准一般是 GC 停顿时间、GC 频率、吞吐量占比。用工具量化现状jstat、jmap、GC 日志可视化工具等。基于现状推测瓶颈是新生代太小导致 Young GC 频繁是老年代增长太快导致 Full GC还是晋升过多、碎片导致空间浪费改一个参数验证一个阶段不要同时改多个变量。我举个例子。假设有个订单服务通过 jstat 看到 Young GC 每 2 秒一次每次停顿 30ms虽然停顿不大但频率太高接口 P99 被拖到 200ms 以上。排查后发现新生代只有 512MBEden 区更小新的订单对象大量写入 Eden很快就满。这时调大-Xmn到 2GBYoung GC 频率降到每 8 秒一次P99 立刻恢复到 80ms 以内。这个案例里没碰任何回收器参数问题就解决了。反过来如果你的问题是“Full GC 频率不高但一次就要 2 秒”那就需要看看老年代里到底堆了什么。用jmap -histo:live导出存活对象分布找到那些异常大的对象或集合优先从代码层面解决而不是盲目调堆。5.3 jstat 和 GC 日志是调优的眼睛调优不能靠猜一定要看数据。我最常用的三板斧jstat -gc pid 1000每秒打印一次 GC 状态能看到各个区的大小、已用空间、GC 次数和时间。jstat -gccause pid额外显示最近一次 GC 的原因。-Xlog:gc*JDK 9或者-XX:PrintGCDetails -XX:PrintGCDateStampsJDK 8打印 GC 详情。GC 日志里最关键的信息是GC 前和 GC 后各区域的空间占用、停顿时间、晋升对象大小。比如一条DefNew日志里有before-after(区大小), 0.0123456 secs你要看的是“本次 Minor GC 放了多少东西进去、又清掉了多少、占用了多长时间”。如果每次 Young GC 后 Survivor 区都接近满说明对象根本放不下容易提前晋升老年代。我有个习惯线上环境至少保留-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/app.hprof这样 OOM 时能自动导出堆快照后面排查泄漏就有依据。5.4 可视化工具JConsole、VisualVM 与 Arthas虽然命令台工具够用但可视化工具在分析波动趋势时更直观。JConsole 适合看堆内存、线程、CPU 的实时曲线VisualVM 别名 JVisualVM能看堆 dump、线程 dump还能装插件做 GC 分析。Arthas 是阿里开源的一款诊断工具我日常排查线上问题用得最多。它有dashboard可以看全局实时状态memory可以查各区域内存使用量heapdump可以直接导出堆快照jj命令能生成 GC 摘要。最方便的是它的trace和watch命令但调 GC 时我主要用memory和dashboard。另外一个偏冷门但好用的工具是jhsdb jmap --heap pid在 JDK 8 里可以查看堆布局和区域占比比 jmap 的某些老命令更稳定。5.5 一个实战调优案例从频繁 Full GC 到恢复正常我说一个以前处理过的真实案例帮助你把上面的知识串起来。服务是 Spring Boot 应用JDK 8堆配置-Xms4g -Xmx4g没设置新生代大小默认 ParallelGC。上线后运行了大概一周业务方反馈每天晚上有两次接口全部超时每次持续 10 多秒。先用jstat -gcutil pid 1000看发现老年代使用率在某个时间点从 20% 突然飙升到 95%然后发生 Full GC停顿 8~10 秒。连续观察两次确认是一批定时任务触发的老年代突然增长的原因不是内存泄漏而是一次性加载了大量数据。再查数据来源发现定时任务里有一个操作把一批大对象放入了一个静态 List处理完没有置空导致 List 一直被 GC Root 引用无法回收。修复方式很简单把静态 List 改为局部变量或者处理完后clear()。之后老年代使用率稳定在 30% 左右Full GC 偶尔才出现一次。这个案例里如果只看 GC 日志会发现 Full GC 停顿很长但真正的根因在业务代码。所以调参之前先确认是不是代码有问题否则你把堆调得再大也只是延缓 OOM 而已。6. 常见问题与排查技巧实录6.1 完整速查表遇到 GC 相关故障怎么下手这里我把日常排查中高频遇到的场景整理成一张速查表方便你对照排查现象可能原因优先排查动作Young GC 频率极高新生代太小或对象分配速率高jstat 查 Eden 使用率评估对象生命周期Full GC 频繁且老年代回收后使用率无明显下降大对象/缓存/集合未释放jmap 导出堆快照用 MAT 分析大对象Full GC 时间特别长老年代对象太多、回收器选择不当或堆过大调大堆/换 G1/ZGC观察 GC 日志老年代空间充足但触发 Full GC晋升对象过大、碎片化检查晋升阈值考虑开启并行整理或换回收器进程内存远大于堆内存直接内存/元空间/线程栈叠加用 pmap 排查进程内存分布检查 NIOGC 停顿短但接口延迟高锁竞争、CPU 争抢、IO 瓶颈先看线程 dump不要急着调 GCOOM 后堆 dump 很小不是堆问题可能是直接内存或线程创建过多检查操作系统日志和进程内存6.2 为什么回收不了“看上去没有引用”的对象很多新手用 jmap 找到一个大对象发现业务代码里已经置空了但堆快照显示该对象还在。原因基本是两类一类是其他线程的局部变量仍然持有引用另一类是通过 ThreadLocal、静态内部类等隐式引用链挂在 GC Roots 上。ThreadLocal 是最经典的内存泄漏源头。如果你在 Web 应用里用 ThreadLocal 存了一些大对象比如用户上下文但请求结束时没有remove()线程池里的线程复用时这些对象就一直被线程对象引用着。解决方式很简单用完必须 remove最好在 finally 里处理。还有一种情况是ThreadLocal的 key 是弱引用但 value 是强引用。所以即使 key 被回收了value 仍然被 Entry 里的强引用持有只要 Thread 存活value 就释放不了。这也是 ThreadLocal 内存泄漏被反复讨论的原因。6.3 GC 日志里那些缩写到底什么意思刚从 JDK 8 切到 JDK 9 的人第一次看新格式的 GC 日志会很懵。我举一段简化示例[0.123s][info][gc,start] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 60M-20M(100M) 2.123ms [0.214s][info][gc,phases] Pre Evacuate Collection Set: 0.1ms [0.215s][info][gc,phases] Evacuate Collection Set: 1.8ms [0.215s][info][gc,phases] Post Evacuate Collection Set: 0.2ms第一行GC(0) Pause Young (Normal)表示这是一次 G1 的年轻代回收60M-20M(100M)表示堆内存从使用 60MB 回收后变成 20MB总容量 100MB2.123ms是整个暂停时间。后面三行是各个阶段的耗时方便你定位卡在哪个环节。如果是 Full GC日志里会看到Pause Full (G1 Compaction Pause)这种暂停通常较长。遇到时要重点看 root scan 和 region 遍历的时间占比判断是不是存活对象特别多、引用链特别复杂。6.4 不要盲目照搬别人的“最佳参数”网上很多文章会发一套“调优参数模板”直接复制到生产。这种事我劝你少干。因为垃圾回收行为和应用的对象分配模式强相关同一个参数在订单系统里有效在数据仓库程序里可能就是灾难。举个例子-XX:MaxGCPauseMillis50对 G1 来说是一个目标值而非硬性保证。为了达到这个目标G1 可能大幅缩减新生代导致对象更容易晋升老年代反而增加 Full GC。相反把目标设得太宽松G1 又可能一次回收太多 Region导致停顿变长。合理做法是先观察实际 GC 停顿分布再微调目标值比如从 200ms 慢慢调到 150ms看 GC 频率和停顿的变化是否可接受。还有一点-Xms和-Xmx设置成一样的好处是避免堆动态扩缩容带来的额外开销但代价是进程启动即占满内存。在容器内存受限的环境里设置相同的初值和最大值可能会让容器过早被分配内存而影响其他进程。所以现在很多云原生部署反而会留一点点余量让 JVM 在必要时扩一下。6.5 容器环境下的 GC 隐患容器化部署已经是大趋势但 JVM 对容器内存的感知历史上有不少坑。JDK 8u191 之前JVM 默认拿宿主机内存来算堆默认值导致容器里没设-Xmx时可能直接占满宿主机内存引发宿主机 OOM Killer 干掉你的容器。解决方案有两个升级 JDK 到 8u191它会正确读取容器配额或者显式设置-Xmx并加上-XX:UseContainerSupportJDK 8u191 默认开启。使用容器时还要注意MaxRAMPercentage这一系列参数。你可以用-XX:MaxRAMPercentage75.0表示只使用容器内存的 75% 作为 JVM 堆上限而不是死板地写-Xmx4g这样在不同规格的容器间迁移时更灵活。同理-XX:InitialRAMPercentage和-XX:MinRAMPercentage也可以配合使用。容器 CPU 限制对 GC 的影响也很明显。如果容器只分配 2 个核JVM 默认会根据宿主机 CPU 数量创建 GC 线程可能导致 GC 线程调度开销大。这时要显式设置-XX:ParallelGCThreads和-XX:ConcGCThreads让 GC 线程数和容器 CPU 配额匹配。6.6 几个容易忽略的检查项最后再说几个我踩过的坑全是非常细节、但影响很大的检查项。一是-XX:UseAdaptiveSizePolicy和手动设置 SurvivorRatio 的冲突。如果你在 ParallelGC 下手动设置了-Xmn和 SurvivorRatio但又开启了自适应策略JVM 仍可能动态调整区域比例导致你的设置不生效。建议在明确要手动控制时关闭自适应参数。二是显式调用System.gc()。许多框架比如 RMI、NIO 相关组件会触发显式 GC尤其在老年代使用率高时System.gc()会执行 Full GC造成明显停顿。生产环境建议开启-XX:DisableExplicitGC但要评估直接内存释放的影响。三是大对象的分配。G1 里创建超大数组或大对象会直接分配到 Humongous Region且不会在 Young GC 中移动如果连续创建很容易造成堆空间碎片和 Full GC。代码里要尽量避免一次性申请过大的 byte[] 或 List。如果你发现 G1 的 Humongous 分配频繁优先审查代码而不是调 GC 参数。四是线程栈大小。-Xss设置不当会导致每个线程占用太多内存大量线程创建时吃掉堆外内存最终触发无法分配的 OOM。服务器端线程量大的应用建议调小-Xss比如 512k但要注意过小的栈可能触发 StackOverflowError要根据实际调用深度评估。五是 Swap 的影响。生产服务器如果开启了 SwapJVM 的堆内存被换出到磁盘后GC 扫描和对象访问会变得极慢你会看到 GC 日志里停顿时间并不长但接口响应极慢。这种情况用 jstat 看不出来需要查看系统 swap 使用率并避免过度使用内存。7. 我的个人经验总结与扩展建议做了这么多年 JVM 相关的问题排查我最大的体会就是垃圾回收机制不是一门背书的学问而是一个“内存理解 业务分析 工具使用”的综合能力。很多人一看到 GC 频繁就急着加内存、换回收器其实大多数问题都出在业务代码的对象生命周期管理上。先把代码里隐含的引用理清楚再谈参数调整顺序不能反。如果看完这篇你想动手实践我建议按这个路径走先在本机写一个简单的循环创建对象程序开PrintGCDetails把 GC 日志打出来对照日志里的区域变化把新生代、老年代、晋升这几个概念真正“看明白”然后写一个模拟内存泄漏的程序比如不断往静态 List 里塞数据用 jmap 导出堆快照自己用 MAT 或 VisualVM 找到泄漏点最后再去调回收器参数对比不同参数下的停顿时间和吞吐量。后续你还可以往这几个方向深入对象分配与逃逸分析JIT 编译对 GC 的影响大页内存对 GC 停顿的改善以及在微服务场景下怎么统一收集和分析 Prometheus 暴露出来的 GC 指标。这些内容每一个都够再写几篇文章但基石还是你对垃圾回收机制本身的掌握。先把这篇文章里的内容吃透后面学习任何高级特性都会顺畅很多。
返回列表