JVM 面试很少停在「堆和栈有什么区别」。答完内存区域,面试官会顺着问对象怎么分配、哪些对象可回收、GC 为什么停顿,最后落到线上 OOM、频繁 Full GC、CPU 飙高怎么排查。本篇按内存区域、GC、类加载、调优排查四条线,讲透高频题的原理与追问方向。

考察地图

  • 内存区域:运行时数据区划分、永久代与元空间的关系、对象创建过程。考察对 JVM 内存模型的体系化理解。
  • 垃圾回收:可达性分析与 GC Roots、四种引用、Minor/Major/Full GC 的区别、G1 如何控制停顿、ZGC 如何压低停顿。考察对回收机制与收集器取舍的把握。
  • 类加载:双亲委派模型及其执行流程。考察类隔离与 SPI 场景下的变通。
  • 调优排查:频繁 Full GC 的排查路径、CPU 飙高的定位方法。考察证据链思维,而不是背命令。
  • 建议自行查阅:指针碰撞与空闲列表、句柄访问与直接指针、Class 文件结构、Serial/Parallel 等老收集器的适用场景——这些是铺垫性知识,面试中多作为追问的细节。

内存区域

JVM 运行时数据区包括哪些部分?哪些是线程私有的?

结论:线程私有:程序计数器、虚拟机栈、本地方法栈;线程共享:堆、方法区。直接内存不属于运行时数据区,但常与 NIO 一起被问到。

原理:程序计数器记录当前线程执行的字节码行号,是线程切换后能恢复执行的关键,也是唯一不会 OOM 的区域。虚拟机栈以栈帧为单位保存方法的局部变量表、操作数栈、动态链接和返回地址,方法调用即入栈、返回即出栈;栈深度超限抛 StackOverflowError,栈无法扩展时抛 OutOfMemoryError。堆是对象实例的主战场,几乎所有 GC 都围绕它展开;方法区(JDK 8 起由元空间实现)存类元信息、运行时常量池、JIT 编译产物。

延伸:「所有对象都分配在堆上」不严格成立——JIT 逃逸分析可让短生命周期对象栈上分配甚至被标量替换拆散;字符串常量池 JDK 7 起也迁入了堆中。

追问:为什么程序计数器不会 OOM?提示:它只存一个行号,大小固定,与方法调用深度无关。

方法区、永久代和元空间是什么关系?为什么移除永久代?

结论:方法区是 JVM 规范定义的逻辑区域;永久代是 HotSpot 在 JDK 7 及以前的实现,元空间是 JDK 8 起的实现,改用本地内存。

原理:永久代有固定上限(-XX:MaxPermSize),类多或动态代理、反射生成类密集时容易撑爆报 PermGen space,且其回收与 Full GC 耦合、效率低。换成元空间后,类元数据放进本地内存,默认上限只受物理内存约束(可用 -XX:MaxMetaspaceSize 限制),类的卸载随类加载器回收自然发生。

延伸:元空间不是不会 OOM——类加载器泄漏(典型的如热部署、Groovy 脚本动态生成类)照样能把它打满,只是门槛高了很多。

追问:元空间 OOM 怎么查?提示:看 -XX:MaxMetaspaceSize、用 jcmd VM.classloader_stats 找异常增长的类加载器。

HotSpot 创建一个对象要经过哪些步骤?

结论:类加载检查 → 分配内存 → 零值初始化 → 设置对象头 → 执行 <init>。

原理:遇到 new 指令后,先检查常量池中能否定位到类的符号引用且该类已加载,否则先触发类加载。分配内存有两种方式:堆规整时用指针碰撞(挪动分界指针),堆不规整时用空闲列表(CMS 这类有碎片的收集器适用);并发分配靠 CAS 重试或 TLAB(每线程在 Eden 预占一块私有缓冲区)解决竞争。接着把内存清零(这也是字段不赋初值也有默认值的原因),再写入对象头(Mark Word、类型指针、数组长度),最后执行 <init> 完成构造。

延伸:对象在内存里分为对象头、实例数据、对齐填充三段;HotSpot 要求对象大小是 8 字节对齐,所以末尾可能补白。普通对象通过直接指针访问(栈上的引用直接指向堆),这也是 HotSpot 的主流方案,比句柄池少一次转发。

追问:TLAB 会不会造成空间浪费?提示:会,线程退出的 TLAB 残余被废弃,JVM 用「浪费比例」控制是否开启下一次 TLAB 分配。

垃圾回收

怎么判断一个对象已经死亡?哪些对象能作为 GC Roots?

结论:主流方案是可达性分析:从 GC Roots 出发沿引用链搜索,不可达的对象判定可回收。引用计数法因无法解决循环引用,主流商用 JVM 都不采用。

原理:GC Roots 包括:虚拟机栈和本地方法栈中引用的对象、方法区中静态属性和常量引用的对象、JNI 引用、活跃线程等。「不可达」不等于「立即死亡」:对象还要经历两次标记,finalize() 是它最后的自救机会——但该机制不可靠,JDK 9 起已废弃。

延伸:可达性分析必须在「一致性快照」上进行,为此 GC 会在安全点(Safe Point)挂起所有用户线程,这就是 STW(Stop The World)的来源;安全点通常选在方法调用、循环跳转、异常跳转等位置,长时间不进入安全点的循环会让全 JVM 等它。

追问:循环引用的两个对象会被回收吗?提示:会,只要从 GC Roots 不可达,引用计数为 1 也照样回收。

强引用、软引用、弱引用、虚引用有什么区别?

结论:四者的区别在于 GC 时机:强引用永不回收;软引用在内存不足、即将 OOM 前回收;弱引用只要发生 GC 就回收;虚引用不影响存活,仅用于在对象回收时收到通知。

原理:软引用(SoftReference)适合做内存敏感缓存——堆紧张时自动释放;弱引用(WeakReference)的经典场景是 ThreadLocalMap 的 key,防止线程长期存活时 key 强持有 ThreadLocal;虚引用(PhantomReference)必须配合 ReferenceQueue 使用,NIO 用它跟踪堆外内存的释放(Cleaner 机制)。GC 把回收的引用对象加入关联的 ReferenceQueue,程序可以轮询队列做后续清理。

延伸:ThreadLocalMap 的 key 是弱引用但 value 是强引用,线程池复用线程且不调 remove() 时 value 会沿 Thread 引用链泄漏——这就是「用完 ThreadLocal 必须 remove」的原因。

追问:软引用缓存为什么仍可能 OOM?提示:软引用回收发生在 OOM 抛出之前,但分配速率远高于回收速度时,软引用对象照样堆积。

Minor GC、Major GC 和 Full GC 有什么区别?

结论:Minor GC 只回收新生代;Major GC 通常指只回收老年代(目前只有 CMS 有单独的老年代回收);Full GC 回收整个堆加方法区。但要注意这些术语在规范里没有严格定义,不同收集器和日志里的语义有差异,回答时最好结合具体收集器说。

原理:Minor GC 非常频繁:Eden 满即触发,存活对象复制到 Survivor,年龄达到阈值(默认 15,由对象头 Mark Word 中 4 bit 年龄计数决定)或 Survivor 装不下时晋升老年代。Full GC 的触发路径更值得关注:老年代空间不足、空间分配担保失败、晋升对象的平均大小超过老年代剩余空间、元空间不足、显式调用 System.gc()(可通过 -XX:+DisableExplicitGC 禁用)等。Full GC 的代价远高于 Minor GC,停顿通常以秒计。

延伸:大对象(超长数组、大字符串)可能绕过新生代直接进老年代(-XX:PretenureSizeThreshold,仅 Serial 和 ParNew 有效),这是避免 Eden 区为大对象反复复制的保护机制。

追问:动态年龄判定是什么?提示:Survivor 中某一年龄及以下对象总大小超过 Survivor 一半时,大于等于该年龄的对象直接晋升,不必等到 15 岁。

G1 是怎么做到可预测停顿的?

结论:G1 把堆切成等大的 Region,按「回收价值」(回收能获得的空间与预计耗时之比)排序,在用户设定的停顿目标(-XX:MaxGCPauseMillis,默认 200ms)内优先回收价值最高的 Region 集合,从而把停顿控制在可预测范围。

原理:Region 不再是物理连续的 Eden/Survivor/Old 分区,每个 Region 动态扮演某种角色,超大对象(超过 Region 一半)进专门的 Humongous Region。跨 Region 引用靠每个 Region 的 Remembered Set 记录,避免扫描整个老年代。整体上是标记-整理,Region 之间是复制,因此不会产生 CMS 那样的内存碎片。当老年代占用达到 -XX:InitiatingHeapOccupancyPercent(默认 45%)触发并发标记,随后通过多次 Mixed GC 同时回收新生代和一部分高价值的老年代 Region。

延伸:G1 的代价是写屏障维护 Remembered Set 带来的额外开销,且小堆(小于约 4~6G)下未必比 CMS/Parallel 更优;JDK 9 起 G1 成为默认收集器。

追问:为什么 Full GC 在 G1 里仍然是坏消息?提示:G1 的 Full GC 是单线程 Serial 化的兜底(JDK 10 起才并行),一旦出现说明 Mixed GC 跟不上晋升速度。

ZGC 是如何把停顿压到毫秒级的?

结论:ZGC 把标记、转移(重定位)全部做成并发执行,STW 只剩根节点扫描等极短阶段,停顿时间官方目标是 10ms 以内且不随堆大小增长。

原理:核心技术是着色指针(Colored Pointers)加读屏障:64 位指针的高位被用来存放 Marked、Remapped 等元信息,GC 线程转移对象后只需更新指针的颜色位;应用线程访问对象时经过读屏障,发现指针指向已转移的旧地址就触发自愈——先修正引用再使用。这省去了 CMS/G1 中昂贵的跨代记忆集维护。代价是吞吐量比 Parallel/G1 低约 10%~15%,且着色指针占用高位后可用寻址空间受限(ZGC 早期不支持指针压缩)。

延伸:ZGC 适合大堆(百 GB 级)、低延迟敏感场景;JDK 15 转正,JDK 21 引入分代 ZGC 进一步缓解高分配速率下的压力。

追问:低停顿收集器适合所有服务吗?提示:不适合——后台计算、离线任务更看重吞吐,Parallel GC 反而是更好的选择。

类加载

什么是双亲委派模型?它是怎么工作的?

结论:一个类加载器收到加载请求后,先委托父加载器尝试加载,父加载器层层向上直到启动类加载器;父加载器在自己的搜索范围内找不到时,才回退给子加载器加载。这套机制保证核心类全局唯一、不被篡改。

原理:以 java.lang.String 为例:任何类加载请求最终都会上浮到启动类加载器(Bootstrap),由它从 rt.jar/模块中加载,用户自己写的同名 java.lang.String 根本没有机会进入 JVM——这就堵死了核心 API 被替换的口子。实现上 ClassLoader#loadClass() 的逻辑就是:先查已加载缓存 → 委托父加载器 → 父加载器失败 → 调用自己的 findClass()。注意类的唯一性由「全限定名 + 加载它的类加载器」共同决定,不同加载器加载的同名类是两个不同的类。

延伸:双亲委派不是铁律。SPI 场景(JDBC、JNDI)中核心类要回调厂商实现,而厂商类由应用类加载器加载,只能借线程上下文类加载器「逆向」委托;Tomcat 为隔离各 Web 应用让 WebappClassLoader 优先自加载;OSGi、热部署更是彻底打破委派链。自定义类加载器若不想破坏委派,重写 findClass() 即可,不要轻易动 loadClass()。

追问:两个类全限定名相同就相等吗?提示:不相等,必须加载器也相同,instanceof、equals、强转都以「加载器 + 类名」为身份标识。

调优排查

线上频繁 Full GC 怎么排查?

结论:先确认「频繁」的量化标准和触发原因,再沿老年代增长、晋升速率、元空间、显式调用四条主线查,最后对症修参数或代码——不能上来就调大堆。

原理:第一步看 GC 日志:Full GC 的触发原因(Allocation Failure、Metadata GC Threshold、System.gc()、G1 的 to-space exhausted 等)、回收前后老年代占用、停顿时长。回收后老年代占用仍高,说明对象被长期持有,转内存泄漏方向(dump 堆后用 MAT 看 Dominator Tree 和到 GC Roots 的引用链);回收效果正常但很快又满,说明晋升速率过快或大对象多,查 Young GC 后存活对象量、-XX:PretenureSizeThreshold、批量加载逻辑;触发原因是元空间,查类加载器数量;是 System.gc(),加 -XX:+DisableExplicitGC 或定位调用方。

延伸:临时止血可以摘流量、重启,但重启会清掉现场——执行前至少留 GC 日志、一两次 jstat -gcutil 采样和堆直方图。修复后要在相同流量下对比 Full GC 次数、停顿时间和老年代曲线,确认改动有效。

追问:只出现一两次 Full GC 但每次停 5 秒要管吗?提示:要——单次停顿超过 SLA 同样是问题,方向是换低停顿收集器或减小堆。

CPU 飙高怎么定位到具体代码?

结论:进程 → 线程 → 栈帧三级定位:top 找高 CPU 进程,top -Hp <pid> 找高 CPU 线程,把线程 ID 转十六进制后在 jstack 快照中匹配 nid,连续抓几份确认热点栈。

原理:

top -Hp <pid>                 # 找到占用最高的线程 tid(十进制)
printf '%x\n' <tid> # 转成十六进制,对应线程栈中的 nid
jstack <pid> > /tmp/t1.txt # 间隔几秒再抓 1~2 份对比

单次栈可能是采样巧合,连续几份都停在同一方法才可确认。常见热点类型:无法退出的循环或递归、正则灾难性回溯、大对象序列化/加解密等纯计算、频繁 GC(此时 GC 线程本身吃 CPU)、多线程争抢同一把锁带来的自旋与上下文切换。定位到代码后结合流量和发布记录判断是 bug 还是业务量增长。

延伸:生产环境抓栈要控制频率,且 jstack 会让 JVM 走到安全点,堆大线程多时自身有开销;更平滑的方案是 JFR 或 Arthas 的 profiler 做低开销采样。

追问:CPU 不高但 Load 很高说明什么?提示:Load 统计不可中断睡眠的线程,多半是磁盘 I/O 或网络存储等待,用 vmstat/iostat 查而不是盯 JVM。