拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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 提供了强引用、软引用、弱引用、虚引用四种。强引用就是普通的new,GC 绝不回收;软引用在内存不足时回收;弱引用每次 GC 都回收;虚引用主要用于跟踪对象的回收时机。在实际开发中,软引用适合做缓存,弱引用适合做规范化映射(比如 WeakHashMap),但要注意它们都不是“免死金牌”,该 OOM 还是会 OOM。

3. 三种基本回收算法,每一种都是取舍

3.1 标记-清除:最简单的思路,却有两个硬伤

标记-清除算法分两步:先遍历所有对象,把可达对象标记出来;再遍历整个区域,把没标记的对象清掉。

思路很直白,但问题也直白:碎片化。清除过的内存是不连续的,大对象分配时找不到连续空间,会提前触发 GC,导致明明内存总量够用,却因为碎片太多而性能下降。另一个问题是效率不稳定,标记和清除都要全区域扫描,对象越多越慢。

所以现代垃圾回收器很少单独用它,但它的变种思想仍然在 CMS 等回收器里应用,只是配合了并发标记等手段。我在实际项目里看到过因为碎片化导致老年代空间充足、却反复 Full GC 的现象,最后靠改用 G1 或开启-XX:+UseCMSCompactAtFullCollection(CMS 时代)才缓解。

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 World,STW)。单线程听起来很落后,但它的优点是没有线程切换开销,在单核 CPU 或内存很小的环境下,效率反而比多线程回收器高。Serial Old 是它的老年代版本,一般配合 Serial 使用。

现在服务器基本都是多核多线程了,Serial 很少单独出现在生产环境。但在某些极端场景——比如容器只有 1 核 CPU、内存 256MB——Serial 反而是最稳的选择。我在一个边缘网关设备上就遇到过一次,JDK 8 默认回收器在 1 核环境下 GC 频繁抖动,换回-XX:+UseSerialGC后停顿还变小了。

4.2 ParNew / CMS:曾经的应用首选组合,后来被替代

ParNew 是 Serial 的多线程版本,专门配合老年代的 CMS 使用。CMS(Concurrent Mark Sweep)是第一款真正意义上的并发回收器,目标是减少 STW 时间,它把 GC 过程拆成四个阶段:

  • 初始标记:只标记 GC Roots 直接引用的对象,停顿短
  • 并发标记:从 GC Roots 开始遍历对象图,和业务线程并发执行
  • 重新标记:修正并发标记期间因业务线程运行而变化的引用,停顿略长
  • 并发清除:清除垃圾,和业务线程并发执行

CMS 的并发设计让它在响应优先的应用里火了很多年,但问题也很明显。一是并发清除阶段会占用 CPU,对 CPU 核数敏感;二是它会产生浮动垃圾,并发阶段产生的垃圾只能留到下次 GC;三是标记-清除带来的碎片问题;四是并发模式失败后,会退化为 Serial Old 做 Full GC,停顿可能长达几十秒。

在 JDK 8 时代,绝大多数互联网后端默认是ParallelGC,追求吞吐量;而低延迟应用会用-XX:+UseConcMarkSweepGC启用 CMS。后来 JDK 9 开始废弃 CMS,JDK 14 正式移除,现在新项目基本不会再考虑它了。

4.3 Parallel Scavenge / Parallel Old:吞吐量优先的“隐形冠军”

Parallel 回收器的目标是吞吐量,就是“GC 总耗时占比尽量低”。它支持一个关键参数:-XX:GCTimeRatio=n,表示希望 GC 时间占整体时间的比例不超过 1/(1+n)。默认 n=99,也就是 GC 占比不超过 1%。

还有一个配合参数-XX:UseAdaptiveSizePolicy,开启后 JVM 会自动调整 Eden、Survivor 大小和晋升阈值,不需要你手动指定。很多人吐槽 Parallel 是“吞吐优先、响应拉胯”,但严格说,Parallel 的 Full GC 停顿在新老回收器对比中并不算最差。如果你跑的是离线任务、批处理、不需要太关注单次延迟,Parallel 往往是最好的选择。

我从 JDK 8 开始做线上调优,最常用的就是 Parallel + 调大新生代、降低 GC 频率,效果立竿见影。G1 发布后大家一窝蜂切 G1,但很多批处理服务在 G1 下表现反而没有 Parallel 好,这事后文还会细说。

4.4 G1:把堆分成 Region 后,一切都变了

G1(Garbage First)从 JDK 7 开始实验,JDK 9 后成为默认回收器。它的核心设计是放弃物理上的“新生代/老年代”分区,把堆划分成很多个大小相等的 Region(默认最多 2048 个,每个 Region 大小是 1MB~32MB,由堆大小决定)。

每个 Region 的逻辑角色可以动态变化:新生代需要更多空间时,就把空闲 Region 变成 Eden 或 Survivor;老年代对象多时,就用老年代 Region 存储。G1 还有一个重要的概念叫 Humongous Region,专门存超过 Region 大小 50% 的大对象。

G1 的回收策略叫“回收集合(Collection Set,CSet)”,它会优先回收垃圾最多的 Region,所以叫 Garbage First。这带来一个优势:你可以给 G1 设定一个 GC 停顿预测目标-XX:MaxGCPauseMillis,默认 200ms,G1 会通过调整新生代大小、回收 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:SurvivorRatio:Eden 和单个 Survivor 的比值,默认 8
  • -XX:MaxMetaspaceSize:元空间最大值
  • -XX:MaxDirectMemorySize:直接内存最大值

再看回收器:

  • -XX:+UseSerialGC:Serial + Serial Old
  • -XX:+UseParNewGC:ParNew + CMS(需配合 UseConcMarkSweepGC)
  • -XX:+UseConcMarkSweepGC:CMS
  • -XX:+UseParallelGC/-XX:+UseParallelOldGC:吞吐量优先
  • -XX:+UseG1GC:G1,JDK 9+ 默认
  • -XX:+UseZGC:ZGC,JDK 15+

还有几个通用参数:

  • -XX:MaxGCPauseMillis:GC 目标停顿时间(G1 和 Parallel 可参考)
  • -XX:GCTimeRatio:GC 时间占比目标(Parallel)
  • -XX:MaxTenuringThreshold:对象晋升老年代年龄阈值
  • -XX:PretenureSizeThreshold:大于该值的对象直接在老年代分配,避免在新生代反复复制

还有一个容易被忽略的参数:-XX:+DisableExplicitGC。生产环境建议加上,防止框架或业务代码里调用System.gc()触发不必要的 Full GC。但如果你在用 NIO 并依赖Cleaner释放直接内存,禁用显式 GC 可能导致直接内存堆积,需要评估后决定。

5.2 调优第一步:先定目标,再动参数

我见过太多人一上来就改一堆参数,最后连问题是什么都没搞清楚。调优的正确姿势是:

  1. 先明确指标:是吞吐量优先,还是延迟优先?参考标准一般是 GC 停顿时间、GC 频率、吞吐量占比。
  2. 用工具量化现状:jstat、jmap、GC 日志可视化工具等。
  3. 基于现状推测瓶颈:是新生代太小导致 Young GC 频繁?是老年代增长太快导致 Full GC?还是晋升过多、碎片导致空间浪费?
  4. 改一个参数,验证一个阶段,不要同时改多个变量。

我举个例子。假设有个订单服务,通过 jstat 看到 Young GC 每 2 秒一次,每次停顿 30ms,虽然停顿不大但频率太高,接口 P99 被拖到 200ms 以上。排查后发现新生代只有 512MB,Eden 区更小,新的订单对象大量写入 Eden,很快就满。这时调大-Xmn到 2GB,Young 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:+PrintGCDateStamps(JDK 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 排查进程内存分布,检查 NIO
GC 停顿短但接口延迟高锁竞争、CPU 争抢、IO 瓶颈先看线程 dump,不要急着调 GC
OOM 后堆 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,总容量 100MB,2.123ms是整个暂停时间。后面三行是各个阶段的耗时,方便你定位卡在哪个环节。

如果是 Full GC,日志里会看到Pause Full (G1 Compaction Pause),这种暂停通常较长。遇到时要重点看 root scan 和 region 遍历的时间占比,判断是不是存活对象特别多、引用链特别复杂。

6.4 不要盲目照搬别人的“最佳参数”

网上很多文章会发一套“调优参数模板”,直接复制到生产。这种事我劝你少干。因为垃圾回收行为和应用的对象分配模式强相关,同一个参数在订单系统里有效,在数据仓库程序里可能就是灾难。

举个例子,-XX:MaxGCPauseMillis=50对 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:+UseContainerSupport(JDK 8u191+ 默认开启)。

使用容器时还要注意MaxRAMPercentage这一系列参数。你可以用-XX:MaxRAMPercentage=75.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 的影响。生产服务器如果开启了 Swap,JVM 的堆内存被换出到磁盘后,GC 扫描和对象访问会变得极慢,你会看到 GC 日志里停顿时间并不长,但接口响应极慢。这种情况用 jstat 看不出来,需要查看系统 swap 使用率并避免过度使用内存。

7. 我的个人经验总结与扩展建议

做了这么多年 JVM 相关的问题排查,我最大的体会就是:垃圾回收机制不是一门背书的学问,而是一个“内存理解 + 业务分析 + 工具使用”的综合能力。很多人一看到 GC 频繁就急着加内存、换回收器,其实大多数问题都出在业务代码的对象生命周期管理上。先把代码里隐含的引用理清楚,再谈参数调整,顺序不能反。

如果看完这篇你想动手实践,我建议按这个路径走:先在本机写一个简单的循环创建对象程序,开PrintGCDetails把 GC 日志打出来,对照日志里的区域变化,把新生代、老年代、晋升这几个概念真正“看明白”;然后写一个模拟内存泄漏的程序(比如不断往静态 List 里塞数据),用 jmap 导出堆快照,自己用 MAT 或 VisualVM 找到泄漏点;最后再去调回收器参数,对比不同参数下的停顿时间和吞吐量。

后续你还可以往这几个方向深入:对象分配与逃逸分析,JIT 编译对 GC 的影响,大页内存对 GC 停顿的改善,以及在微服务场景下怎么统一收集和分析 Prometheus 暴露出来的 GC 指标。这些内容每一个都够再写几篇文章,但基石还是你对垃圾回收机制本身的掌握。先把这篇文章里的内容吃透,后面学习任何高级特性都会顺畅很多。

返回列表