距离上次在团队内部做 JVM 分享已经过了半年,最近连续帮几个朋友做模拟面试,发现一个挺有意思的现象:十个人里面有七个栽在 JVM 上,而且挂的原因惊人地一致——不是不知道概念,而是概念背得滚瓜烂熟,一问到“为什么”就卡壳。比如都知道堆内存分新生代和老年代,但问“为什么新生代要分 Eden 和 Survivor,Survivor 为什么是两个”,很多人就答不上来了。
这篇不是面试题答案的口播稿,而是把内存模型、GC 调优、元空间这三块真正串起来讲明白。你会看到这些知识点之间的因果链:为什么 JVM 要这么设计内存、为什么 GC 选型要看业务场景、为什么元空间能把很多线上问题从根源上解释清楚。我还把面试官真正想听到的答题逻辑、以及我自己在调优过程中踩过的坑都放了进来。看完这篇文章,你至少能在下一次面试中用“推导”代替“背诵”,也能在线上遇到 JVM 问题时有一个清晰的排查思路。
1. JVM 内存模型:大厂面试第一关,到底在考什么
1.1 运行时数据区:面试必背的六块地盘,但重点在“联动”
JVM 运行时数据区分为程序计数器(Program Counter Register)、虚拟机栈(JVM Stack)、本地方法栈(Native Method Stack)、堆(Heap)、方法区(Method Area)和运行时常量池(Runtime Constant Pool)。前三个是线程私有的,后两个是线程共享的。很多人能背出这句话,但面试官真正想听的,是这几块区域之间如何协同完成一次方法调用和对象创建。
我一般建议用“一次方法调用”的视角去理解。比如 main 方法里执行 new User(),JVM 首先在虚拟机栈中为 main 方法创建栈帧,栈帧里的局部变量表存储了 reference 类型,指向堆中的 User 对象实例。对象实例的类元信息(如类名、方法字节码、字段描述)则在方法区/元空间中维护。程序计数器记录当前线程执行的字节码行号,供分支、循环、跳转、异常恢复等依赖。
这个“联动”关系才是面试官想听到的。单独说“栈管运行,堆管存储”太表面了。栈帧里不只是局部变量,还有操作数栈、动态链接、方法出口,这些一起决定了方法调用和返回的过程。动态链接尤其值得一提——每个栈帧都包含一个指向运行时常量池中该方法的引用,支撑了 Java 的多态特性。面试如果被问到“多态在 JVM 层面如何实现”,答案的根就在这里。
提示:被问“内存模型”时,先画出六块区域的划分图,然后立刻用一个方法调用串起来。面试官要的是“你对这块内存动线有整体认知”,而不是背 wiki。
1.2 堆内存细分与对象生命周期:新生代为什么是 Eden + 两个 Survivor
堆是 JVM 管理的最大一块内存,也是 GC 的主战场。它逻辑上分为新生代(Young Generation)和老年代(Old Generation),新生代又细分为 Eden 区、Survivor From 区和 Survivor To 区,默认比例是 8:1:1。这个比例可以通过 -XX:SurvivorRatio 调整。
为什么这样设计?根源是“绝大多数对象朝生夕灭”这个统计学事实。新创建的对象优先分配在 Eden 区,第一次 Minor GC 后存活的对象进入 Survivor From,经过一次 GC 后,From 和 To 交换角色,存活对象在两个 Survivor 之间复制搬运,每经历一次 GC 年龄加一,当年龄达到阈值(默认 15,通过 -XX:MaxTenuringThreshold 设置)或者 Survivor 放不下时,晋升到老年代。
很多同学不理解为什么要两个 Survivor。用一个生活化类比:这就像食堂打饭窗口,Eden 是排队区,Survivor 是临时餐盘区。如果只有一个餐盘区,你刚把餐盘端过去,下一批人的餐盘又往同一个地方放,新旧混杂,GC 时没法区分哪些是刚放的需要保留、哪些是吃剩的要倒掉。两个 Survivor 交替使用,一轮 GC 结束,From 区清空,下一轮它变成 To 区,始终保证一个区是干净的空盘子,这样复制算法才能高效工作。
这个设计还引出一个大厂高频追问:“对象一定优先分配在 Eden 吗?”。答案是否定的。大对象(如长字符串、大数组)会直接进入老年代,可以通过 -XX:PretenureSizeThreshold 设置阈值,避免大对象在新生代反复复制带来的性能损耗。另外,栈上分配也是一个例外——如果开启了逃逸分析和标量替换(JDK 8 默认开启),对象可能不进入堆,直接在栈上分配,随着方法栈帧弹出而销毁,连 GC 都省了。这里能答出“逃逸分析”,面试官对你的加分一定很明显。
1.3 栈、PC 寄存器、本地方法栈:被忽略但更常被追问的细节
堆是重点,但栈区的细节往往是拉分题。虚拟机栈的默认容量在大多数平台是 1MB,可以通过 -Xss 调整。每个线程创建时会申请独立的栈空间,所以线程数越多,栈内存占用越大。经典问题“栈溢出 StackOverflowError”就发生在栈深度超过 JVM 允许的最大深度时,典型场景是无限递归。
很多人不知道的是,HotSpot 虚拟机栈和本地方法栈是合二为一的,也就是说 -Xss 同时作用于两者。另外,程序计数器是唯一不会出现 OOM 的区域,因为它只保存字节码行号,非常小巧。面试如果被问“哪些区域会抛 OutOfMemoryError”,程序计数器就是唯一的例外,这属于送分题,但要有意识地说出来。
栈还有一个隐藏考点:-Xss 设置过小会导致方法调用深度不足,设置过大则会占用过多内存,减少可创建的线程数。在容器环境下这特别明显——你给 JVM 分配了 2GB 堆,却忽略了线程栈的内存占用,结果线程数一上来,容器直接 OOM。结合热词里“docker 容器部署的 java 程序异常重启”这类问题,很多时候根因就是线程栈内存没算进去。这个我会在第 4 章详细展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 元空间:从永久代到元空间,一个被低估的高频面试点
2.1 为什么要用元空间替换永久代
方法区在 JDK 8 之前由永久代(PermGen)实现,JDK 8 以后永久代被移除,取而代之的是元空间(Metaspace)。面试官问到“为什么替换”时,理想答案包含四个层面,缺一不可:
第一,永久代的大小是固定的,启动时通过 -XX:MaxPermSize 设定,一旦类元数据超过这个上限,就会抛出 java.lang.OutOfMemoryError: PermGen space。而字符串常量池在 JDK 7 就已经移到了堆中,JDK 8 的元空间直接使用本地内存(Native Memory),默认情况下上限取决于系统可用内存,类元数据爆掉的风险大幅降低。
第二,永久代和堆在一起,会增加 Full GC 的复杂度。Full GC 需要扫描永久代,每次扫描都可能引发性能抖动。元空间独立于堆之外,Full GC 时对元空间的回收由专门的逻辑管理。
第三,调优方式更灵活。元空间可以通过 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 控制,MetaspaceSize 是触发 GC 的初始阈值,MaxMetaspaceSize 是上限。如果不设置后者,理论上元空间可以一直增长,直到耗尽本地内存。
第四,JRockit(Oracle 收购的另一个 JVM)没有永久代概念,合并 OpenJDK 和 JRockit 的代码基础时,统一采用元空间方案更合理。这个历史背景知道的人少,说出来反而显得你了解 JVM 演进脉络。
2.2 元空间参数配置:从 CompileThreshold 到 MaxMetaspaceSize
元空间的参数配置是我面试必问的细节,因为很多候选人只停留在“知道有 MetaspaceSize 这个参数”的层面,但说不清默认行为和配置逻辑。
-XX:MetaspaceSize 是元空间触发垃圾回收的初始阈值,注意它不是元空间的上限,而是“低水位标记”。当元空间使用量达到这个值,JVM 会触发一次 Full GC 来卸载不再使用的类。-XX:MaxMetaspaceSize 才是硬上限,达到上限后如果元空间还在增长,就会抛 OutOfMemoryError: Metaspace。
还有一个容易被忽略的参数:-XX:CompileThreshold。JIT 编译器根据方法调用次数或循环回边次数来决定是否将热点方法编译为本地代码,默认阈值是 10000 次。热词里有人搜“jvm参数 -xx:compilethreshold”,其实就是这个。CompileThreshold 越小,方法越早被编译,启动后性能爬坡变快,但编译开销也会增加。这不算元空间的直接参数,但在类加载器层面与 C1/C2 编译器相关,经常和元空间问题一起考察。
实操中我的建议是:容器环境下永远设置 MaxMetaspaceSize。虽然默认无上限看起来“安全”,但元空间泄漏(典型场景:大量动态生成代理类、热部署反复加载类)会把宿主机内存吃光。设置一个合理上限,比如 512MB 或 1GB,可以让问题在容器 OOM 前暴露出来,而不是让整个容器被杀掉。
注意:元空间 OOM 的排查首看类加载器。使用
jstat -gcmetacapacity <pid>观察 Metaspace 容量的增长趋势,如果持续上升不回落,大概率是类加载器泄漏。重点检查反射动态代理、CGLIB、热部署框架和频繁使用 Lambda 的代码。
2.3 从 class 文件到类加载器:元空间回收的核心机制
元空间的回收和 GC Roots 直接相关。类的元数据要被回收,前提是该类的类加载器不再存活,且该类的所有实例都不可达。换句话说,只要类加载器还活着,类元数据就不会被卸载。这也是为什么使用自定义类加载器的应用(如 OSGi、Spring Boot DevTools 热部署)更容易出现元空间泄漏——每次重新部署都会创建新的类加载器,如果旧加载器无法被 GC 回收,元空间就会被占满。
这里有一个经典误区:JDK 8 默认提供的四个系统类加载器(Bootstrap、Platform、App)本身不需要被回收,只有当用户创建了自定义类加载器并反复加载/卸载时,元空间的压力才会凸显。
用 jcmd <pid> GC.class_stats 可以统计每个类加载器所加载的类数量,这是排查元空间泄漏最直接的工具。遇到元空间 OOM,第一步不是调大 MaxMetaspaceSize,而是先找出谁在无止境地加载类,否则调大参数只是把爆发时间推迟。
3. GC 算法与收集器选型:面试官真正想听的“为什么”
3.1 可达性分析与三种基础 GC 算法
JVM 判断对象能否回收的依据是“可达性分析”——从 GC Roots 出发,沿着引用链搜索,不可达的对象即为可回收对象。GC Roots 包括:虚拟机栈帧中的局部变量引用、静态变量引用、JNI 引用、活跃线程、已启动且未停止的 Java 线程等。
三种基础回收算法的本质和适用场景如下表:
| 算法 | 思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 标记-清除 | 标记存活对象,清除未标记对象 | 实现简单,不移动对象 | 产生内存碎片 | 老年代配合 CMS |
| 标记-复制 | 将存活对象复制到另一块空间,原空间整体清空 | 无碎片,分配高效 | 浪费空间,需要额外空间 | 新生代 |
| 标记-整理 | 标记存活对象后向一端移动 | 无碎片,空间利用率高 | 移动对象需要 STW | 老年代 |
这三种算法分别对应新生代、老年代的不同特点。新生代存活对象少,复制成本低,适合标记-复制;老年代存活对象多,不适合反复复制,所以采用标记-清除或标记-整理。
3.2 从 Serial 到 G1,再到 ZGC:选型背后的逻辑
面试经常给场景让选收集器,这不是考“我记得哪个命令”,而是考“我理解每个收集器的设计取舍”。
Serial 是单线程收集器,GC 时有较长的 Stop The World,但它简单高效,适合客户端模式或堆内存极小(几百 MB)的场景。
Parallel Scavenge 被称为“吞吐量优先收集器”,目标是最大化吞吐量(运行用户代码时间 / 总时间)。适合后台计算任务,对延迟不敏感。
CMS 是第一款并发收集器,目标是低停顿,但它有两个致命问题:一是并发标记阶段会占用 CPU,导致吞吐量下降;二是基于标记-清除,会产生碎片,最终触发 Full GC 时停顿反而更大。CMS 在 JDK 9 后被打上废弃标记,JDK 14 被移除。
G1 是目前服务端默认收集器,它的核心设计是“分区”,把堆划分为多个大小相等的 Region,不再有物理上的新生代/老年代边界,而是逻辑上动态调整。G1 的停顿可预测,通过 -XX:MaxGCPauseMillis 设置目标停顿时间,默认 200ms。G1 把堆划分为约 2048 个 Region,每个 Region 大小由堆大小除以 2048 决定,范围为 1MB 到 32MB。
ZGC 是 JDK 11 引入的低延迟收集器,目标停顿时间控制在 10ms 以内,核心是染色指针和读屏障。它适合超大堆(几十 GB 到 TB 级)且对延迟要求极高的场景。代价是 CPU 开销较高,且需要较高的内存带宽。
3.3 大厂高频问题:你的业务该用什么收集器
这道题没有标准答案,但有清晰的推导路径。我建议按下面这个逻辑回答:
第一步,明确业务对延迟和吞吐的权重。在线交易系统、用户请求接口,延迟是第一位;离线计算、日志处理、定时任务,吞吐量优先。
第二步,评估堆大小和机器规格。堆小于 4GB,用 Parallel 或 Serial 足够;堆在 4GB~64GB,G1 是安全选择;堆在 64GB 以上,延迟要求严苛,考虑 ZGC。
第三步,结合 JDK 版本确认可用性。JDK 8 的 G1 是实验性参数 -XX:+UseG1GC 开启,JDK 9 后默认。JDK 11 才能用 ZGC,JDK 15 才转正。如果公司在 JDK 8 上,现实可选项只有 Parallel 和 CMS。
这个推导过程会让面试官觉得你有“选型意识”,而不是背参数。另外,一个加分项是提及“G1 的停顿预测模型”:G1 通过历史数据预测每个 Region 回收的时间,然后优先回收收益最大的 Region,以保证停顿时间可控。这就是 Remembered Set 和 Card Table 的作用——记录跨 Region 引用,避免全堆扫描。展开到这里,面试官基本就能确认你研究过底层实现。
4. GC 调优实战:从“看懂日志”到“精准调参”
4.1 先学会看 GC 日志
先声明一个原则:GC 调优的第一步永远是观察,而不是调参。你要知道 JVM 当前发生了什么、停顿多久、频率多高,才能决定要不要动参数。
JDK 8 启用 GC 日志的参数是 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log,JDK 9+ 则使用统一的 -Xlog:gc*:file=/path/to/gc.log。热词里有人搜“jvm日志在哪儿”,多半是线上 JVM 异常重启后找不到日志。JVM 的崩溃日志(hs_err_pid*.log)默认生成在工作目录,GC 日志则完全取决于你配置的 Xloggc 路径,没配置就没有。所以容器环境部署 Java 程序,一定要同时显式配置 GC 日志路径,否则重启后什么都查不到。
一段典型的 G1 日志看起来是这样的:
code复制[2025.01.15 10:23:45.123] [info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 256M->32M(512M) 12.345ms
要关注三组数字:GC 前的堆占用(256M)、GC 后的堆占用(32M)、当前堆总容量(512M),以及停顿时间(12.345ms)。多次 GC 后,把 GC 后的堆占用连成趋势线,就能判断是“对象朝生夕灭”的正常波动,还是“老年代持续增长”的内存泄漏信号。
4.2 三个最常用的调优维度:堆大小、新生代比例、GC 目标
堆大小是所有 JVM 参数的基石。-Xms 是初始堆,-Xmx 是最大堆,生产环境我强烈建议两者设成相同值,避免堆频繁扩容缩容带来的性能抖动。常见的错误是只设 -Xmx 不管 -Xms,启动时堆很小,运行中逐步扩到最大值,扩堆本身会触发 Full GC,反而引入额外停顿。
新生代大小通过 -Xmn 设置,或者用 -XX:NewRatio 设置老年代与新生代的比例(默认 2,即老年代占 2/3)。新生代太小,短命对象频繁进入老年代,产生大量不必要的 Full GC;新生代太大,老年代空间不足,Full GC 频繁且耗时长。经验值是新生代占堆的 1/3 到 1/2,然后根据 GC 日志微调。
GC 目标参数如上文提到的 -XX:MaxGCPauseMillis。千万不要以为把这个参数设得越小越好——G1 的停顿预测模型会根据目标值动态调整每个 Region 的回收数量,目标值过小,G1 可能会减少一次 GC 回收的 Region 数量,导致 GC 频率升高,整体吞吐下降。我见过有人把停顿目标从 200ms 改为 20ms,结果 GC 频率从每分钟 2 次变成每秒 3 次,CPU 打满,系统更慢。
4.3 容器环境陷阱:为什么 Docker 里的 JVM 总异常重启
热词里“docker 容器部署的 java 程序异常重启”是这几天搜索量最高的词之一,这个问题我在一线排查过很多次。先说结论:大概率是 JVM 没有感知容器内存限制。
在 JDK 8u131 之前,JVM 默认取宿主机内存来设置堆最大值,不管你 Docker --memory 限制多小,JVM 都以为自己是超级计算机,堆飙到几个 GB,最终触发系统 OOM-Killer,容器被直接杀掉。表现为程序没留下任何 Java 异常日志,容器状态变成 Exited。
解决办法分两步。第一步,显式设置堆大小,不要依赖默认值;第二步,JDK 8u191 之后可用 -XX:+UseContainerSupport(默认开启)和 -XX:MaxRAMPercentage=75.0 让 JVM 感知容器限额。注意,MaxRAMPercentage 设的是整个 JVM 进程能用的内存中堆所占百分比,不是容器内存的百分比。堆之外还有线程栈、元空间、直接内存,所以 75.0 是个相对保守的安全值,如果线程数很多,可能需要降到 50.0 或 60.0。
这里再补充一个容易忽略的点:容器环境下的 GC 日志位置。容器一旦被杀,容器内的一切文件系统都随之消失。如果 GC 日志写在容器内部路径,重启后新容器不会有旧日志。正确做法是用 Docker volume 或 log driver 把日志持久化到宿主机。
4.4 常见 JVM 问题速查表
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| Full GC 频繁 | 老年代持续增长,内存泄漏或晋升过快 | jstat -gcutil 观察老年代趋势 |
先排查泄漏源,再调整 -Xmn、MaxTenuringThreshold |
| Metaspace OOM | 类加载器无法回收,动态生成类过多 | jcmd GC.class_stats 统计加载器 |
修复泄漏,设置 MaxMetaspaceSize 兜底 |
| 容器被 OOM-Killer | JVM 未感知容器内存限制 | 检查容器退出码为 137 | 升级 JDK 到 8u191+,设置 MaxRAMPercentage |
| GC 停顿远超预期 | 堆过大时 GC Roots 扫描慢,或误用 CMS 产生碎片 | 对比并发阶段时间 | 升级 G1/ZGC,优化堆大小 |
| Young GC 后存活对象异常多 | 大对象直接分配到老年代但疑点在新生代 | 查看日志中晋升大小 | 调整 PretenureSizeThreshold |
5. 面试杀手题拆解:从“背诵”到“答题框架”
5.1 三道高频“杀手题”的答题逻辑
第一题:“请你说说 JVM 内存模型。”
普通回答:背六块区域。高分回答:先用一句话点出“线程私有 vs 线程共享”,然后用一次方法调用串起来,最后补充“内存模型解决了什么问题”——这里的高阶加分是区分“JVM 内存模型”和“Java 内存模型 JMM”。JMM 是规范层面,解决多线程下共享变量的可见性、有序性、原子性问题,通过主内存和工作内存的交互、happens-before 规则来约束。JVM 内存模型是虚拟机层面,决定数据存在哪、如何管理。两者是面向前置架构的两个不同维度,混为一谈是面试大忌。
第二题:“Full GC 频繁,你怎么排查?”
普通回答:调大堆。高分回答按顺序给出四条线:第一条,通过 jstat -gcutil <pid> 1000 每秒打印 GC 情况,确定 Full GC 频率和老年代增长趋势;第二条,通过 jmap -dump:format=b,file=heap.hprof <pid> 导出堆快照,用 MAT 分析支配树定位大对象和泄漏嫌疑对象;第三条,检查代码中的显式垃圾回收(System.gc(),尤其是使用 RMI 框架时),通过 -XX:+DisableExplicitGC 关闭;第四条,分析 GC 日志晋升对象大小,判断是否需要调整晋升阈值和 Survivor 空间。能说出这条排查链路的人,已经是一名合格的 Java 工程师了。
第三题:“CMS 和 G1 有什么区别?”
普通回答:一个老年代一个全堆。高分回答:从三个维度推进——内存布局上,CMS 是物理分代,G1 是逻辑分代、Region 分区;回收过程上,CMS 四步(初始标记、并发标记、重新标记、并发清除),G1 四步(初始标记、并发标记、最终标记、筛选回收);调优目标上,CMS 追求低停顿但对碎片敏感,G1 可预测停顿、大堆更友好。再补充一句:CMS 的两个败笔——内存碎片和并发模式失败(Concurrent Mode Failure),是它退役的根因。G1 通过复制整理彻底规避碎片,通过 -XX:InitiatingHeapOccupancyPercent 控制并发回收的触发时机减少 Concurrent Mode Failure。
5.2 一份面试官视角的“加分清单”
面试时,能主动说出下面这些细节的人,通常会被我划到“深入理解”档位:
-XX:+PrintGCDetails和-Xlog:gc的差异:JDK 9 后日志系统重构,旧参数移除-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath:生产环境必须开启的兜底参数-XX:+DisableExplicitGC的副作用:某些框架依赖System.gc()做堆外内存回收时,禁用可能导致堆外内存耗尽(Netty 的 Direct Memory 是典型)- G1 的
-XX:G1HeapRegionSize调整逻辑:默认按堆大小推导,堆很大时 Region 过大,回收粒度变粗 -Xss与线程总数的关系:栈内存不是占位符,每个线程真实占用
6. 大厂标准里的“隐藏考点”和新手常踩的坑
6.1 为什么大厂强调“调优不是调参数,而是调代码”
我在面试中特别警惕一种候选人:把 JVM 参数背得滚瓜烂熟,GC 日志分析头头是道,但一追问线上服务的 QPS、响应时间、对象分配速率,完全答不上来。这说明他对调优的理解停留在“改参数”层面,没有建立起“代码行为导致内存行为”的因果意识。
真正的调优链路是:业务现象 → 监控数据 → 内存行为 → 代码问题 → 代码优化 → 验证效果。参数调整只是链路中的一环。比如频繁 Full GC,根因可能是某个接口每次请求都创建了一个大集合缓存了全量数据,而不是堆不够大。这种情况调大堆只会让 Full GC 来得更晚,但问题依然存在。
所以,大厂标准里“调优”的真正含义是:你先能读懂 GC 日志,知道 JVM 在干什么;再能通过工具定位到代码热点,知道是哪段逻辑造成了内存压力;最后才轮到通过参数调整优化 GC 行为,平衡延迟和吞吐。这三个能力对应初级、中级、高级三个阶段,面试考察的也正是你现在处在哪一段。
6.2 我踩过的三个调优坑,现在分享给你
踩坑一:GC 日志路径不持久化。最早容器化部署时,我把 -Xloggc:/app/logs/gc.log 配在容器内部,结果 OOM-Killer 杀容器之后,日志随容器一起消失,线上问题查了个寂寞。后来全部持久化到 Docker volume,还配了 Filebeat 采集到统一日志平台。现在任何 JVM 问题都能快速回溯,不用裸奔排查。
踩坑二:盲目调大 MetaspaceSize 导致 Full GC 变早。MetaspaceSize 不是“越大越好”。它的本质是元空间扩容的触发阈值,设得过大,元空间会持续增长到阈值才触发回收,短期内反而可能增加 Full GC 次数。正确思路是保持默认或设一个合理的初始值(比如 256MB),配合 MaxMetaspaceSize 做上限保护。
踩坑三:不看 CPU 核数就调 MaxGCPauseMillis。G1 的并发标记和并发回收阶段会占用多个 GC 线程,默认并行线程数太小会导致并发阶段耗时过长。如果你在 2 核的容器上把 -XX:ParallelGCThreads 默认值改成 8,GC 线程自己把 CPU 挤爆,停顿目标反而达不到。线程数和堆大小、CPU 核心数必须匹配,这个参数是需要根据机器规格实际测的。
6.3 未来方向:ZGC 和生成式 AI 时代的 JVM 热点
很多同学问“现在学 JVM 到底学到哪一代才算合格”。我的判断是:JDK 11 是基础门槛,G1 是核心必修,ZGC 是增量优势。JDK 21 的 ZGC 已经在绝大多数场景下做到了亚毫秒级停顿,加上分代 ZGC 的引入,对大堆、高并发、低延迟场景的支持已经相当成熟。面试能主动聊到分代 ZGC 的“分代 + 染色指针”组合,已经超过 90% 的候选人。
另一个不可忽视的方向是 Native Memory Tracking(NMT)。JVM 堆只是整个进程内存的一部分,堆外的 DirectBuffer、线程栈、元空间、JIT 编译器代码缓存加起来往往占到总内存的 30%~40%。排查容器 OOM 时,我常用 -XX:NativeMemoryTracking=summary 配合 jcmd <pid> VM.native_memory 观察进程内各模块内存占用,这一步能快速定位堆外内存泄漏。比如 Netty 的 Direct Memory 泄漏,光看堆快照是永远查不出来的。
从面试到实战,JVM 的知识体系确实庞大,但主线非常清晰:理解内存如何划分,理解对象如何分配与回收,理解收集器如何用算法实现回收,最后理解如何用工具观察和优化整个过程。这条线走通了,面试题、线上故障、性能调优都能串起来。我自己也是从“背参数”阶段一步步走到“看日志定方案”阶段,中间踩的坑不算少,所以特别希望这篇文章能帮你少走一些弯路。哪怕只把其中一两个点真正吃透,下次面试或排查问题时,效果都会很明显。
