面试追问地图

主问题必讲关键点下一层追问
运行时内存堆、虚拟机栈、方法区、直接内存每个区域可能出现什么 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、类冲突
字符串常量池字面量复用、internJDK 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 的 DirectByteBufferDirect buffer memory

为什么这么设计:线程私有区随线程生灭,内存随栈帧入栈出栈自动回收,GC 完全不用管;共享区(堆/方法区)才是 GC 的战场。JDK 8 用元空间替换永久代后,类元数据改存本地内存,不再与堆抢 -Xmx,也不再因永久代固定上限频繁 OOM;字符串常量池更早(JDK 7)已挪进堆。

常见追问

  • 栈深度不够是 SOF 还是 OOM?→ 单线程递归过深抛 StackOverflowError;不断创建线程把内存耗光才是 OOM。-Xss 调大单栈容量,可创建线程数就变少,两者算的是同一笔账。
  • 为什么用元空间替掉永久代?→ ①永久代大小要预设、很难估(动态代理/脚本引擎生成类多的应用常炸 PermGen);②类元数据的生命周期与类加载器绑定,放本地内存配合类卸载更自然;③为与无永久代的 JRockit 合流。注意元空间默认只受物理内存限制,生产必须用 -XX:MaxMetaspaceSize 设上限。
  • new 出来的对象一定在堆上吗?→ 不一定。JIT 逃逸分析判定不逃逸的对象可做标量替换(HotSpot 实际实现),字段拆散放栈上,连对象头都省了。

各区 OOM 的排查工具链见本篇”OOM 有哪几种?分别怎么排查?“。

对象创建过程

频次 ★★★ · 难度 🟡

  1. 类加载检查:检查类是否已加载、解析、初始化
  2. 分配内存:根据类信息计算对象大小,从堆中分配(指针碰撞或空闲列表)
  3. 初始化零值:将内存空间初始化为零值(保证字段默认值)
  4. 设置对象头:存储类元数据信息、哈希码、GC 分代年龄、锁状态等
  5. 执行 <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 区的一半时,大于等于该年龄的对象直接晋升

三色标记:并发标记为什么会漏标?怎么解决?

频次 ★★★★ · 难度 🔴

是什么:并发标记阶段给对象染三种颜色描述扫描进度。

颜色含义
未被扫描到;标记结束仍是白色 → 回收
自身已扫描,但它引用的对象还没扫完
自身和引用都扫描完毕,不会再重新扫描

为什么会出问题:标记和用户线程并发执行,对象引用关系在标记过程中会变。

  • 多标(浮动垃圾):已标记存活的对象随后变成垃圾 → 本轮不回收,留到下轮,无害。
  • 漏标(致命):同时满足两个条件时,存活对象会被当垃圾回收:
    1. 用户线程把一个白色对象挂到黑色对象下(黑色不再扫描)
    2. 同时删除了所有灰色对象到该白色对象的引用(灰色扫不到它了)

怎么解决:破坏两个条件之一即可,两大流派对应两种屏障:

方案破坏的条件做法使用者
增量更新(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 式的碎片。

回收过程

  1. Young GC:Eden 满触发,存活对象复制到 Survivor/Old Region,STW 但很短
  2. 并发标记周期:老年代占比超过 -XX:InitiatingHeapOccupancyPercent(默认 45%)触发,含初始标记(STW,借 Young GC 顺带做)→ 并发标记 → 最终标记(STW,处理 SATB 记录)→ 清理
  3. 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 信息
1C1 简单编译client 编译器,轻度优化,尽快出码
2C1 有限 profiling部分收集 profiler 信息
3C1 完全 profiling收集所有 profiler 信息
4C2 终极编译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.loadClassClass.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 文件

类卸载的苛刻条件

难度 🟡

需要 同时满足 三点:

  1. 该类所有实例都已被回收
  2. 加载该类的 ClassLoader 已被回收
  3. 该类的 Class 对象无任何引用

四、调优与排障

JVM 调优的方法论:从目标到证据链

频次 ★★★★ · 难度 🔴

是什么:调优三步,顺序不能反——

  1. 定目标:吞吐、延迟、内存占用三者取舍(P99 停顿 < 50ms?还是批处理吞吐最大?没有目标就没有”调好了”的标准)
  2. 拿证据:开 GC 日志(JDK 9+ 用 -Xlog:gc*:file=gc.log,JDK 8 用 -XX:+PrintGCDetails)、jstat -gcutil 看趋势、JFR 抓采样
  3. 一次只改一个变量:改完压测对比指标,否则不知道是哪个改动起了作用

GC 日志怎么看(抓三类信息):

  • 频率:Young GC 多久一次 → 反映分配速率
  • 停顿:Pause Young / Mixed / Full 的耗时分布,长尾是谁贡献的
  • 回收效果:每次 GC 后堆占用回到多少——老年代水位是否持续上涨是最关键的一条线

症状 → 方向速查

症状可能原因排查方向
Young GC 频繁但停顿短新生代太小 / 分配速率高增大新生代;找临时对象大户
Full GC 频繁晋升过快 / 内存泄漏 / 元空间满HeapDump + MAT;查动态类生成
单次停顿特别长安全点等待 / Humongous / 系统 swapsafepoint 日志;禁 swap;查大对象
CPU 高但吞吐低堆太小反复 GC,GC 线程抢 CPU加堆或找泄漏

为什么不能只调参数:参数是果不是因——大多数”GC 问题”实际是代码问题(无界缓存、大结果集查询、循环里拼字符串)。先用 MAT 看对象分布再谈参数;上来就抄一套”神参数”是背题选手的标志。

常见追问

  • 生产常开 GC 日志代价大吗?→ 几乎为零,应该常开;-Xlog:gc* 支持 filecount/filesize 滚动,不会撑爆磁盘。
  • 怎么判断该扩堆还是该修代码?→ 看 Full GC 后老年代能否回到低位:能回去 = 容量问题,扩堆有效;回不去且持续上涨 = 泄漏,扩堆只是推迟爆炸,走内存泄漏排查(见下文)。

JVM 常用调优参数

频次 ★★★ · 难度 🟢

参数作用
-Xms / -Xmx堆初始 / 最大大小
-Xss单线程栈大小
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时生成堆快照
-XX:MaxGCPauseMillisG1 最大停顿时间目标
-XX:+UseZGC启用 ZGC 收集器
-XX:+PrintGCDetails打印 GC 详细日志

如何排查内存泄漏?

频次 ★★★★ · 难度 🟡

步骤

  1. 添加 JVM 参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof
  2. 发生 OOM 时自动生成堆快照
  3. 用 MAT / JProfiler 分析,看 支配树(Dominator Tree) 找出占用最大的对象
  4. 查看 引用链(GC Root Path) 确定是谁持有了泄漏对象

常见内存泄漏原因

  • 静态集合无意识缓存大量对象
  • 未关闭资源(连接、流等)
  • ThreadLocal 未 remove
  • 事件监听器未注销

OOM 有哪几种?分别怎么排查?

频次 ★★★★ · 难度 🟡

OOM 类型报错信息常见原因排查
堆溢出Java heap space内存泄漏 / 堆太小 / 大查询加载过多数据HeapDump + MAT 支配树(见”如何排查内存泄漏”)
元空间Metaspace动态生成类失控(CGLIB 代理、Groovy 脚本、反射滥用)-XX:MaxMetaspaceSize 限制 + jcmd 看类加载数量
直接内存Direct buffer memoryNIO/Netty 堆外内存未释放,-XX:MaxDirectMemorySize 不足NMT(Native Memory Tracking)、Netty 泄漏检测
无法创建线程unable to create new native thread线程数超系统限制(ulimit)、线程栈总量耗尽jstack 数线程 + 检查线程池是否无界
栈溢出StackOverflowError递归过深、-Xss 太小看栈轨迹的重复帧
GC 开销超限GC overhead limit exceeded98% 时间在 GC 但只回收 2% 内存,堆接近满同堆溢出

答题要点:先说 OOM ≠ 只有堆,报错信息直接指向区域;再按区域给排查工具链。可衔接生产排障的内存上涨排查流程。


五、JIT 编译与 JVM 底层

堆外内存(直接内存)是什么?与堆内存有何区别?

频次 ★★ · 难度 🟡

是什么:堆外内存(Direct Memory)是 JVM 堆之外、由操作系统直接分配的内存。Java 通过 ByteBuffer.allocateDirect()Unsafe.allocateMemory()(不推荐)分配。不受 GC 管理,由开发者通过 Cleaner 机制或手动释放。

优势:NIO 进行 I/O 操作时,堆外内存可以不经过堆内缓冲区直接与操作系统 I/O 交互(零拷贝的一个环节),避免”堆内缓冲区 → 堆外缓冲区 → 内核”的额外拷贝。

风险:堆外内存不会自动 GC 回收,释放依赖 CleanerByteBuffer 被 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 回收常量池中不再使用的字符串。