面试追问地图
| 主问题 | 必讲关键点 | 下一层追问 |
|---|---|---|
| 运行时内存 | 堆、虚拟机栈、方法区、直接内存 | 每个区域可能出现什么 OOM |
| 对象创建 | 类检查、分配、零值、对象头、构造 | 指针碰撞、TLAB、逃逸分析 |
| 对象布局 | 对象头、实例数据、对齐填充 | Mark Word 如何保存锁和 GC 信息 |
| 栈帧 | 局部变量表、操作数栈、动态链接 | 返回地址、this 在第 0 个 slot |
| 可达性分析 | GC Roots、引用链 | 四种引用、finalize 为什么废弃 |
| GC 算法 | 标记清除、复制、标记整理 | 分代假说、跨代引用 |
| 并发标记 | 三色标记、漏标两条件 | 增量更新 vs SATB、CMS 与 G1 为何选择不同 |
| 收集器 | 吞吐量、延迟、堆规模 | G1 Region/RSet/Mixed GC、ZGC 染色指针、如何选型 |
| STW | 安全点、主动式中断 | 大循环拖长 STW、安全区域 |
| JIT 编译 | 解释器+C1/C2、分层编译、方法内联、逃逸分析 | OSR、反优化、PrintCompilation、编译阈值 |
| 类加载 | 加载、验证、准备、解析、初始化 | 双亲委派、SPI、类冲突 |
| 字符串常量池 | 字面量复用、intern | JDK 7 为何移到堆、GC 回收 |
| JVM 调优 | 目标、指标、证据链 | GC 日志怎么看、为什么不能只调参数 |
| 内存泄漏 | 对象存活但不再需要 | MAT 支配树、ThreadLocal、类加载器泄漏 |
| OOM | 报错信息定位区域 | 堆 / 元空间 / 直接内存 / 线程,各自排查工具 |
回答调优题先说目标和现象,再说工具、证据和修改,不能从背 JVM 参数开始。
一、运行时内存与对象
运行时数据区有哪些?各自会出现什么 OOM?
频次 ★★★★★ · 难度 🟡
是什么:
| 区域 | 线程私有/共享 | 存什么 | 溢出报错 |
|---|---|---|---|
| 程序计数器 | 私有 | 当前字节码行号 | 唯一不会 OOM 的区域 |
| 虚拟机栈 | 私有 | 栈帧(局部变量表/操作数栈/动态链接/返回地址) | StackOverflowError;线程过多时 unable to create new native thread |
| 本地方法栈 | 私有 | native 方法栈帧(HotSpot 与虚拟机栈合一) | 同上 |
| 堆 | 共享 | 对象实例、数组 | Java heap space |
| 方法区(JDK 8+ 为元空间) | 共享 | 类元数据、运行时常量池 | Metaspace |
| 直接内存 | 共享(不属于运行时数据区) | NIO 的 DirectByteBuffer | Direct buffer memory |
为什么这么设计:线程私有区随线程生灭,内存随栈帧入栈出栈自动回收,GC 完全不用管;共享区(堆/方法区)才是 GC 的战场。JDK 8 用元空间替换永久代后,类元数据改存本地内存,不再与堆抢 -Xmx,也不再因永久代固定上限频繁 OOM;字符串常量池更早(JDK 7)已挪进堆。
常见追问
- 栈深度不够是 SOF 还是 OOM?→ 单线程递归过深抛
StackOverflowError;不断创建线程把内存耗光才是 OOM。-Xss调大单栈容量,可创建线程数就变少,两者算的是同一笔账。 - 为什么用元空间替掉永久代?→ ①永久代大小要预设、很难估(动态代理/脚本引擎生成类多的应用常炸 PermGen);②类元数据的生命周期与类加载器绑定,放本地内存配合类卸载更自然;③为与无永久代的 JRockit 合流。注意元空间默认只受物理内存限制,生产必须用
-XX:MaxMetaspaceSize设上限。 - new 出来的对象一定在堆上吗?→ 不一定。JIT 逃逸分析判定不逃逸的对象可做标量替换(HotSpot 实际实现),字段拆散放栈上,连对象头都省了。
各区 OOM 的排查工具链见本篇”OOM 有哪几种?分别怎么排查?“。
对象创建过程
频次 ★★★ · 难度 🟡
- 类加载检查:检查类是否已加载、解析、初始化
- 分配内存:根据类信息计算对象大小,从堆中分配(指针碰撞或空闲列表)
- 初始化零值:将内存空间初始化为零值(保证字段默认值)
- 设置对象头:存储类元数据信息、哈希码、GC 分代年龄、锁状态等
- 执行
<init>方法:按程序员意图初始化对象
常见追问
- 并发分配内存怎么保证安全?→ 两招:CAS + 失败重试;或 TLAB(Thread Local Allocation Buffer)——每个线程在 Eden 预先划一小块私有缓冲,分配先走 TLAB,用完再同步申请新的,把竞争摊薄到”申请缓冲”这一步。
- 指针碰撞和空闲列表怎么选?→ 取决于堆是否规整,也就是取决于 GC 算法:复制/整理算法(Serial、ParNew、G1)堆规整用指针碰撞;标记-清除(CMS)有碎片,只能用空闲列表。
对象在内存中的布局
难度 🟡
对象头(Header) + 实例数据(Instance Data) + 对齐填充(Padding)
- 对象头:Mark Word(存储哈希码、GC 年龄、锁状态) + 类指针(指向 Class 对象)
- 实例数据:对象中定义的各种字段
- 对齐填充:JVM 要求对象大小是 8 字节的整数倍
二、垃圾回收
GC Roots 有哪些?
频次 ★★★★ · 难度 🟢
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI 引用的对象
- 活跃线程对象
强软弱虚四种引用
频次 ★★★★ · 难度 🟡
| 引用 | 回收时机 | 典型场景 |
|---|---|---|
强引用 Object o = new Object() | 永不回收(可达时) | 默认引用 |
软引用 SoftReference | 内存不足(OOM 前) 才回收 | 内存敏感缓存(图片缓存) |
弱引用 WeakReference | 下一次 GC 就回收 | ThreadLocalMap 的 key、WeakHashMap |
虚引用 PhantomReference | 随时,无法通过它拿到对象 | 配合 ReferenceQueue 跟踪回收时机,管理堆外内存(DirectByteBuffer 的 Cleaner) |
常见追问
- ThreadLocal 为什么用弱引用还会泄漏?→ key(ThreadLocal 对象)是弱引用会被回收,但 value 是强引用,线程不死(线程池)value 就一直挂在 ThreadLocalMap 里 → 必须手动
remove()。弱引用只解决了 key 的泄漏,还留下 key 为 null 的 stale entry。 - 软引用适合做缓存吗?→ 谨慎。回收时机由 JVM 决定,接近 OOM 时集中清空导致缓存雪崩 + Full GC 变长;生产缓存更推荐 Caffeine 这类带容量/过期策略的库。
GC 算法:标记-清除、标记-复制、标记-整理怎么选?
频次 ★★★★ · 难度 🟡
是什么:
| 算法 | 做法 | 优点 | 代价 | 用在哪 |
|---|---|---|---|---|
| 标记-清除(Mark-Sweep) | 标记存活对象,原地清掉垃圾 | 不搬对象,最快 | 内存碎片;分配要维护空闲列表 | CMS 老年代 |
| 标记-复制(Copying) | 存活对象整体搬到另一块空区 | 无碎片,分配用指针碰撞;存活越少越快 | 常驻浪费一块空间;存活多时搬运贵 | 新生代(Eden + S0/S1) |
| 标记-整理(Mark-Compact) | 标记后把存活对象向一端压缩 | 无碎片、不浪费空间 | 移动对象 + 更新所有引用,STW 长 | 老年代(Parallel Old、G1 整体视角) |
为什么这么设计(分代假说):绝大多数对象朝生夕灭(弱分代假说),熬过多次 GC 的对象大概率继续活(强分代假说)→ 新生代存活率低,复制算法只搬极少数存活对象最划算;老年代存活率高,复制不划算,改用清除或整理。Eden:S0:S1 = 8:1:1 也是这个假说的落地——只拿 10% 空间做复制缓冲,Survivor 装不下时由老年代做分配担保兜底。
常见追问
- 为什么 Survivor 要两块?→ 复制算法的目标区必须是干净的连续空间,两块轮换(from/to)才能保证每次 Minor GC 都往空区搬、搬完即无碎片;一块做不到。
- Minor GC 时老年代指向新生代的引用怎么发现?→ 跨代引用靠记忆集(卡表):把老年代划成 512B 的卡页,写屏障把”可能存在跨代引用”的卡页标脏,Minor GC 只扫脏卡,不用扫整个老年代。G1 的 RSet 是同一思想按 Region 细化,见”G1 收集器原理”。
- 标记-清除的碎片什么时候爆雷?→ 总空闲足够却放不下一个大对象时,被迫提前 Full GC——CMS 的 concurrent mode failure 常见诱因之一。
对象什么时候会从新生代晋升到老年代?
频次 ★★★ · 难度 🟡
- 年龄阈值:对象在 Survivor 区熬过一次 Minor GC 年龄 +1,达到
MaxTenuringThreshold(默认 15)时晋升 - 大对象直接进入:超过
-XX:PretenureSizeThreshold的大对象直接分配到老年代 - 动态年龄判定:Survivor 区中同一年龄的对象大小总和超过 Survivor 区的一半时,大于等于该年龄的对象直接晋升
三色标记:并发标记为什么会漏标?怎么解决?
频次 ★★★★ · 难度 🔴
是什么:并发标记阶段给对象染三种颜色描述扫描进度。
| 颜色 | 含义 |
|---|---|
| 白 | 未被扫描到;标记结束仍是白色 → 回收 |
| 灰 | 自身已扫描,但它引用的对象还没扫完 |
| 黑 | 自身和引用都扫描完毕,不会再重新扫描 |
为什么会出问题:标记和用户线程并发执行,对象引用关系在标记过程中会变。
- 多标(浮动垃圾):已标记存活的对象随后变成垃圾 → 本轮不回收,留到下轮,无害。
- 漏标(致命):同时满足两个条件时,存活对象会被当垃圾回收:
- 用户线程把一个白色对象挂到黑色对象下(黑色不再扫描)
- 同时删除了所有灰色对象到该白色对象的引用(灰色扫不到它了)
怎么解决:破坏两个条件之一即可,两大流派对应两种屏障:
| 方案 | 破坏的条件 | 做法 | 使用者 |
|---|---|---|---|
| 增量更新(Incremental Update) | 条件 1 | 写屏障记录”黑→白”的新增引用,重新标记阶段把黑色对象重扫一遍 | CMS |
| 原始快照(SATB, Snapshot At The Beginning) | 条件 2 | 写屏障记录”被删除的旧引用”,按标记开始那一刻的快照视角,被删对象本轮仍算存活 | G1、Shenandoah |
常见追问
- 为什么 G1 选 SATB 而 CMS 选增量更新?→ SATB 重新标记只需扫屏障记录的旧引用,停顿更短更可控(G1 的卖点是可预测停顿);代价是快照视角产生更多浮动垃圾。增量更新要重扫黑色对象,重新标记停顿更长。
- ZGC 为什么不用这套?→ ZGC 用读屏障 + 染色指针(把标记位存在 64 位指针的高位上),加载引用时通过读屏障自愈修正,标记/转移全程并发,停顿与堆大小无关。
G1 收集器原理
频次 ★★★★ · 难度 🔴
是什么:JDK 9+ 默认收集器。把堆划分为约 2048 个大小相等的 Region(1~32MB),Eden/Survivor/Old 不再物理连续,而是 Region 的逻辑集合;超过 Region 一半的大对象放 Humongous 区。
为什么这么设计:
- 可预测停顿:
-XX:MaxGCPauseMillis(默认 200ms)是目标而非命令。G1 跟踪每个 Region 的回收价值(能回收多少空间/要花多少时间),每次只挑收益最高的一部分 Region 回收(Garbage First 名字的由来),用停顿预测模型控制单次 GC 时长。 - Region 化解决碎片:整体是标记-整理,Region 之间是复制算法,不产生 CMS 式的碎片。
回收过程:
- Young GC:Eden 满触发,存活对象复制到 Survivor/Old Region,STW 但很短
- 并发标记周期:老年代占比超过
-XX:InitiatingHeapOccupancyPercent(默认 45%)触发,含初始标记(STW,借 Young GC 顺带做)→ 并发标记 → 最终标记(STW,处理 SATB 记录)→ 清理 - Mixed GC:并发标记结束后的若干次 Young GC 顺带回收部分高收益 Old Region
跨 Region 引用怎么处理:每个 Region 有 RSet(Remembered Set),记录”谁引用了我”(points-into),配合卡表实现。Young GC 时只需扫 RSet 而不用扫全堆。代价:RSet 占用约 10~20% 额外内存,写屏障维护有开销。
常见追问
- G1 什么时候会退化成 Full GC?→ Mixed GC 回收速度赶不上分配速度(并发模式失败)、Humongous 分配失败、转移时 Survivor/Old 装不下(to-space exhausted)。退化后是单线程/多线程的整堆压缩(JDK 10+ 并行化),停顿秒级。排查方向:增大堆、调低 IHOP 让并发标记提早、排查大对象。
- 为什么大对象是”半个 Region”为界?→ 连续 Region 分配 Humongous 开销大且回收时机特殊(Young GC / Full GC / 并发清理才回收),大量短命大对象是 G1 的典型性能杀手。
什么时候该用什么 GC 收集器?
频次 ★★★ · 难度 🟢
| 收集器 | 适用场景 |
|---|---|
| Parallel GC | 后台计算,追求高吞吐 |
| G1(默认) | 大堆内存(数 GB+),兼顾吞吐和延迟 |
| ZGC | 超大堆(TB 级),停顿 < 1ms |
| Shenandoah | 大堆低延迟,类似 ZGC |
| Serial GC | 小内存 / 客户端应用 |
CMS 和 ParNew 已在 JDK 14 被移除,不再适用于现代环境。
安全点与安全区域
频次 ★★★ · 难度 🔴
是什么:GC 需要 STW 时,线程不能停在任意位置——安全点(Safepoint) 是线程状态确定、栈上引用关系明确的位置(方法调用、循环回跳、异常跳转处)。
为什么需要:可达性分析要求引用关系冻结。JVM 用主动式中断:设置一个全局标志,线程执行到安全点时轮询该标志,发现置位就挂起。所以 STW 时间 = 等所有线程走到安全点 + GC 本身。
常见追问
- 大循环为什么会拖长 STW?→ 可数循环(int 计数)默认不在循环内插安全点轮询,一个千万次的纯计算循环会让其他所有线程干等它跑完才能开始 GC。JDK 10+ 的 Loop Strip Mining 缓解;排查用
-XX:+PrintSafepointStatistics看 sync 时间。 - 线程 sleep/blocked 了走不到安全点怎么办?→ 安全区域(Safe Region):进入该区域前声明”这段代码不改引用关系”,GC 不必等它;线程离开安全区域时检查 GC 是否完成,未完成则挂起。
JIT 编译优化
频次 ★★★★ · 难度 🔴
是什么:HotSpot 对热点代码(调用次数达阈值的方法/循环)从字节码编译为本地机器码,避免重复解释执行。JVM 启动时解释执行,热点代码触发编译,兼顾启动速度和峰值性能。
分层编译(Tiered Compilation,JDK 8+ 默认):
| 层 | 模式 | 说明 |
|---|---|---|
| 0 | 解释执行 | 收集调用次数、分支 profiler 信息 |
| 1 | C1 简单编译 | client 编译器,轻度优化,尽快出码 |
| 2 | C1 有限 profiling | 部分收集 profiler 信息 |
| 3 | C1 完全 profiling | 收集所有 profiler 信息 |
| 4 | C2 终极编译 | server 编译器,最激进优化,编得慢但代码最优 |
核心优化技术:
- 方法内联:最基础的优化,把被调方法代码”复制”到调用处,消除调用开销。内联阈值
-XX:MaxInlineSize=35字节,热点方法会突破。JVM 根据调用计数和分支 profiler 决定是否内联——多态调用(接口方法)有类型 profiling 支持的”内联缓存”可大幅降低虚方法开销 - 逃逸分析:分析对象作用域,不逃逸则做标量替换(拆散字段放栈上)或栈上分配(实际是标量替换的别名),连对象头都省了
- 循环优化:循环展开(减少分支)、循环剥离(Loop Strip Mining,把长循环切成带安全点轮询的短段)、循环向量化(自动使用 SIMD 指令)
- 锁消除:逃逸分析判定锁对象不共享时,直接去掉
synchronized——所以局部锁变量即使加了 synchronized 也不会真正加锁 - 栈上替换(OSR,On-Stack Replacement):长循环在解释执行期间就触发了编译,编译完成后直接在当前栈帧中替换成编译后代码继续执行,不必等当前方法返回再启用
常见追问:
- 为什么 Java 启动慢但越跑越快?→ 启动时纯解释执行慢,热点代码逐步触发 C1→C2 编译,编译后代码质量越来越高。所以压测要有 warm-up 阶段
- 为什么第一次调用比后续慢很多?→ 类加载、链接、解释执行 + profiler 收集。C2 编译本身也要消耗 CPU 和内存(编译线程占资源)
- 如何判断方法已被 JIT 编译?→
-XX:+PrintCompilation打印编译日志;JFR 的jdk.Compilation事件 - 什么情况会导致 JIT 失效(退化为解释执行)?→
-Xint强制解释;C2 编译太慢触发-XX:CompileThreshold回退;-XX:CompileCommand=exclude显式排除;反优化(deoptimization)发生时
三、类加载
类加载过程:一个类从 class 文件到可用要经历什么?
频次 ★★★★ · 难度 🟡
是什么:加载 → 验证 → 准备 → 解析 → 初始化 五个阶段(解析可以晚于初始化,为运行期动态绑定留口子):
| 阶段 | 干什么 | 关键点 |
|---|---|---|
| 加载 | 读字节流 → 方法区的类数据结构 + 堆中 Class 对象 | 数组类由 JVM 直接创建,不经类加载器 |
| 验证 | 文件格式/元数据/字节码/符号引用四道检查 | 保证字节流不会危害 JVM |
| 准备 | 静态变量分配内存并赋零值 | static int a = 1 此阶段 a=0;static final int a = 1(ConstantValue)此阶段直接是 1 |
| 解析 | 符号引用 → 直接引用 | |
| 初始化 | 执行 <clinit>():静态变量赋值 + 静态块按源码顺序合并 | JVM 保证 <clinit> 加锁且只执行一次 |
为什么这么设计:初始化刻意惰性——只有首次主动使用才触发(new、读写静态变量、调静态方法、反射、初始化子类、main 所在类),保证”用到才付出成本”;<clinit> 的加锁单次语义把类初始化的线程安全从程序员手里拿走,由 JVM 兜底——静态内部类单例线程安全的根据就在这里。
常见追问
ClassLoader.loadClass和Class.forName的区别?→ 前者只加载不初始化;后者默认初始化(JDBC 老写法Class.forName("com.mysql...")就是靠初始化触发驱动静态块里的registerDriver)。- 访问
static final常量会触发类初始化吗?→ 不会。编译期常量在编译时已折叠进调用方的常量池,字节码层面根本不引用定义类;换成运行期才能确定的值(static final String s = UUID.randomUUID().toString())就会触发。 - 双亲委派是什么、防什么?→ Bootstrap ← Platform(JDK 8 叫 Ext)← App 三层,loadClass 先委托父加载器,父找不到才自己加载。保证核心类全局唯一(自己写的
java.lang.String永远轮不到加载)+ 防篡改。被破坏的场景见下节。
双亲委派的破坏场景
频次 ★★★★ · 难度 🟡
以下场景需要打破双亲委派:
- SPI 机制:如 JDBC 驱动加载,父加载器(Bootstrap)需要加载子加载器(Application)路径下的类,通过
Thread.currentThread().getContextClassLoader()解决;ServiceLoader 懒加载源码与 Dubbo SPI 对比见Java基础”SPI 机制”一节 - Tomcat:每个 Web 应用有自己的 ClassLoader,优先加载自己的类(WebappClassLoader),实现应用隔离
- 热部署/热替换:自定义 ClassLoader 每次加载新版本 class 文件
类卸载的苛刻条件
难度 🟡
需要 同时满足 三点:
- 该类所有实例都已被回收
- 加载该类的 ClassLoader 已被回收
- 该类的 Class 对象无任何引用
四、调优与排障
JVM 调优的方法论:从目标到证据链
频次 ★★★★ · 难度 🔴
是什么:调优三步,顺序不能反——
- 定目标:吞吐、延迟、内存占用三者取舍(P99 停顿 < 50ms?还是批处理吞吐最大?没有目标就没有”调好了”的标准)
- 拿证据:开 GC 日志(JDK 9+ 用
-Xlog:gc*:file=gc.log,JDK 8 用-XX:+PrintGCDetails)、jstat -gcutil看趋势、JFR 抓采样 - 一次只改一个变量:改完压测对比指标,否则不知道是哪个改动起了作用
GC 日志怎么看(抓三类信息):
- 频率:Young GC 多久一次 → 反映分配速率
- 停顿:Pause Young / Mixed / Full 的耗时分布,长尾是谁贡献的
- 回收效果:每次 GC 后堆占用回到多少——老年代水位是否持续上涨是最关键的一条线
症状 → 方向速查:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| Young GC 频繁但停顿短 | 新生代太小 / 分配速率高 | 增大新生代;找临时对象大户 |
| Full GC 频繁 | 晋升过快 / 内存泄漏 / 元空间满 | HeapDump + MAT;查动态类生成 |
| 单次停顿特别长 | 安全点等待 / Humongous / 系统 swap | safepoint 日志;禁 swap;查大对象 |
| CPU 高但吞吐低 | 堆太小反复 GC,GC 线程抢 CPU | 加堆或找泄漏 |
为什么不能只调参数:参数是果不是因——大多数”GC 问题”实际是代码问题(无界缓存、大结果集查询、循环里拼字符串)。先用 MAT 看对象分布再谈参数;上来就抄一套”神参数”是背题选手的标志。
常见追问
- 生产常开 GC 日志代价大吗?→ 几乎为零,应该常开;
-Xlog:gc*支持 filecount/filesize 滚动,不会撑爆磁盘。 - 怎么判断该扩堆还是该修代码?→ 看 Full GC 后老年代能否回到低位:能回去 = 容量问题,扩堆有效;回不去且持续上涨 = 泄漏,扩堆只是推迟爆炸,走内存泄漏排查(见下文)。
JVM 常用调优参数
频次 ★★★ · 难度 🟢
| 参数 | 作用 |
|---|---|
-Xms / -Xmx | 堆初始 / 最大大小 |
-Xss | 单线程栈大小 |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时生成堆快照 |
-XX:MaxGCPauseMillis | G1 最大停顿时间目标 |
-XX:+UseZGC | 启用 ZGC 收集器 |
-XX:+PrintGCDetails | 打印 GC 详细日志 |
如何排查内存泄漏?
频次 ★★★★ · 难度 🟡
步骤:
- 添加 JVM 参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof - 发生 OOM 时自动生成堆快照
- 用 MAT / JProfiler 分析,看 支配树(Dominator Tree) 找出占用最大的对象
- 查看 引用链(GC Root Path) 确定是谁持有了泄漏对象
常见内存泄漏原因:
- 静态集合无意识缓存大量对象
- 未关闭资源(连接、流等)
- ThreadLocal 未 remove
- 事件监听器未注销
OOM 有哪几种?分别怎么排查?
频次 ★★★★ · 难度 🟡
| OOM 类型 | 报错信息 | 常见原因 | 排查 |
|---|---|---|---|
| 堆溢出 | Java heap space | 内存泄漏 / 堆太小 / 大查询加载过多数据 | HeapDump + MAT 支配树(见”如何排查内存泄漏”) |
| 元空间 | Metaspace | 动态生成类失控(CGLIB 代理、Groovy 脚本、反射滥用) | -XX:MaxMetaspaceSize 限制 + jcmd 看类加载数量 |
| 直接内存 | Direct buffer memory | NIO/Netty 堆外内存未释放,-XX:MaxDirectMemorySize 不足 | NMT(Native Memory Tracking)、Netty 泄漏检测 |
| 无法创建线程 | unable to create new native thread | 线程数超系统限制(ulimit)、线程栈总量耗尽 | jstack 数线程 + 检查线程池是否无界 |
| 栈溢出 | StackOverflowError | 递归过深、-Xss 太小 | 看栈轨迹的重复帧 |
| GC 开销超限 | GC overhead limit exceeded | 98% 时间在 GC 但只回收 2% 内存,堆接近满 | 同堆溢出 |
答题要点:先说 OOM ≠ 只有堆,报错信息直接指向区域;再按区域给排查工具链。可衔接生产排障的内存上涨排查流程。
五、JIT 编译与 JVM 底层
堆外内存(直接内存)是什么?与堆内存有何区别?
频次 ★★ · 难度 🟡
是什么:堆外内存(Direct Memory)是 JVM 堆之外、由操作系统直接分配的内存。Java 通过 ByteBuffer.allocateDirect() 或 Unsafe.allocateMemory()(不推荐)分配。不受 GC 管理,由开发者通过 Cleaner 机制或手动释放。
优势:NIO 进行 I/O 操作时,堆外内存可以不经过堆内缓冲区直接与操作系统 I/O 交互(零拷贝的一个环节),避免”堆内缓冲区 → 堆外缓冲区 → 内核”的额外拷贝。
风险:堆外内存不会自动 GC 回收,释放依赖 Cleaner(ByteBuffer 被 GC 时清理器线程释放堆外内存),但 GC 时机不确定,高并发下可能堆外内存堆积触发 OOM。-XX:MaxDirectMemorySize 限制堆外内存上限。
排查:NMT(-XX:NativeMemoryTracking=summary + jcmd <pid> VM.native_memory)查看堆外内存用量;pmap -x <pid> 查看进程内存映射。
对象的组成:对象头、实例数据、对齐填充
频次 ★★ · 难度 🟡
是什么:JVM 中每个对象 = 对象头 + 实例数据 + 对齐填充。
- 对象头(Object Header):Mark Word(8 字节,存 GC 分代年龄/hashCode/锁状态/偏向线程 ID 等)+ Klass Pointer(4/8 字节,指向方法区中类的元数据)。压缩指针默认开启(
-XX:+UseCompressedOops),64 位下 Klass Pointer 压缩到 4 字节。 - 实例数据:各字段按类型对齐排列,字段重排序(相同宽度字段放一起)减少对齐填充。
- 对齐填充:HotSpot 要求对象起始地址为 8 字节整数倍,不足时填充补全。
常见追问:为什么对象头要用 Mark Word 存这么多信息?→ 同一时间 Mark Word 只存一种状态(如锁状态),这些信息是互斥的——偏向锁状态不存 hashCode,GC 时不存锁信息。这是”空间复用”:一个 8 字节区域在不同阶段扮演不同角色。
栈帧是什么?包含哪些信息?
频次 ★★ · 难度 🟡
是什么:每个方法调用对应一个栈帧(Stack Frame),压入虚拟机栈,方法返回时弹出。栈帧包含:
- 局部变量表:方法参数 + 方法内局部变量,以 slot 为单位(32 位 1 slot,64 位 2 slot)。
this在实例方法中占第 0 个 slot - 操作数栈:执行指令时的临时数据区,指令从局部变量表取数压栈→运算→结果写回
- 动态链接:指向运行时常量池中该方法的符号引用
- 返回地址:方法调用位置的下一条指令地址
JIT 编译是什么?如何优化热点代码?
频次 ★★ · 难度 🔴
是什么:JIT(Just-In-Time)编译器在运行时将热点字节码编译为本地机器码,避免解释执行的开销。HotSpot 采用解释器 + JIT 混合模式:先解释执行,统计方法/循环的调用计数,达到阈值后触发 JIT 编译。
C1(Client Compiler)vs C2(Server Compiler):C1 编译快但优化少(适合 GUI 需要快速启动),C2 编译慢但优化激进(适合服务端长期运行)。Java 8+ 默认分层编译:先 C1(带计数),热点再 C2 重编译。
JIT 关键优化:方法内联、逃逸分析(栈上分配/同步消除/标量替换)、循环展开、死代码消除、分支预测。
常见追问:怎么判断方法被 JIT 编译了?→ -XX:+PrintCompilation 输出编译日志,带 % 的是 OSR(On-Stack Replacement,循环中触发的编译),不带的是普通编译。
逃逸分析是什么?对性能有什么影响?
频次 ★★ · 难度 🟡
是什么:JIT 分析对象的作用域——如果对象只在方法内使用(不逃逸),可以栈上分配(随栈帧销毁自动回收,不走 GC)、同步消除(去掉对该对象的 synchronized)、标量替换(对象字段拆成独立局部变量,不再分配对象)。
常见追问:逃逸分析是默认开启的吗?→ 是的(-XX:+DoEscapeAnalysis),但分析成本随方法复杂度上升,过于复杂的对象图可能放弃分析。
方法内联是什么?对性能有什么影响?
频次 ★★ · 难度 🟡
是什么:JIT 将方法调用处直接替换为被调用方法的代码体,消除方法调用的开销(栈帧创建/销毁、参数传递、返回值处理)。方法内联是 JIT 最重要的优化——没有内联,后续的逃逸分析、锁消除等优化都没有足够的上下文分析。
内联条件:热点方法、方法体不太大(-XX:MaxInlineSize=35 字节码)、非虚方法优先(静态/私有/构造器/final)。虚方法通过**类层次分析(CHA)**判断单态/多态调用,单态可内联。
字符串常量池是什么?如何实现的?
频次 ★★ · 难度 🟡
是什么:字符串常量池(String Pool)是堆中一块特殊区域,存储唯一的字符串字面量。String s = "hello" 会先去常量池查找,有则复用,无则创建;new String("hello") 在堆上创建新对象(但引用的 char[] 仍指向常量池中的字符数组,JDK 9+ 为 byte[])。
intern() 方法:手动将字符串加入常量池。s.intern() 返回常量池中该字符串的引用——如果常量池已有,返回已有引用;如果没有,将 s 的引用放入常量池并返回(JDK 7+ 行为,6 之前是复制一份)。
常见追问:字符串常量池为什么移到堆?→ JDK 7 从 PermGen 移到堆。PermGen 空间固定且小,大量字符串(如国际化、动态拼接)容易撑爆;堆空间大且可 GC 回收常量池中不再使用的字符串。