很多刚接触 JVM 的同学,一听到“GC 调优”这四个字,脑子里先浮现的是一排排冷冰冰的参数和复杂的日志,觉得那是资深架构师才配碰的东西。但我在实际带实习生的时候发现,真正困扰大家的往往不是某个参数记不住,而是不知道 GC 到底在干什么、调优是调什么。我排查过不少线上问题,也带人复现过各种经典场景,这里想用一篇文章把 JVM 内存模型、GC 原理、常见收集器、调优工具和面试考点串成一条完整链路,帮你在面试和工作中都能说出个所以然来。
JVM(Java Virtual Machine,Java 虚拟机)是 Java 程序的运行基石,所有 Java 开发者都要跟它的内存管理打交道。GC 调优(Garbage Collection 调优)就是从内存回收的角度,让程序的停顿时间、吞吐量和内存占用达到相对合理状态。这篇文章面向的正是 Java 实习生、刚转岗做 Java 开发的新人,也适合想系统复习 GC 相关面试题的候选人。全文不搞玄学,先讲原理,再给工具和步骤,最后落到生产环境常见问题的排查。
1. 内存模型:先搞清楚“对象住在哪”,再谈回收
1.1 堆内存的分代结构与默认比例
一上来就讲垃圾回收算法,很多实习生会卡住,因为脑子里没有“内存长什么样”的地图。JVM 管理的内存中,堆(Heap)是最大的一块,也是 GC 最主要的舞台。Java 里绝大多数对象都分配在堆上,调优时你动的 -Xmx、-Xms 改的就是这块区域。
堆不是铁板一块,而是“分代”的。HotSpot 默认把堆分成新生代(Young Generation)和老年代(Old Generation),新生代内部又分为 Eden 区和两个 Survivor 区(通常叫 S0、S1,或者 From、To)。为什么要分?因为大多数对象“朝生夕死”,统计上 80% 到 90% 的临时对象活不过第一轮 GC。既然大部分对象活不长,就把它们集中放在一块地方,用复制算法快速清理;少数能活过多次回收的对象,再晋升到老年代做长期管理。这就是“弱分代假说”在工程上的落地。
默认比例也是面试常问的点。以 HotSpot 的默认参数来看,-XX:NewRatio 默认是 2,意思是老年代与新生代的比例为 2:1,也就是新生代约占堆的 1/3。在新生代内部,Eden 与两个 Survivor 的比例默认是 8:1:1,可以通过 -XX:SurvivorRatio=8 明确指定。这里有一个常见误解:有的同学以为三个区比例固定不变,其实 JVM 会根据运行情况动态调整,比如开启 -XX:+UseAdaptiveSizePolicy 后,Survivor 大小会由 JVM 自己调整,你看到 jstat 输出里的数值和平常理解的比例不一致,通常就是这个原因。
1.2 栈、元空间、直接内存:三个容易被忽略的地方
GC 调优不能只盯着堆。我见过不少线上事故,现象是 Full GC 频繁,结果查了半天才发现问题出在堆外。
先说虚拟机栈(VM Stack)。每个线程创建时都会分配一个栈,栈里放的是局部变量、操作数栈和栈帧信息。栈帧还记录着对象引用,这些引用是 GC Roots 的重要来源。栈大小默认是 1MB(不同系统可能有差异),可以通过 -Xss 调整。如果递归过深或局部变量超大,就可能抛 StackOverflowError。这个区域不需要 GC 操心,线程结束就释放了。
再说元空间(Metaspace)。JDK 8 之后,类的元数据放到了本地内存中,不再像以前一样放在堆内的 PermGen。好处是减少了字符串常量池移出堆带来的整堆不断溢出的问题,坏处是元空间用量不受堆大小控制,而是受操作系统可用内存限制。如果你在代码里不停动态生成类(CGLib、反射、ASM),元空间可能会悄悄涨满,抛 OutOfMemoryError: Metaspace。排查时要去看 -XX:MaxMetaspaceSize 配置,以及类加载器的使用情况。
还有一块很容易被忽略的是直接内存(Direct Memory)。NIO 的 ByteBuffer.allocateDirect() 会从堆外分配内存,它不占用堆空间,但受 -XX:MaxDirectMemorySize 限制。直接内存的回收依赖 GC 时的 Cleaner 机制,如果大量使用堆外 buffer 且没有及时释放,会出现“堆明明很空,进程却内存爆掉”的诡异现象。这类问题用 jmap 看堆是看不出来的,需要用操作系统层面的工具观察进程内存占用变化。
1.3 常见内存异常速查表
遇到内存相关异常,先别慌,对照表现缩小范围会快很多。这里整理一张我平时排查时常用的表:
| 异常/现象 | 可能原因 | 排查入口 |
|---|---|---|
| StackOverflowError | 递归过深、线程栈设置太小 | -Xss 调整,检查递归代码逻辑 |
| java.lang.OutOfMemoryError: Java heap space | 堆内存不足,或存在内存泄漏 | jmap dump + MAT,查大对象 |
| java.lang.OutOfMemoryError: Metaspace | 动态生成类、类加载器泄漏 | -XX:MaxMetaspaceSize,查看类加载统计 |
| java.lang.OutOfMemoryError: Direct buffer memory | 堆外缓冲区泄漏、未释放 | 检查 NIO 代码,观察 native 内存变化 |
| java.lang.OutOfMemoryError: Unable to create new native thread | 线程数超过操作系统限制 | ulimit、-Xss、排查线程泄漏 |
这张表只能帮你定位方向,真正想搞清楚“是泄漏还是峰值”,还是要做堆转储分析。第 5 节我会把从现场保留到定位根因的完整排查流程展开讲,那部分才是生产环境里最值钱的经验。
2. GC 原理:垃圾收集器是怎么判断和回收的
2.1 可达性分析:从 GC Roots 出发找“活”对象
判断一个对象是否该回收,最常见的错误答案是“看还有没有引用指向它”。引用计数法看着合理,但解决不了循环引用问题——A 引用 B,B 引用 A,外部已经没人用它们了,计数却永远不为 0。生产环境主流的 HotSpot 不用引用计数,而是用可达性分析(Reachability Analysis)。
可达性分析的思路很直接:从一组被称为 GC Roots 的起点出发,沿着对象引用链遍历整个对象图。凡是能从 GC Roots 出发到达的对象,都算“活的”,它们会被保留;其余没有到达路径的对象,一律是垃圾。过程中的核心问题是“GC Roots 包括哪些”,面试也爱问,主要包括:虚拟机栈和本地方法栈中引用的对象、方法区中类静态属性引用的对象、常量引用的对象、JNI 引用、运行中的线程等。
理解这一点对排查内存泄漏特别重要。当你看到一个对象迟迟不被回收,你要问的不是“它有没有引用”,而是“还有谁在引用它,这个引用链的源头是不是某个 GC Root”。很多泄漏其实就是某个生命周期很长的对象,把本不该保留的东西一直串在引用链上。比如一个 static 的 HashMap,map 里放的所有对象,都会因为“static 字段是 GC Root”这个关系而被永久视为存活,哪怕业务上已经不需要了。
2.2 三种基础回收算法,以及为什么只靠一种不够
可达性分析解决了“哪些是垃圾”的问题,接着要考虑“怎么回收”。基础算法就三种,理解了它们,后面所有收集器的行为就都好懂了。
复制算法(Copying):把内存分成两块,每次只使用其中一块。GC 时把存活对象复制到另一块空内存中,然后一次性清空当前整块区域。实现简单、无碎片,但代价是空间利用率低。新生代里用这种思路,只不过因为对象死亡率高,不用 1:1 对半分,而是用 Eden 加两块较小的 Survivor,存活对象从 Eden 复制到 Survivor,空间浪费被控制得很低。
标记-清除算法(Mark-Sweep):先标记所有可达对象,再清除不可达对象。能用在老年代,但致命问题是产生大量内存碎片。你想想看,内存被割成一堆小窟窿,后来想分配一个大对象却找不到连续空间,只能再触发一次 GC,恶性循环。
标记-整理算法(Mark-Compact):标记阶段和标记-清除一样,但后续会把存活对象向内存一端移动,然后清理边界以外的空间。解决了碎片问题,代价是移动对象需要更新所有引用,停顿更长。
你仔细琢磨就会发现,没有哪种算法是十全十美的。新生代对象死得多,适合复制;老年代对象存活率高,复制成本太大,适合标记-清除或标记-整理。分代收集其实就是把这些算法组合起来使用,这也是为什么我们不能只看单一算法,而要看收集器怎么组合调度。
2.3 Minor GC、Major GC、Full GC 的触发条件
“这一步能跑,下一步就卡”是很多实习生对 GC 的直观感受。要理解卡顿在哪,得知道各种 GC 的触发时点。
新生代空间不够放新对象时,触发 Minor GC / Young GC。Eden 区里大部分对象会被回收,少数活下来的对象经过一次 GC 会进入 Survivor 区,在 Survivor 之间来回拷贝,每熬过一次 GC,年龄就加一。默认 -XX:MaxTenuringThreshold=15,也就是活了 15 次还没死,就晋升到老年代。同时 JVM 还有动态年龄判定机制:如果 Survivor 中相同年龄对象的总和超过一半,就把这批对象直接晋升,不一定非熬到 15 次。
老年代空间不足时,会触发 Major GC 或直接 Full GC。Full GC 是整个堆加方法区/元空间的回收,停顿通常最明显。触发 Full GC 的典型路径有四种:老年代空间不够;元空间不足;调用 System.gc() 且未开启 -XX:+DisableExplicitGC;G1 或 CMS 的并发回收失败,退化成 Serial Old 的 Full GC。
这里想提醒一点:别看到 Full GC 就觉得调优任务来了。有些业务高峰期对象吞吐量就是很大,Full GC 频率高但单次停顿短,可能并不需要动;真正要警惕的是 Full GC 频率和单次时长同时走高,那多半是堆里堆积了大量本不该存活的对象,也就是泄漏或者缓存失控。
3. 主流垃圾收集器选型:不盲目追新
3.1 Parallel、CMS、G1 的特点与适用场景
垃圾收集器本质上是对“吞吐量、停顿时间、内存占用、实现复杂度”这四个维度的取舍。在读过面经、背过八股之后,你需要建立一套自己的选型判断依据,而不是别人说哪个新就上哪个。
Parallel Scavenge + Parallel Old 是 JDK 8 默认组合,目标就是吞吐量最大化。它很适合后台批处理、离线计算这类允许停顿、但希望单位时间干活多的场景。对多数在线 Web 服务来说,它并不是最优选择,因为停顿时间不可控。如果你维护的是老项目,JDK 8 默认就是它,先别急着换,得看业务指标再决定。
CMS(Concurrent Mark Sweep)曾经是低延迟场景的明星,目标就是尽量减少停顿,但它采用标记-清除,会产生碎片,且并发阶段占用 CPU,CPU 资源紧张时反而拉垮性能。JDK 9 开始标记废弃,JDK 14 正式移除。如果还在用 JDK 8 维护老项目,可能会碰到它,但要清楚它已经被历史淘汰,新项目没必要再选。
G1(Garbage First)是 JDK 9 之后 HotSpot 的默认收集器。它把堆切分成一个个大小相等的 Region,不再严格区分物理连续的年轻代和老年代,而是逻辑上的分代。G1 有一个明确的设计目标:在可控停顿时间范围内尽可能提高吞吐。它维护了一个停顿预测模型,通过 -XX:MaxGCPauseMillis(默认 200ms)来引导回收节奏。G1 的另一个优势是能优先回收垃圾最多的 Region(Garbage First),所以比全堆扫描要聪明一些。
3.2 和选型直接相关的几个关键参数
用 G1 时,我一般不只靠默认参数,至少要理解几个关键参数的作用,这是生产排障的基本功。
-XX:G1HeapRegionSize:Region 大小,默认会根据堆大小自动计算,范围一般从 1MB 到 32MB。Region 切得太大,Region 数量少,粒度粗;切得太小,Region 数量多,管理结构本身会占内存。不建议频繁手动改,但如果你发现大对象很多,默认 Region 偏小,导致分配大对象时直接进入 Humongous 区,一连串回收被触发,这时候就要考虑调大 Region。
-XX:InitiatingHeapOccupancyPercent,简称 IHOP,默认 45%。它指的是老年代占用达到整个堆容量多少百分比时,G1 会启动并发标记周期。调小,GC 更激进,停顿更分散但频繁;调大,老年代堆积风险上升,可能出现并发标记来不及,最后退化 Full GC。网上有人建议调到 60% 减少 GC 次数,我见过照做之后反而 Full GC 更频繁的案例。所以没有压测数据支撑,默认值往往更可靠。
-XX:MaxGCPauseMillis:停顿目标。注意它是“目标”不是“保证”,G1 会尽量满足,但如果堆已经快满了或者对象分配速率很高,它还是会突破目标。我遇到过把目标设为 100ms、实际平均 250ms 的服务,压测后发现是一次性往列表里塞了太多数据,这本质是代码问题,不是参数问题。调参只能缓解,不能根治。
3.3 什么时候才考虑 ZGC 这类低延迟收集器
ZGC(JDK 11 引入,JDK 15 转正)和 Shenandoah 的目标是把 GC 停顿压缩到毫秒甚至亚毫秒级。它们用染色指针、读屏障等技术实现了并发整理,能在大堆(几百 GB 到 TB 级)下依然保持低停顿。
但我并不是见着低延迟就推荐。ZGC 的代价也很实在:它更多依赖 CPU 资源做并发处理,吞吐量会有所下降;对内存的占用也比传统收集器高;而且它在一部分国产 JDK 和老版本操作系统上的兼容性需要额外验证。如果你们的服务是响应时间极度敏感、堆内存很大、CPU 还有余量的场景,比如大型网关、高频核心交易系统,ZGC 才值得放进选型范围。普通中小项目,G1 先把默认参数吃透,已经能解决 80% 的问题。说实话,实习生面试遇到“你会选哪个收集器”,能说清楚 G1 和 ZGC 的取舍背景,比报一个新版本号要加分得多。
3.4 收集器选型判断路径
我自己的判断路径大致是:先确认 JDK 版本,看默认收集器是谁;再确认业务对停顿的容忍度,是做批处理还是在线服务;再看堆大小和机器配置,堆特别大、停顿要求特别高,才有必要看 ZGC 系。最后记得,任何选型都要拿到 GC 日志和监控数据去验证,而不是换上去就完事。选型判断里的常见误区,是把网上某篇文章的收集器配置硬套到自己业务上。比如明明是一个 512MB 堆的小服务,非要上 G1 的复杂参数,收益微乎其微,反而增加了排查难度。收集器也是工具,工具要适配场景,不是越先进越好。
4. 调优之前先测量:从数据到参数的实战路径
4.1 先定调优目标,再动手改参数
这一节是整个生产环境实战里最重要的一环。很多新手调优失败,原因不是不懂参数,而是根本不知道自己要调成什么样。调优目标大致分三类:
吞吐量优先:适合批处理任务。可以适当放宽停顿时间,追求单位时间内完成更多任务,常见思路是加大堆空间、减少 GC 次数。
停顿时间优先:适合在线交互系统。希望每次 GC 停顿尽量短,避免接口超时,常见思路是用 G1 或 ZGC,并精细控制触发时机。
内存占用优先:在容器化环境里很普遍。机器内存有限,堆不能给太大,那就要接受 GC 频率上升的代价,调优目标变成“在 512MB 或 1GB 堆内跑稳服务”。
定目标的同时,还要明确一个底线:调优不能把业务稳定性调崩了。凡是改动,都要有监控、有回滚预案。我自己习惯先改个相对保守的值,观察半天,再迭代,绝不一次把多个参数全调了,否则出了问题根本分不清是哪个改动引起的。
4.2 用 JDK 自带工具收集第一手数据
动手调参数之前,先回答几个问题:当前堆用了多少?对象晋升速率是多少?每次 GC 停顿多久?Minor GC 和 Full GC 各发生多少次?下面是我最常用的命令组合。
先用 jps 列出 Java 进程,拿到 PID。这步很简单,但很容易被忽略。多实例部署时,得先确认你面对的是哪个进程,拉错进程的操作我见得太多了。
然后用 jstat -gcutil 1000 每秒输出一次堆各区域使用率和 GC 时间汇总。重点看 E、O、M、FGC 列:Eden 占用率、老年代占用率、元空间占用率、Full GC 次数。如果老年代持续上升而不回落,多半是对象晋升后没有被正常释放。
再用 jcmd GC.heap_info 或 jmap -heap 查看更详细的堆配置和各代容量。JDK 8 里 jmap 还常用,JDK 更高版本有些命令被 jcmd 合并了,所以建议顺手把 jcmd 用起来,后面排查时总能用上。
想记录长期趋势,生产环境一定要开 GC 日志。JDK 8 及以前的写法是 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log;JDK 9 之后统一成了 -Xlog:gc*:file=/path/gc.log。日志里能直接看到每次 GC 前后的堆占用、停顿时长和 Cause。没有日志的线上 JVM,出了问题跟裸奔一样,这是我一直强调的底线。
4.3 基础参数怎么给:一个可直接落地的启动参数模板
给实习生一个可以直接抄的起点模板,我常用的大致长这样:
-Xms4g -Xmx4g -Xmn1g -Xss512k \ -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \ -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \ -Xlog:gc*:file=/export/logs/gc.log:time,uptime,level,tags \ -Xlog:gc:file=/export/logs/gc.log:time,uptime:filecount=5,filesize=50m \ -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/export/logs/oom.dump参数不多,解释一下我的习惯:-Xms 和 -Xmx 设为相同值,这样避免堆频繁伸缩带来的性能抖动,也方便统计真实堆使用率。-Xmn 是新生代大小,比例我会参考 NewRatio 默认给的 1/3,再结合观察到的对象分配速率微调。-Xss 512k 对绝大多数业务线程够用,可以有效降低线程数很多时的内存压力。-XX:+HeapDumpOnOutOfMemoryError 务必要配上,它能在 OOM 时自动导出堆转储,保存的 dump 文件是之后排查泄漏的关键证据。
另外提醒一句:网上很多文章会把 CMS 的参数模板直接扔给你,别乱抄。不同 JDK 版本默认行为差异很大,比如 JDK 8 默认 Parallel,JDK 11 默认 G1,JDK 17 之后 G1 仍是默认。照着不匹配版本的模板抄,大概率会踩坑。
4.4 一个真实可复现的调优案例
我遇到过一次典型的 Full GC 频发问题。服务用 JDK 8,堆 4GB,平时还算平稳,晚高峰前开始频繁 Full GC,每次老年代回收后占用依然有 90% 以上。先看 jstat,老年代曲线明显是一条逐步上升的直线,回不到低水位。我判断对象晋升速度快且没有被释放,大概率是缓存类数据堆积。
当时先用 jcmd 查看堆上对象统计,发现有一个缓存 Map 占用了近 2GB,而且 key 是无界增长的用户维度数据。项目里用的本地缓存没有设置过期策略,也没有容量上限。修复方案分两步:先给缓存加上 TTL 和最大条目数,再做一次老年代清理验证。改完之后 Full GC 从每几分钟一次降到几乎为零,接口 TP99 也明显下降。这个案例里没有用到任何炫技参数,核心动作反而是“找到谁在持续堆积”并及时止损。所以实战调优,真的是“定位问题”比“改参数”重要。
5. 内存泄漏与 OOM 排查:从现象到根因的完整链路
5.1 一次 OOM 的真实排查链路
内存泄漏在生产环境里最典型的症状就是:堆内存越涨越高,GC 越来越频繁,最终进程 OOM 或被监控重启。很多实习生第一次遇到 OOM 会直接慌,我建议你养成一套固定肌肉记忆。
第一步,保留现场。如果 JVM 已经配上 -XX:+HeapDumpOnOutOfMemoryError,OOM 时会自动生成 dump 文件;如果还没配置,且进程还活着,可以用 jmap -dump:format=b,file=heap.hprof 手动导出现场堆。这一步是后续一切分析的前提,现场丢了就只能靠猜。
第二步,看 GC 日志和监控。从日志里区分是“瞬时内存峰值”还是“持续缓慢上涨”。前者可能是批处理任务一次加载太多数据,更像一个内存峰值问题;后者更像泄漏。这一步能决定你是该改代码,还是先调参数应急。区分方法其实很简单:把 GC 后的老年代占用画出来,如果每次回收后都能回到低位,那只是临时压力;如果低点一次比一次高,那基本就是泄漏。
第三步,用 MAT(Memory Analyzer Tool)或 JProfiler 打开 dump。MAT 里两个最常用的视图是 Leak Suspects(泄漏嫌疑)和 Dominator Tree(支配树)。Leak Suspects 会直接告诉你保留内存最多的对象和可能的引用路径;Dominator Tree 可以看某个大对象是谁引用了它,顺着引用链一路摸到 GC Root。
第四步,定位到代码并验证。找到嫌疑对象后,回到代码里确认它的生命周期控点。比如是不是静态集合被反复写入,是不是线程池任务里把大对象挂在成员变量上。修复后必须观察一个完整业务周期,确认老年代水位不再上升,才算闭环。
5.2 我踩过的常见泄漏代码模式
内存泄漏不总是“复杂神秘扣人心弦”,多数时候就是一些不起眼的编码习惯。下面这几个高频模式,你可以拿着自己的代码对一对。
静态集合当缓存:static Map<String, Object> CACHE = new HashMap<>(),只 put 不清理。对象长期挂在 static 字段上,而 static 字段又是 GC Root,等于给泄漏开了永久许可证。这里尤其要小心:很多同学以为“缓存”就应该是静态的,但没有任何淘汰机制的静态缓存,就是生产事故预备役。
ThreadLocal 忘记 remove:在 Tomcat 这类线程池复用的容器里,线程对象不会销毁。用完 ThreadLocal 不清掉,线程池里下一个任务就会读到上一个任务的数据,同时数据也被线程引用着,无法回收。用完一定要在 finally 里 remove。
未关闭的外部资源:数据库连接、IO 流、Socket、文件句柄,如果不 close,底层 native 资源和关联对象会一直堆积。虽然 JVM 有 finalize 兜底,但触发完全不可控,正规代码必须显式释放。
自定义类加载器反复创建:在一些动态部署框架里,每发布一次就 new 一个 ClassLoader,老 ClassLoader 如果还被全局引用,整个类元数据连带它加载的所有 Class 对象都进老年代,迟迟释放不掉。这类问题在元空间暴涨的时候尤其明显。
5.3 堆转储分析时的几个实用技巧
dump 文件很小的时候,打开哪块都无所谓;可生产环境一个 4GB 堆,dump 出来可能是 5GB 以上,直接打开能把笔记本卡死。我一般会先做两步:一是在分析工具里配置足够大的堆,比如 -Xmx2g -Xms512m,避免工具自己 OOM;二是先看 Leak Suspects,别在 Dominator Tree 里漫无目的地翻。
MAT 的 incoming references / outgoing references 功能很少有人提。打开一个大对象后,可以查看它的 outgoing references(这个对象引用了谁,决定它为什么这么大)和 incoming references(这个对象被谁引用)。从业务对象一层层往上追,通常 3 到 5 层就能看到某个集合、某个 Thread、某个静态字段。还有个小技巧,Leak Suspects 给出的“Shortest Path To GC Roots”报告里,会明确写出“这个对象为什么没被回收”,对着那条路径改代码几乎不会跑偏。
6. 面向面试:实习生最值得准备的 GC 考点
6.1 面试官高频问题与回答思路
前面讲了这么多,最后落回很多实习生最关心的问题:面试会怎么考?我梳理几个出现频率很高的考点。
JVM 内存区域有哪些,哪些线程私有?从堆、虚拟机栈、本地方法栈、程序计数器、方法区(元空间)讲起,再点名哪些是线程共享、哪些是线程私有。能顺手把每块区域对应什么异常也说了更好。
如何判断对象已死?先说可达性分析,解释为什么不用引用计数,再举一个循环引用的反例。提到 GC Roots 时,把“栈帧中的局部变量”“静态字段”“JNI 引用”说全,面试官会相信你不只是背了标题。
Full GC 触发条件是什么?老年代空间不足、元空间不足、System.gc() 显式调用、并发回收失败等。能把每种触发背后的原因链条说清楚,非常加分。
什么是内存泄漏,如何在 Java 中排查?直接用我上面说的静态集合、ThreadLocal、未关资源三个例子展开,然后说排查工具链:jps、jstat、jmap、MAT。你能说出“先看老年代 GC 后水位是否回落”这个判断标准,就已经超过大部分候选人了。
G1 和 CMS 有什么区别?先提 Region 化、可预测停顿、并发标记,再提 CMS 标记-清除有碎片问题且已废弃,最后结合自己的实验数据补充,会比干巴巴背参数有力得多。
6.2 防止“面试背得溜,上手不会做”
实习生的常见窘境,是能背出“内存区域八大块”,但问一句“怎么查看当前 JVM 堆内存使用率”就懵了。原因很简单,因为只用脑记、不动手。我给的建议很朴素:无论你实习还是自学,请用一个真实的 Spring Boot 或普通 Java 服务,开上 GC 日志,人为制造一个内存泄漏场景(比如静态 Map 只放不删),然后走一遍第 5 节的排查流程。你亲手做过一次,面试讲出来的细节层次,和单纯背出来的完全不同。
如果时间实在有限,至少把这一步做了:启动一个 Demo 服务,用 jstat 观察不同压力下的 Eden、Old 和 FGC 变化。你会发现,GC 调优这个话题本身并不玄,它就是“理解内存结构、学会看数据、再回代码里找原因”这三件事。
写到这里,GC 调优的核心链路基本已经够了。最后再分享一个我自己的心得:真正的调优高手不是参数背得多,而是能把 JVM 内存模型、GC 行为、工具输出和业务代码四者对上号。遇到线上问题时先冷静记录数据,再动手改;不要为了调优而调优,多数服务其实只需要把基础参数设对,真正要动刀的地方往往是代码层面的对象堆积。