JVM 面试八股文30条(深度详解版)

📖 学习指南

🎯 学习目标:通过本文,你将系统掌握 JVM 的核心知识,能够自信地应对任何相关面试问题。

适合人群

  • 🔰 初学者:想系统学习 JVM 的开发者
  • 🚀 有经验者:想深入理解 JVM 原理的高级开发者
  • 💼 面试准备者:想刷 JVM 面试题的求职者

学习建议

  1. 先理解概念,再深入原理:先搞懂”是什么”,再搞懂”为什么”
  2. 结合实战场景:不要死记硬背,要理解实际应用场景
  3. 动手实践:在自己电脑上安装环境,执行本文的示例代码
  4. 反复复习:面试前一周,每天复习 10 个问题

学习时间估算

  • ⏱️ 快速复习(只看一句话总结):2 小时
  • 📚 系统学习(看深度解析):1-2 天
  • 💪 深入理解(研究源码):1 周+

🗺️ 知识图谱

mindmap
  root((JVM))
    内存区域
      堆 Heap
      虚拟机栈
      本地方法栈
      程序计数器
      方法区/元空间
    对象与引用
      对象内存布局
      内存分配策略
      四种引用类型
    垃圾回收
      GC Roots 与可达性分析
      三色标记与漏标
      标记-清除/复制/整理
    垃圾收集器
      CMS
      G1
      ZGC
      Shenandoah
    类加载机制
      双亲委派模型
      类加载五阶段
      自定义类加载器
    调优与诊断
      JVM 参数调优
      GC 日志分析
      OOM 排查
      JIT 与逃逸分析

⚠️ 常见陷阱与误区

陷阱 1:以为”方法区”就是”永久代”

❌ 错误理解:

1
2
3
4
# 方法区 = 永久代(PermGen),这是错的
# 方法区是《Java 虚拟机规范》中定义的一块"逻辑区域"(规范概念)
# 永久代(JDK 7 及之前)和元空间 Metaspace(JDK 8+)只是 HotSpot 对方法区的两种"实现"
# 严格来说不能划等号,就像"车"和"汽车"的关系一样

✅ 正确做法:

  • 回答时说”方法区的 HotSpot 实现,在 JDK 8 之前是永久代,JDK 8 之后是元空间”,而不是笼统地说”方法区就是永久代”

陷阱 2:误以为 Full GC 一定会回收老年代所有垃圾,或一定伴随 STW 很久

❌ 错误理解:

  • 认为 Full GC 结束后老年代一定会变干净、内存占用一定大幅下降
  • 认为所有收集器的 Full GC 停顿都很长

✅ 正确做法:

  • Full GC 只是回收范围更大(新生代+老年代+方法区),是否”彻底”取决于可达性分析的结果,和 Minor GC 一样只回收不可达对象
  • G1、ZGC 等现代收集器已经大幅压缩了 Full GC 的停顿时间,不能一概而论

陷阱 3:误以为调用 System.gc() 一定会触发 GC

❌ 错误理解:

1
System.gc(); // 以为这行代码执行后一定会立刻发生一次 Full GC

✅ 正确做法:

  • System.gc() 只是建议 JVM 尽快进行一次垃圾回收,是否真正执行、何时执行由 JVM 自行决定
  • 可以用 -XX:+DisableExplicitGC 直接屏蔽这个建议,线上代码应避免手动调用 System.gc()

💡 面试技巧

技巧 1:结构化回答

不要只回答”是什么”,要按照以下结构回答:

  1. 一句话总结(概念)
  2. 深度解析(原理、实现、优缺点)
  3. 面试加分回答(实际项目经验、源码理解、行业最佳实践)

技巧 2:结合实战场景

不要只背概念,要结合实际项目经验回答。

技巧 3:引导到你会的方向

如果遇到不会的问题,不要慌,可以引导到你会的方向。


🎯 实战演练(真实面试场景)

场景 1:线上服务频繁 Full GC,你怎么排查?

回答思路:

  1. 观察现象:用 jstat -gcutil <pid> 1000 观察老年代(O)使用率和 GC 频率,确认是不是真的频繁 Full GC,而不是 Minor GC
  2. 抓取证据:开启/查看 GC 日志(-Xlog:gc*),结合 jmap -histo:live <pid> 或 -XX:+HeapDumpOnOutOfMemoryError 拿到的堆转储文件(用 MAT/JProfiler 分析)
  3. 定位原因:常见原因排查顺序——① 内存泄漏(某类对象数量持续增长不释放)② 大对象/大集合一次性加载 ③ Survivor 空间过小导致对象过早晋升老年代 ④ 显式调用了 System.gc() ⑤ 元空间不足(类加载过多,如动态代理、热部署)
  4. 给出方案:根据定位结果对症下药,比如调大堆/调整新生代比例、修复代码中的泄漏点、更换为低停顿收集器(G1/ZGC)

🚀 学习路径总结

第一阶段:基础概念(1-2 天)

  • 理解核心概念
  • 掌握基本操作
  • 完成入门教程

第二阶段:高级特性(2-3 天)

  • 掌握高级特性
  • 理解实现原理
  • 完成进阶教程

第三阶段:实战应用(1 周+)

  • 搭建实际项目
  • 解决实战问题
  • 阅读源码(可选)

第四阶段:面试准备(1 周)

  • 刷完本文的所有问题
  • 复习相关知识点
  • 准备项目经验
  • 模拟面试

📚 扩展学习资源

官方资源

书籍推荐

  • 《深入理解Java虚拟机》(周志明 著,第 3 版)
  • 《Java性能权威指南》(Scott Oaks 著)

博客推荐


JVM 面试八股文30条(深度详解版)

本文力求把每个知识点讲透,不只是背答案,而是理解「为什么」。
适用于 Java 后端开发面试深入复习,建议结合实战场景理解。


一、JVM 内存区域(1-6)


第 1 题:JVM 的内存区域是怎么划分的?

一句话结论

JVM 内存 = 堆 + 虚拟机栈 + 本地方法栈 + 程序计数器 + 元空间。Java 8 之后,方法区(永久代)被元空间(Metaspace)取代。


深度解析

JVM 内存区域全景图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────────────────────────┐
│ JVM 内存区域 │
├──────────────────┬──────────────────────────────────┤
│ │ 元空间(Metaspace) │
│ │ (类元数据、常量池、方法字节码) │
│ ├──────────────────────────────────┤
│ 所有线程共享 │ 堆(Heap) │
│ │ (对象实例、数组) │
│ │ 分代:新生代 + 老年代 │
├──────────────────┼──────────────────────────────────┤
│ │ 虚拟机栈(Stack) │
│ 线程私有 │ (栈帧:局部变量表、操作数栈) │
│ │ 本地方法栈(Native 方法) │
│ │ 程序计数器(PC Register) │
└──────────────────┴──────────────────────────────────┘

用图区分「线程私有」和「线程共享」两栏,并标注每块区域会不会 OOM / StackOverflowError(面试常问):

flowchart TB
    subgraph PRIVATE["线程私有(每个线程一份)"]
        direction TB
        PC["程序计数器 PC Register\n不会 OOM\n不会 StackOverflowError\n(唯一不会有内存溢出问题的区域)"]
        VMS["虚拟机栈 JVM Stack\n会 StackOverflowError(栈深度超限)\n会 OOM(-Xss 设太大,线程数过多)"]
        NMS["本地方法栈 Native Method Stack\nHotSpot 中与虚拟机栈合二为一\n同样会 StackOverflowError / OOM"]
    end

    subgraph SHARED["线程共享(所有线程一份)"]
        direction TB
        HEAP["堆 Heap\n会 OOM:Java heap space\n对象实例、数组\n新生代 + 老年代"]
        META["方法区 / 元空间 Metaspace\n会 OOM:Metaspace\n类元数据、运行时常量池、JIT 编译代码"]
    end

各区域详解:

① 程序计数器(PC Register)

1
2
3
4
// 代码示例
int a = 1; // 字节码偏移量 0
int b = 2; // 字节码偏移量 1
int c = a + b; // 字节码偏移量 2

程序计数器记录的是当前线程正在执行的字节码指令的地址。

  • 如果执行的是 Java 方法,存的是字节码指令地址;
  • 如果执行的是 Native 方法,存的是 undefined。

为什么需要程序计数器?
→ 线程切换后,要恢复到正确的执行位置。
为什么是线程私有?
→ 每个线程的执行位置不同,不能共享。


② 虚拟机栈(JVM Stack)

1
2
3
4
5
6
7
8
public void methodA() {
int x = 1; // ← 栈帧 A 的局部变量表
methodB();
}

public void methodB() {
int y = 2; // ← 栈帧 B 的局部变量表
}

栈的结构:

1
2
3
4
5
6
7
8
9
10
栈(线程私有):
[栈帧 B] ← 栈顶(正在执行的方法)
| 局部变量表
| 操作数栈
| 动态链接
| 方法返回地址
[栈帧 A]
| ...
[栈帧 main]
| ...

栈帧(Stack Frame):每个方法执行时都会创建一个栈帧,方法结束时栈帧出栈。

局部变量表:存放方法参数和局部变量(编译时确定大小)。
操作数栈:字节码指令的工作区(如 iadd 会从操作数栈弹出两个数,相加后压栈)。

StackOverflowError:

1
2
3
4
5
// 无限递归 → 栈溢出
public void infiniteRecursion() {
infiniteRecursion(); // 不断压栈,直到栈满
}
// 报错:Exception in thread "main" java.lang.StackOverflowError

③ 本地方法栈(Native Method Stack)

和虚拟机栈类似,但是为 Native 方法(如 System.currentTimeMillis())服务。
HotSpot 虚拟机中,本地方法栈和虚拟机栈是合二为一的。


④ 堆(Heap)★ 最重要的区域

1
2
3
// 所有对象实例和数组都在堆上分配
Object obj = new Object(); // obj 是引用(在栈上),对象是实例(在堆上)
int[] arr = new int[100]; // 数组也在堆上

堆的结构(分代收集理论):

1
2
3
4
5
6
7
8
9
10
11
12
堆(所有线程共享):
┌────────────────────────────────┐
│ 新生代(Young) │
│ ┌─────────┬─────────┬──────┐ │
│ │ Eden │ From │ To │ │
│ │ (伊甸园)│(幸存区) │(幸存区)│ │
│ │ 80% │ 10% │ 10% │ │
│ └─────────┴─────────┴──────┘ │
├────────────────────────────────┤
│ 老年代(Old) │
│ (存活时间长的对象) │
└────────────────────────────────┘

为什么分代?
→ 研究表明:大多数对象都是「朝生夕死」的(IBM 研究:98% 的对象活不过一次 GC)。
→ 分代后,可以对不同代使用不同的垃圾回收算法,提高 GC 效率。


⑤ 元空间(Metaspace,Java 8+)

Java 8 之前:方法区 = 永久代(PermGen),在堆上,容易 OOM。
Java 8 之后:方法区 = 元空间(Metaspace),在本地内存(Native Memory)中,不再受 -XX:MaxPermSize 限制。

元空间中存放什么?

1
2
3
4
5
// 以下信息都存放在元空间中:
1. 类的元数据(Class 对象、字段、方法、字节码)
2. 运行时常量池(Runtime Constant Pool)
3. 方法字节码
4. JIT 编译后的本地代码缓存

为什么要用元空间替代永久代?

1
2
3
4
5
6
7
8
// Java 6 及之前:字符串常量池在永久代,容易 OOM
String s1 = "abc";
String s2 = new String("abc");
// 如果程序中大量使用字符串 intern,永久代会 OOM

// Java 7 起:字符串常量池移到堆中(与永久代是否被元空间取代无关)
// Java 8:方法区的实现从永久代改为元空间,使用本地内存
// → 默认不限制大小(受物理内存限制),不容易 OOM

面试加分回答

「JVM 内存区域是理解 GC、OOM、性能调优的基础。面试时经常追问:字符串常量池在哪里? 答案是:Java 7 之前在永久代,Java 7 开始移到堆中。另外,直接内存(Direct Memory) 不是 JVM 运行时数据区的一部分,但 NIO 的 DirectByteBuffer 会分配直接内存,不受 Java 堆限制,所以 -Xmx 设置得再大,也可能发生 OOM(直接内存溢出)。」


第 2 题:Java 8 为什么要移除永久代(PermGen),引入元空间(Metaspace)?

一句话结论

永久代容易 OOM(大小固定,难调优),元空间使用本地内存(理论上无限),且 GC 效率更高(元空间中的类元数据只在 ClassLoader 卸载时回收,不需要频繁 GC)。


深度解析

永久代的问题:

1
2
3
// 永久代的大小由 -XX:MaxPermSize 决定(如 256MB)
// 如果加载的类太多(如用了大量动态代理、反射、OSGi),会 OOM:
// Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

问题 ①:大小难调
→ 永久代的大小在启动时固定(-XX:MaxPermSize),如果设置太小,容易 OOM;如果设置太大,浪费内存。

问题 ②:GC 复杂
→ 永久代在堆上,会被 Full GC 回收(永久代的 GC 和老年代绑定),但类元数据的回收条件很苛刻(ClassLoader 卸载),导致 Full GC 频繁但效果不好。


元空间的优势:

1
2
3
4
// 元空间默认不受限制(受物理内存限制)
// 也可以手动设置上限:
-XX:MetaspaceSize=128m // 初始大小(触发 GC 的阈值)
-XX:MaxMetaspaceSize=512m // 最大大小(防止无限制增长)

优势 ①:自动扩容
→ 元空间使用本地内存,可以动态扩容(直到物理内存耗尽)。

优势 ②:GC 更简单
→ 元空间中的类元数据只在 ClassLoader 卸载 时回收(此时元数据全部可回收),不需要像永久代那样复杂的可达性分析。

优势 ③:字符串常量池移到堆中
→ Java 7 开始,字符串常量池从永久代移到堆中,便于 GC 回收不再使用的字符串。


元空间的内存管理:

1
2
3
4
// 元空间的内存分配:
1. 当需要存储类元数据时,从操作系统申请内存
2. 当类元数据不再使用时(ClassLoader 卸载),释放内存给操作系统
3. 元空间有专门的垃圾回收器(Metaspace GC),但不频繁触发

面试加分回答

「元空间和永久代的区别是 JVM 内存管理的一个经典改进。面试时可能会追问:元空间会不会 OOM? 答案是:会!虽然元空间默认不限制大小,但如果设置了 -XX:MaxMetaspaceSize,或者物理内存耗尽,仍然会 OOM(MetaspaceError)。另外,元空间的 GC 触发条件是:元空间使用率达到 -XX:MetaspaceSize 时触发 Full GC,所以如果元空间增长很快(如大量使用动态代理),需要调整 MetaspaceSize 避免频繁 Full GC。」


第 3 题:对象的创建过程是怎样的?

一句话结论

new Object() 的过程 = 类加载检查 → 分配内存 → 初始化零值 → 设置对象头 → 执行 <init> 方法(构造函数)。


深度解析

完整流程:

1
Object obj = new Object();

步骤 ①:类加载检查

1
2
3
// JVM 遇到 new 指令时,先检查:
1. 这个类是否已经被加载、解析、初始化?
2. 如果没有 → 执行类加载过程(见第 16 题)

步骤 ②:分配内存

1
// 对象需要多大内存,在类加载完成后就已经确定(对象头 + 实例数据 + 对齐填充)

内存分配的两个方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
方式 1:指针碰撞(Bump the Pointer)
→ 适用于:堆内存规整(没有内存碎片)
→ 原理:用一个指针分隔「已用内存」和「空闲内存」
分配内存时,指针向空闲端移动对象大小的距离

|已用内存| 空闲内存 |
↑ 指针
→ 分配 16 字节:指针向后移动 16 字节

方式 2:空闲列表(Free List)
→ 适用于:堆内存不规整(有内存碎片)
→ 原理:JVM 维护一个「空闲内存块列表」
分配时从列表中找到足够大的块

哪种方式取决于垃圾收集器是否有压缩整理功能:

  • 指针碰撞:Serial、ParNew(标记-复制算法,内存规整)
  • 空闲列表:CMS(标记-清除算法,会产生碎片)

并发安全问题:

1
2
3
4
5
6
// 问题:多个线程同时 new 对象,可能分配到同一块内存!
// 解决方式 1:CAS + 失败重试(乐观锁)
// 解决方式 2:TLAB(Thread Local Allocation Buffer,推荐)
// 每个线程在堆中预先分配一小块内存(TLAB)
// 线程分配对象时,优先在自己的 TLAB 中分配(无锁)
// TLAB 用完或不够时,再用 CAS 分配

步骤 ③:初始化零值

1
2
3
4
// 内存分配完后,JVM 将分配到的内存空间(不包括对象头)初始化为零值
int a; // 这里会是 0(不是 null,也不是随机值)
boolean flag; // 这里会是 false
Object obj; // 这里会是 null

这步操作保证了 Java 代码中的成员变量可以不赋初值就直接使用(有默认零值)。


步骤 ④:设置对象头(Object Header)

1
2
3
4
5
// 对象头(Object Header)包含两部分信息:
1. Mark Word(标记字段,8 字节)
→ 存储:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID
2. Klass Pointer(类型指针,4 或 8 字节)
→ 指向该对象的类元数据(存在元空间中)

Mark Word 的状态变化:

1
2
3
4
5
6
7
8
9
10
Mark Word(32 位 JVM,25 位可用):
┌──────────────────────────────────────────────┐
│ 状态 │ 存储内容 │
├──────────────────────────────────────────────┤
│ 未锁定 │ 哈希码 + 分代年龄 + 无锁标志 │
│ 偏向锁 │ 偏向线程 ID + 时间戳 + 分代年龄│
│ 轻量级锁 │ 指向栈中锁记录的指针 │
│ 重量级锁 │ 指向互斥量(Monitor)的指针 │
│ GC 标记 │ 空(CMS 垃圾收集时使用) │
└──────────────────────────────────────────────┘

步骤 ⑤:执行 <init> 方法(构造函数)

1
2
3
4
5
6
7
8
9
10
11
12
public class Person {
private int age = 25; // 实例变量初始化
private String name = "Alice";

public Person() {
// 构造函数体
}
}

// 编译后,实例变量初始化和构造函数体合并到 <init> 方法中
// JVM 执行完对象头设置后,执行 <init> 方法
// → 这时 age = 25, name = "Alice"

面试加分回答

「对象的创建过程涉及很多细节,面试时经常追问:对象一定在堆上分配吗? 答案是:不一定!JVM 有 逃逸分析(Escape Analysis) 优化,如果一个对象没有逃逸出方法(即不会被其他线程访问),JVM 可以在栈上分配对象(栈上分配的对象,方法结束时自动销毁,不需要 GC)。另外,标量替换(把对象拆成基本类型)也是逃逸分析的一个优化手段。」


第 4 题:对象的内存布局是怎样的?

一句话结论

对象在内存中 = 对象头(Header)+ 实例数据(Instance Data)+ 对齐填充(Padding)。对象头包含 Mark Word 和 Klass Pointer。


深度解析

对象内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────┐
│ Java 对象在内存中 │
├──────────────┬──────────────────────────────────┤
│ 对象头 │ Mark Word(8 字节) │
│ (Header) │ Klass Pointer(4/8 字节) │
├──────────────┼──────────────────────────────────┤
│ 实例数据 │ 成员变量 a(4 字节) │
│ (Instance │ 成员变量 b(8 字节) │
│ Data) │ ... │
├──────────────┼──────────────────────────────────┤
│ 对齐填充 │ 填充到 8 字节的倍数 │
│ (Padding) │ │
└──────────────┴──────────────────────────────────┘

对象头详解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Mark Word(8 字节,64 位 JVM):
┌──────────────────────────────────────────────────────┐
│ 位数(从高位到低位) │ 含义 │
├──────────────────────────────────────────────────────┤
│ 25 位 │ 未使用 │
│ 31 位 │ 哈希码(调用 hashCode() 后填充)│
│ 4 位 │ 分代年龄(最大 15,因为 4 位)│
│ 1 位 │ 偏向锁标志 │
│ 2 位 │ 锁状态标志 │
│ 1 位 │ GC 标记 │
└──────────────────────────────────────────────────────┘

// Klass Pointer(类型指针):
→ 指向该对象的类元数据(在元空间中)
→ 开启压缩指针(-XX:+UseCompressedOops,默认开启)时占 4 字节
→ 不开启时占 8 字节

实例数据(Instance Data):

1
2
3
4
5
6
public class Person {
private int age; // 4 字节
private String name; // 引用类型,开启压缩后 4 字节
private long salary; // 8 字节
private byte gender; // 1 字节(但会对齐到 4 或 8 的倍数)
}

字段重排(Field Reordering):
JVM 为了节省空间,会对字段进行重排(把相同大小的字段放在一起):

1
2
3
4
5
6
7
8
9
10
// 源码中的顺序:
int a; // 4 字节
byte b; // 1 字节
long c; // 8 字节

// 内存中的顺序(可能):
long c; // 8 字节
int a; // 4 字节
byte b; // 1 字节
padding; // 3 字节(对齐到 8 的倍数)

对齐填充(Padding):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 为什么需要对齐?
// → CPU 读取内存时,一次读取 8 字节(64 位系统)
// → 如果对象大小不是 8 的倍数,可能需要两次读取
// → 对齐后,一次读取就能拿到整个对象头

// 示例:
public class A {
int a; // 4 字节
// 对象头 12 字节 + 实例数据 4 字节 = 16 字节(已经是 8 的倍数,不需要填充)
}

public class B {
byte b; // 1 字节
// 对象头 12 字节 + 实例数据 1 字节 = 13 字节
// → 需要填充 3 字节,凑到 16 字节(8 的倍数)
}

面试加分回答

「对象的内存布局是理解 synchronized 锁升级、对象哈希码、GC 分代年龄的基础。面试时可能会追问:为什么 GC 分代年龄最大是 15? 答案是:分代年龄存储在 Mark Word 的 4 位中(2^4 = 16,但 0 表示未经过 GC,所以最大是 15)。另外,对象的哈希码什么时候计算? 答案是:第一次调用 hashCode() 时计算,并存储在 Mark Word 中(这也是为什么加了偏向锁的对象调用 hashCode() 会导致锁升级)。」


第 5 题:对象的访问定位方式有哪些?

一句话结论

Java 通过栈上的引用(Reference)访问堆中的对象,有两种方式:句柄访问 和 直接指针访问。HotSpot 使用直接指针访问。


深度解析

方式 ①:句柄访问(Handle Access)

1
2
3
4
5
6
7
8
栈(引用)                  堆
│
│ 引用(Reference)
│
└──→ 句柄池(Handle Pool)
│
├──→ 到对象实例数据的指针(在堆中)
└──→ 到对象类型数据的指针(在元空间中)

优点:引用中存储的是稳定的句柄地址,对象被移动(GC 时)时,只需要修改句柄中的指针,不需要修改引用本身。
缺点:多了一次指针定位的开销(访问对象需要两次指针跳转)。


方式 ②:直接指针访问(Direct Pointer Access)★ HotSpot 使用

1
2
3
4
5
6
7
栈(引用)                  堆
│
│ 引用(Reference)
│
└──→ 对象实例数据
│
└──→ 对象头中的 Klass Pointer → 元空间中的类元数据

优点:访问速度快(只需要一次指针跳转)。
缺点:对象被移动时(GC 时),需要修改所有对该对象的引用。


为什么 HotSpot 选择直接指针访问?

1
2
3
// 答案:访问对象的频率远高于对象移动的频率
// → 访问快带来的收益 > 对象移动时修改引用的成本
// → 所以选择直接指针访问

面试加分回答

「对象的访问定位方式是一个比较冷门但有趣的知识点。面试时可能会追问:引用(Reference)存在哪里? 答案是:局部变量(如 Object obj)存在虚拟机栈的局部变量表中;成员变量(如 class A { Object obj; })存在堆中的对象实例数据中。另外,Java 中的引用有四种类型(强引用、软引用、弱引用、虚引用),见第 9 题。」


第 6 题:OutOfMemoryError 有哪些类型?分别发生在什么区域?

一句话结论

OOM 有 5 种常见类型:Java 堆溢出、虚拟机栈/本地方法栈溢出、元空间溢出、直接内存溢出、GC Overhead Limit Exceeded。


深度解析

① Java 堆溢出(最常见)

1
2
3
4
5
6
7
8
9
// 错误:java.lang.OutOfMemoryError: Java heap space
// 原因:对象太多,堆内存不足以分配

// 复现代码:
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 不断往堆中放 1MB 的对象
}
// 解决:-Xmx 调大堆内存

② 虚拟机栈溢出(StackOverflowError)

1
2
3
4
5
6
7
8
9
10
// 错误:java.lang.StackOverflowError
// 原因:栈深度超过限制(如无限递归)

// 复现代码:
public void infiniteRecursion() {
infiniteRecursion();
}

// 另一种:栈容量不够(每个栈帧太大)
// 解决:-Xss 调大栈容量(如 -Xss2m)

注意:StackOverflowError 不是 OOM,是另一种错误(Error)。


③ 元空间溢出(Java 8+)

1
2
3
4
5
6
7
8
9
10
11
12
// 错误:java.lang.OutOfMemoryError: Metaspace
// 原因:加载的类太多(如大量使用动态代理、反射、OSGi)

// 复现代码(使用 CGLIB 动态代理):
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Person.class);
enhancer.setUseCache(false);
enhancer.create(); // 每次 create 都会生成一个新的类
}

// 解决:-XX:MaxMetaspaceSize 调大元空间上限

④ 直接内存溢出

1
2
3
4
5
6
7
8
9
// 错误:java.lang.OutOfMemoryError: Direct buffer memory
// 原因:NIO 的 DirectByteBuffer 分配的直接内存超出了限制

// 复现代码:
while (true) {
ByteBuffer.allocateDirect(1024 * 1024 * 100); // 每次分配 100MB 直接内存
}

// 解决:-XX:MaxDirectMemorySize 调大直接内存上限

⑤ GC Overhead Limit Exceeded

1
2
3
4
5
6
7
8
// 错误:java.lang.OutOfMemoryError: GC overhead limit exceeded
// 原因:GC 花费的时间太多,但回收的内存太少
// (JVM 认为:98% 的时间在 GC,但只回收了 < 2% 的内存 → 继续 GC 也没用)

// 解决:
// 1. 检查内存泄漏
// 2. 调大堆内存(-Xmx)
// 3. 禁用这个检查:-XX:-UseGCOverheadLimit

面试加分回答

「OOM 的排查是 Java 工程师的必备技能。生产环境中,OOM 的排查流程是:

  1. 查看错误日志,确定 OOM 类型;
  2. 如果是堆溢出,用 jmap -histo:live pid 查看堆中对象分布,找到占用内存最多的类;
  3. 用 jmap -dump:format=b,file=heap.hprof pid 导出堆转储文件,用 MAT(Memory Analyzer Tool)分析内存泄漏;
  4. 如果是元空间溢出,检查是否加载了太多类(如动态代理使用不当)。」


二、垃圾回收基础(7-10)


第 7 题:如何判断对象是否可以被回收?

一句话结论

Java 使用可达性分析算法(Reachability Analysis):从 GC Roots 出发,不可达的对象可以被回收。


深度解析

引用计数法(Reference Counting,Java 不用):

1
2
3
4
5
6
7
8
9
10
11
12
// 引用计数法:每个对象有一个引用计数器
// 被引用 +1,引用失效 -1,计数器 = 0 时可回收

Object A = new Object(); // A 的引用计数 = 1
Object B = A; // A 的引用计数 = 2
B = null; // A 的引用计数 = 1
A = null; // A 的引用计数 = 0 → 可回收

// 致命缺陷:循环引用无法回收!
Obj1.field = Obj2;
Obj2.field = Obj1;
// Obj1 和 Obj2 互相引用,但没有任何外部引用 → 应该回收,但计数器不为 0

可达性分析算法(Java 使用):

1
2
3
4
5
6
7
8
9
10
11
可达性分析:
从「GC Roots」出发,沿着引用链搜索
→ 能被搜索到的对象:存活
→ 不能被搜索到的对象:可回收

GC Roots 包括:
1. 虚拟机栈中引用的对象(局部变量)
2. 本地方法栈中 JNI 引用的对象
3. 元空间中类静态属性引用的对象
4. 元空间中常量引用的对象
5. 所有被同步锁(synchronized)持有的对象

图解:

1
2
3
4
5
6
7
8
9
GC Roots(根对象)
│
├──→ Object A ←──┐
│ │
├──→ Object B │
│ │
└──→ Object C →─┘ (C 被 A 引用,A 是 GC Root 可达的 → C 存活)

Object D(没有任何 GC Root 能到达)→ 可回收

面试加分回答

「可达性分析有个细节:枚举根节点(GC Roots Enumeration)需要 Stop The World(STW)。因为如果不暂停所有线程,根节点集合会不断变化,分析就不准确。但现代 GC(如 CMS、G1、ZGC)都做了优化,STW 的时间非常短(毫秒级)。另外,OopMap(Ordinary Object Pointer Map)是 HotSpot 用来快速枚举 GC Roots 的数据结构,不需要遍历整个栈找引用。」


第 8 题:GC Roots 有哪些?

一句话结论

GC Roots = 虚拟机栈引用 + 本地方法栈引用 + 静态变量 + 常量 + 被同步锁持有的对象 + Java 虚拟机内部引用。


深度解析

固定可作为 GC Roots 的对象(固定不变):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 虚拟机栈中引用的对象(正在执行的方法中的局部变量)
public void method() {
Object obj = new Object(); // obj 是 GC Root
// 方法执行期间,obj 不会被回收
}

// 2. 本地方法栈中 JNI(Native 方法)引用的对象
// 调用 C/C++ 代码时,C 代码持有的 Java 对象

// 3. 元空间中类静态属性引用的对象
static Object staticObj = new Object(); // staticObj 是 GC Root

// 4. 元空间中常量引用的对象
static final Object CONST_OBJ = new Object(); // CONST_OBJ 是 GC Root

// 5. 被同步锁(synchronized)持有的对象
synchronized (lockObj) {
// lockObj 是 GC Root(不会被回收)
}

// 6. Java 虚拟机内部的引用
// 如:系统类加载器、异常对象(ClassNotFoundException 等)、
// 类加载器、线程对象等

动态加入的 GC Roots(根据收集器不同):

1
2
3
4
5
6
7
8
CMS 收集器:
→ 年轻代中的对象,可能被老年代对象引用
→ 所以「老年代对象」也是 GC Roots(需要扫描老年代)

G1 收集器:
→ 每个 Region 有一个 Remembered Set(记忆集)
→ 记录了「其他 Region 中哪些对象引用了本 Region 的对象」
→ 这些引用也是 GC Roots

面试加分回答

「GC Roots 的枚举是 GC 的一个核心步骤。在分代收集中,Young GC 需要扫描老年代(因为老年代对象可能引用年轻代对象),这很慢。解决方式是 Card Table(卡表):把老年代划分为 512 字节的卡页,如果卡页中有对象引用了年轻代,就标记为「脏卡」,Young GC 时只需要扫描脏卡,不需要扫描整个老年代。」


第 9 题:Java 的四种引用类型?

一句话结论

强引用(不回收)> 软引用(内存不足时回收)> 弱引用(下次 GC 就回收)> 虚引用(随时可回收,用于回收通知)。


深度解析

① 强引用(Strong Reference)

1
2
3
4
5
6
7
Object obj = new Object();  // 这就是强引用

// 特点:
// → 只要强引用还在,对象永远不会被回收
// → 想要回收,只能把引用设为 null

obj = null; // 现在对象可以被 GC 回收了

强引用是 Java 中最常见的引用,几乎没有约束。


② 软引用(Soft Reference)

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.lang.ref.SoftReference;

Object obj = new Object();
SoftReference<Object> softRef = new SoftReference<>(obj);
obj = null; // 去掉强引用

// 特点:
// → 内存足够时,软引用对象不会被回收
// → 内存不足时(即将 OOM),软引用对象会被回收

// 使用场景:缓存!
// 如:从网络加载的图片,存到软引用中
// 内存够就用缓存,内存不够就回收,不会 OOM

③ 弱引用(Weak Reference)

1
2
3
4
5
6
7
8
9
10
11
import java.lang.ref.WeakReference;

Object obj = new Object();
WeakReference<Object> weakRef = new WeakReference<>(obj);
obj = null; // 去掉强引用

// 特点:
// → 只能存活到下一次 GC 之前
// → 下次 GC 时,无论内存是否足够,都会被回收

// 使用场景:WeakHashMap(ThreadLocal 中的 ThreadLocalMap 就是用弱引用)

ThreadLocal 的内存泄漏问题:

1
2
3
4
// ThreadLocalMap 的 key 是弱引用
// → 如果 ThreadLocal 没有外部强引用了,key(ThreadLocal)会被回收
// → 但 value 是强引用,不会被回收 → 内存泄漏!
// 解决:用完 ThreadLocal 后,调用 remove() 方法

④ 虚引用(Phantom Reference)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

Object obj = new Object();
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
obj = null;

// 特点:
// → 虚引用完全不影响对象的生命周期
// → 对象被回收时,虚引用会被放入 ReferenceQueue
// → 程序可以通过 ReferenceQueue 得知对象已被回收

// 使用场景:管理堆外内存(如 DirectByteBuffer)
// 当 DirectByteBuffer 被回收时,通过虚引用通知 JVM 释放堆外内存

面试加分回答

「四种引用的强度排序:强引用 > 软引用 > 弱引用 > 虚引用。面试时经常追问:ThreadLocal 为什么要用弱引用? 答案是:如果 key 是强引用,即使 ThreadLocal 的外部强引用被回收了,ThreadLocal 也不会被回收(因为 ThreadLocalMap 的 key 还持有强引用)→ 内存泄漏。用弱引用后,ThreadLocal 的外部强引用被回收时,ThreadLocal 会在下次 GC 时被回收,减少内存泄漏的风险(但 value 仍然需要手动 remove() 才能完全避免泄漏)。」


第 10 题:垃圾回收算法有哪些?

一句话结论

三种基础算法:标记-清除、标记-复制、标记-整理。分代收集理论:新生代用标记-复制,老年代用标记-清除或标记-整理。


深度解析

① 标记-清除算法(Mark-Sweep)

1
2
3
4
5
6
7
8
9
10
流程:
1. 标记阶段:从 GC Roots 出发,标记所有存活对象
2. 清除阶段:遍历整个堆,回收未标记的对象

优点:
→ 实现简单

缺点:
→ 效率不高(需要两次遍历)
→ 会产生内存碎片(存活对象散落各处,大对象可能分配失败)

② 标记-复制算法(Copying)★ 新生代使用

1
2
3
4
5
6
7
8
9
10
11
12
13
流程:
1. 把内存分成两块:From 和 To(大小相等)
2. 只使用 From 块
3. GC 时,把 From 中存活的对象复制到 To 块(连续存放)
4. 清空 From 块
5. 交换 From 和 To 的角色

优点:
→ 没有内存碎片(复制过去是连续的)
→ 效率高(只需要遍历一次)

缺点:
→ 浪费一半内存(From 和 To 各占 50%)

HotSpot 的优化(Eden + Survivor):

1
2
3
4
5
6
7
8
9
10
11
新生代(Young Gen):
Eden(伊甸园) :80%
From Survivor :10%
To Survivor :10%

GC 时:
→ Eden + From 中存活的对象 → 复制到 To
→ 清空 Eden + From
→ From 和 To 交换角色

→ 只浪费 10% 的内存(To 区),不是 50%!

③ 标记-整理算法(Mark-Compact)★ 老年代使用

1
2
3
4
5
6
7
8
9
10
11
12
流程:
1. 标记阶段:从 GC Roots 出发,标记所有存活对象
2. 整理阶段:把所有存活对象向一端移动(压缩)
3. 清理边界以外的内存

优点:
→ 没有内存碎片
→ 不浪费内存

缺点:
→ 移动对象成本高(需要更新所有引用该对象的指针)
→ STW 时间长

三种算法对比:

算法 是否碎片 是否浪费内存 STW 时间 适用场景
标记-清除 ❌ 有碎片 ✅ 不浪费 中等 老年代(CMS)
标记-复制 ✅ 无碎片 ❌ 浪费一半 短 新生代
标记-整理 ✅ 无碎片 ✅ 不浪费 长 老年代(Serial Old、Parallel Old)

面试加分回答

「标记-复制算法有一个有趣的细节:对象存活率很高时,复制算法的效率会很低(需要复制大量对象)。所以复制算法只适合对象存活率低的场景(新生代,98% 的对象活不过一次 GC)。另外,HotSpot 的 Survivor 区设计也是经过优化的:如果 Survivor 区太小,存活对象放不下,就会提前晋升到老年代(Premature Promotion),导致老年代增长过快,引发 Full GC。」


补充题:对象的内存分配策略是怎样的?(高频必考,面试必问)

一句话结论

对象优先在 Eden 区分配;大对象直接进入老年代;长期存活的对象按年龄晋升老年代;同时存在”动态年龄判定”和”空间分配担保”两条兜底规则。


深度解析

① 对象优先在 Eden 区分配

1
2
3
4
5
new 一个对象时:
1. JVM 优先尝试在 Eden 区分配内存(TLAB,线程本地分配缓冲区,避免加锁)
2. 如果 Eden 区空间不足,触发一次 Minor GC(Young GC)
3. Minor GC 后,Eden 中的存活对象被复制到 Survivor 区
4. 如果 Minor GC 后 Eden 仍然放不下新对象,再尝试扩容或直接分配到老年代

大多数对象都是”朝生夕死”的——这正是新生代用标记-复制算法、且 Eden:Survivor 默认按 8:1:1 划分的依据(见第 10 题)。


② 大对象直接进入老年代

1
2
3
4
5
6
// 通过 -XX:PretenureSizeThreshold 设置阈值(单位字节,只对 Serial、ParNew 有效)
-XX:PretenureSizeThreshold=3145728 // 3MB 以上的对象直接进入老年代

// 典型的大对象:
// 1. 很长的字符串
// 2. 元素很多的数组

为什么大对象要直接进入老年代?
→ 新生代用的是标记-复制算法,对象要在 Eden 和 Survivor 之间来回复制。
→ 如果大对象也走这条路,复制的成本非常高(内存拷贝本身就慢),还容易导致 Survivor 区放不下,提前触发 Minor GC 甚至 Full GC。
→ 让大对象一步到位分配到老年代,可以避免这种大量内存复制的开销。


③ 长期存活的对象将进入老年代(年龄晋升)

1
2
3
4
对象头(Mark Word)中有一个 4 位的 age 计数器:
1. 对象在 Eden 出生,第一次 Minor GC 存活并进入 Survivor,age = 1
2. 之后每熬过一次 Minor GC,age + 1
3. 当 age 达到阈值(默认 -XX:MaxTenuringThreshold=15),晋升到老年代
1
-XX:MaxTenuringThreshold=15   // 默认值;4 位存储,最大只能是 15

阈值设得越小,对象越容易晋升老年代(节省 Survivor 空间,但老年代压力大,Full GC 更频繁);设得越大,对象在新生代停留时间越长(Survivor 空间要求更高)。


④ 动态年龄判定(Dynamic Age Determination)

1
2
3
4
5
规则:
Survivor 空间中,相同年龄所有对象大小的总和,
如果 >= Survivor 空间的一半,
则年龄 >= 该年龄的对象直接晋升老年代,
不需要等到 MaxTenuringThreshold!

这是很多人不知道的细节:MaxTenuringThreshold 只是一个”上限”,不是必须等到的固定值。动态年龄判定的意义在于:与其让一批”注定要晋升”的同龄对象反复复制、拖慢 Minor GC,不如提前一次性晋升,腾出 Survivor 空间。


⑤ 空间分配担保(Handle Promotion Failure)

1
2
3
4
5
6
7
8
9
Minor GC 之前,JVM 会检查:
老年代最大可用连续空间 是否 > 新生代所有对象总空间(保守)
或者 是否 > 历次晋升到老年代的平均大小(激进,JDK 6 Update 24 起默认)

情况 A:空间足够 → 正常执行 Minor GC(安全)
情况 B:空间不够,但允许冒险(-XX:+HandlePromotionFailure)
→ 仍然尝试 Minor GC(赌 Survivor 能装下大部分存活对象)
→ 如果赌输了(Survivor 装不下,需要晋升的对象超过老年代剩余空间)→ 退化为一次 Full GC
情况 C:空间不够,且不允许冒险 → 直接改为 Full GC

历史与现状:-XX:+HandlePromotionFailure 参数在 JDK 6 Update 24 之前需要显式配置,之后已经是默认行为,参数本身被保留但不再需要手动开启,很多老资料仍会提到它,理解其含义即可,不必再手动配置。


⑥ Minor GC / Major GC / Full GC 触发条件对比

类型 回收区域 常见触发条件 STW 时长
Minor GC(Young GC) 新生代(Eden + Survivor) Eden 区空间不足 短(毫秒级)
Major GC(Old GC) 老年代 老年代空间不足(多数收集器实际以 Full GC 形式体现,单独的 Major GC 在 CMS 等场景下才有明确区分) 较长
Full GC 整个堆(新生代 + 老年代)+ 方法区/元空间 1. 老年代空间不足;2. 空间分配担保失败;3. 元空间不足;4. 显式调用 System.gc();5. CMS 出现 Concurrent Mode Failure 长(可达秒级,是调优的重点排查对象)

面试加分回答

「对象分配策略是分代收集理论在工程上的具体落地,面试时经常追问一个连环问题:为什么要有动态年龄判定? 因为固定的 MaxTenuringThreshold 是一刀切的:如果本轮 Minor GC 后,某个年龄段的对象已经占了 Survivor 一半以上空间,说明这批对象大概率会继续存活,与其让它们在 Eden/Survivor 之间反复复制拖慢下次 GC,不如直接晋升老年代,用空间换时间。另外,空间分配担保容易被问到”如果担保失败会怎样”——答案是退化为一次 Full GC,这也是为什么线上如果发现 Full GC 频繁,除了看老年代本身,还要排查新生代对象是否”晋升过快”(比如 Survivor 设置太小、或者业务代码里有大量长生命周期的临时对象)。」


三、垃圾收集器(11-17)


第 11 题:常见的垃圾收集器有哪些?

一句话结论

新生代收集器:Serial、ParNew、Parallel Scavenge;老年代收集器:Serial Old、Parallel Old、CMS;整堆收集器:G1(Java 7+)、ZGC(JDK 11 实验特性引入,JDK 15 转正为生产可用,JDK 21 引入分代 ZGC 并成为默认模式)、Shenandoah(Java 12+)。


深度解析

收集器的发展史:

1
2
3
4
5
6
7
8
9
Java 的发展:
Java 1.3:Serial(单线程,STW 很长)
Java 1.4:Parallel(多线程,但还是要 STW)
Java 5:CMS(第一款并发收集器,标记阶段可以与用户线程并发)
Java 7:G1(里程碑,不再物理分代,而是逻辑分代)
Java 11:ZGC 引入(实验特性,超低延迟 < 10ms)
Java 12:Shenandoah(类似 ZGC,OpenJDK 社区驱动)
Java 15:ZGC 转正为生产可用(不再是实验特性)
Java 21:分代 ZGC(Generational ZGC)引入,并成为 ZGC 的默认模式

各收集器特点:

收集器 作用区域 线程数 算法 目标 适用场景
Serial 新生代 单线程 标记-复制 简单高效(客户端) 客户端、嵌入式
Serial Old 老年代 单线程 标记-整理 简单 Client 模式
ParNew 新生代 多线程 标记-复制 配合 CMS 服务端(Java 8-)
Parallel Scavenge 新生代 多线程 标记-复制 吞吐量优先 后台计算
Parallel Old 老年代 多线程 标记-整理 吞吐量优先 配合 PS
CMS 老年代 并发 标记-清除 低延迟 互联网应用(Java 8-)
G1 整堆 并发 标记-整理+复制 可预测停顿 大内存(Java 8+ 推荐)
ZGC 整堆 并发 染色指针 超低延迟(< 10ms) 超大内存(JDK 11+ 实验,JDK 15+ 生产可用)

收集器的搭配关系:

1
2
3
4
5
6
合法的搭配:
Serial + Serial Old (客户端)
ParNew + CMS (Java 8 主流)
Parallel + Parallel Old (吞吐量优先)
G1(独立,不需要搭配) (Java 8+ 推荐)
ZGC(独立) (JDK 11+ 实验,JDK 15+ 生产可用,超低延迟)

Java 8 的默认收集器:Parallel Scavenge + Parallel Old(吞吐量优先)
Java 9+ 的默认收集器:G1


面试加分回答

「收集器的选择是一个经典问题。面试时经常问:你们的线上服务用的什么收集器?为什么? 典型回答:Java 8 用 CMS(低延迟),Java 8+ 用 G1(可预测停顿)。如果是大内存(> 32GB)+ 低延迟(< 10ms) 的场景,用 ZGC。另外,CMS 已经在 Java 9 被标记为 Deprecated,Java 14 被移除,所以新项目不要用 CMS 了。」


第 12 题:CMS 收集器的工作原理?

一句话结论

CMS(Concurrent Mark Sweep)= 一款并发的、低延迟的老年代收集器。流程:初始标记(STW)→ 并发标记 → 重新标记(STW)→ 并发清除。


深度解析

CMS 的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CMS 收集器(老年代):
┌─────────────────────────────────────────────┐
│ 阶段 1:初始标记(Initial Mark)【STW】 │
│ → 标记 GC Roots 直接关联的对象 │
│ → 速度很快(只标记直接关联) │
├─────────────────────────────────────────────┤
│ 阶段 2:并发标记(Concurrent Mark) │
│ → 与用户线程并发执行 │
│ → 从「初始标记」的结果出发,遍历整个对象 │
│ 图,标记所有存活对象 │
│ → 因为用户线程在运行,对象图可能变化 │
├─────────────────────────────────────────────┤
│ 阶段 3:重新标记(Remark)【STW】 │
│ → 修复「并发标记」期间用户线程修改的引用 │
│ → 使用「增量更新」算法 │
│ → 比初始标记慢,但比 Full GC 快很多 │
├─────────────────────────────────────────────┤
│ 阶段 4:并发清除(Concurrent Sweep) │
│ → 与用户线程并发执行 │
│ → 清除未标记的对象 │
│ → 不会产生内存碎片(标记-清除算法) │
└─────────────────────────────────────────────┘

CMS 的优点:

1
2
3
4
5
6
// 1. 低延迟(STW 时间短)
// → 初始标记和重新标记都是短暂停顿
// → 并发标记和并发清除不需要 STW

// 2. 适合响应时间敏感的应用
// → 如:电商网站、API 服务

CMS 的缺陷:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 缺陷 1:对 CPU 资源敏感
// → 并发标记和并发清除会占用一部分 CPU 资源
// → CPU 核心数少时(如 2 核),用户线程会变慢

// 缺陷 2:无法处理浮动垃圾
// → 「并发清除」阶段,用户线程还在产生垃圾
// → 这些垃圾叫「浮动垃圾」,本次 GC 无法回收
// → 需要等到下次 GC

// 缺陷 3:内存碎片(标记-清除算法)
// → 运行一段时间后,会产生大量内存碎片
// → 大对象可能无法分配,触发 Full GC(用标记-整理)
// → 解决:-XX:+UseCMSCompactAtFullCollection(默认开启)

// 缺陷 4:Concurrent Mode Failure
// → 并发清除时,老年代空间不足(浮动垃圾太多)
// → CMS 会失败,降级为 Serial Old(单线程,STW 很长)

面试加分回答

「CMS 的「重新标记」阶段使用了 写后屏障(Write Barrier) 来记录引用变化。具体来说,是 增量更新(Incremental Update) 算法:当黑色对象的引用变化时,把它标记为灰色(需要重新扫描)。另一个算法是 原始快照(SATB,G1 使用),记录删除前的引用关系。面试时可能会追问这两个算法的区别。」


第 13 题:G1 收集器的工作原理?

一句话结论

G1(Garbage First)= 面向服务端应用的收集器,把堆划分为多个大小相等的 Region,优先回收垃圾最多的 Region(Garbage First)。可预测停顿时间。


深度解析

G1 的内存布局(不再物理分代):

1
2
3
4
5
6
7
8
9
10
11
G1 的堆布局:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ R1 │ R2 │ R3 │ R4 │ R5 │ R6 │ R7 │ R8 │ ...
└────┴────┴────┴────┴────┴────┴────┴────┘

每个 Region(Region)大小相等(1MB ~ 32MB,必须是 2 的幂)
每个 Region 可以是:
→ Eden Region(新生代)
→ Survivor Region(幸存区)
→ Old Region(老年代)
→ Humongous Region(大对象,占连续多个 Region)

关键点:G1 不再是物理分代(固定新生代、老年代各占多少),而是逻辑分代(Region 的身份可以变化)。


G1 的收集流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
G1 收集器:
┌─────────────────────────────────────────────┐
│ 阶段 1:初始标记(Initial Mark)【STW】 │
│ → 借用在 Young GC 的暂停中完成 │
│ → 标记 GC Roots 直接关联的对象 │
├─────────────────────────────────────────────┤
│ 阶段 2:并发标记(Concurrent Mark) │
│ → 与用户线程并发执行 │
│ → 遍历对象图,标记存活对象 │
│ → 使用 SATB(原始快照)算法处理引用变化 │
├─────────────────────────────────────────────┤
│ 阶段 3:最终标记(Final Mark)【STW】 │
│ → 处理 SATB 缓冲区中剩余的引用变化 │
├─────────────────────────────────────────────┤
│ 阶段 4:筛选回收(Live Data Counting │
│ and Evacuation)【STW】 │
│ → 计算每个 Region 的回收价值(垃圾占比)│
│ → 优先回收垃圾最多的 Region(Garbage │
│ First!) │
│ → 把存活对象复制到空的 Region(标记-复制│
│ 算法,无碎片) │
└─────────────────────────────────────────────┘

G1 的核心优势:

1
2
3
4
5
6
7
8
9
10
// 1. 可预测的停顿时间
// → 可以设置 -XX:MaxGCPauseMillis=200(目标停顿时间)
// → G1 会优先回收垃圾最多的 Region,在停顿时间内完成尽可能多的回收

// 2. 无内存碎片(标记-复制算法)
// → 复制存活对象到新 Region,天然连续

// 3. 适合大内存(> 4GB)
// → CMS 在大内存下,Remark 阶段会很慢
// → G1 把堆分成多个 Region,每次只回收部分 Region

G1 的重要参数:

1
2
3
4
5
# G1 重要参数
-XX:+UseG1GC # 开启 G1
-XX:MaxGCPauseMillis=200 # 目标停顿时间(默认 200ms)
-XX:G1HeapRegionSize=4m # 每个 Region 的大小
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发 GC 的堆占用率(默认 45%)

面试加分回答

「G1 的 Humongous Object(大对象) 处理是一个细节:大小超过 Region 一半的对象,会被分配到 Humongous Region(连续的多个 Region)。大对象会直接进入老年代,且 G1 在 Full GC 时才会回收大对象。所以如果应用中大对象很多,会导致频繁 Full GC。解决方式:调大 Region 大小(-XX:G1HeapRegionSize),或减少大对象的使用。」


第 14 题:ZGC 的工作原理?

一句话结论

ZGC(JDK 11 引入,JDK 15 转正为生产可用,JDK 21 引入分代 ZGC 并成为默认模式)= 超低延迟的收集器(STW < 10ms),核心是「并发压缩」+「染色指针」+「内存多重映射」。


深度解析

ZGC 的设计目标:

1
2
3
4
5
// 1. 超低延迟:STW 时间 < 10ms(不管堆多大!)
// 2. 停顿时间不随堆大小增加而增加
// → 1GB 的堆,STW < 10ms
// → 1TB 的堆,STW 仍然 < 10ms
// 3. 对吞吐量影响 < 15%

ZGC 的核心技术:

① 染色指针(Colored Pointer)

1
2
3
4
5
6
7
8
9
10
11
12
// ZGC 把「标记信息」存在指针里(而不是对象头里)!
// 64 位指针中,高 18 位 unused,ZGC 用其中 4 位存标记信息:

指针结构(64 位):
┌──────────┬────────┬────────┬────────┬────────┬──────────┐
│ 高 18 位 │ Final │ Remap │ Marked │ Marked │ 低 42 位 │
│ (unused) │ izable │ │ 0 │ 1 │ (对象地址)│
└──────────┴────────┴────────┴────────┴────────┴──────────┘

→ Finalizable:是否可被 finalize()
→ Remap:是否指向最新的对象地址(因为 ZGC 会移动对象)
→ Marked0 / Marked1:标记位(用于并发标记)

为什么要把标记存在指针里?
→ 这样在标记阶段,不需要访问对象本身,只需要看指针的标记位!
→ 可以并发标记,不需要 STW!


② 内存多重映射(Multi-Mapping)

1
2
3
4
5
6
7
8
// ZGC 使用「内存多重映射」技术:
// 同一个物理内存页,映射到多个虚拟地址

// 原因:ZGC 在移动对象时,对象的地址会变
// → 如果使用传统的做法,需要修改所有指向该对象的引用(STW)
// → ZGC 的做法:把「新的虚拟地址」和「旧的虚拟地址」都映射到同一个物理页
// → 这样,旧引用仍然可以访问对象(通过多重映射)!
// → 然后 ZGC 并发地修改所有引用(不需要 STW)

③ 并发压缩(Concurrent Compaction)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ZGC 的 GC 周期:
┌─────────────────────────────────────────────┐
│ 阶段 1:初始标记(STW,很短 < 1ms) │
│ → 标记 GC Roots 直接关联的对象 │
├─────────────────────────────────────────────┤
│ 阶段 2:并发标记(并发) │
│ → 遍历对象图,标记存活对象 │
│ → 通过「染色指针」实现并发 │
├─────────────────────────────────────────────┤
│ 阶段 3:再标记(STW,很短) │
│ → 处理并发标记期间的引用变化 │
├─────────────────────────────────────────────┤
│ 阶段 4:并发转移准备(并发) │
│ → 选择需要回收的 Region │
├─────────────────────────────────────────────┤
│ 阶段 5:初始转移(STW,很短 < 1ms) │
│ → 转移 GC Roots 直接关联的对象 │
├─────────────────────────────────────────────┤
│ 阶段 6:并发转移(并发) │
│ → 转移剩余存活对象 │
│ → 通过「转发指针」实现并发 │
└─────────────────────────────────────────────┘

面试加分回答

「ZGC 的 转发指针(Forwarding Pointer) 是并发转移的关键:当对象被移动到新地址后,ZGC 在原对象地址放一个「转发指针」,指向新地址。这样,如果有其他线程访问旧地址,会通过转发指针自动跳转到新地址(不需要 STW 修改所有引用)。这个技术让 ZGC 的 STW 时间极低(< 10ms)。另外,ZGC 的版本演进要记清楚:JDK 11 引入(实验特性)→ JDK 15 转正为生产可用 → JDK 21 引入分代 ZGC(Generational ZGC)并成为默认模式,推荐大内存、低延迟场景使用。」


第 15 题:三色标记算法?

一句话结论

三色标记 = 白色(未标记)、灰色(自身标记,引用未标记)、黑色(自身和引用都已标记)。CMS 使用「增量更新」,G1 使用「原始快照(SATB)」来解决漏标问题。


深度解析

三色标记的定义:

1
2
3
4
5
6
7
8
9
白色(White):
→ 尚未被标记的对象(GC 开始时,所有对象都是白色)

灰色(Gray):
→ 已经被标记,但它的引用(字段)还没有被扫描

黑色(Black):
→ 已经被标记,且它的所有引用也都已经扫描过了
→ 黑色对象是「安全」的,不会被回收

标记过程:

1
2
3
4
5
6
7
8
9
10
11
初始状态:所有对象都是白色
↓
从 GC Roots 出发,把直接关联的对象标记为灰色
↓
从灰色对象出发,扫描它的所有引用:
→ 把白色引用标记为灰色
→ 把当前灰色对象标记为黑色
↓
重复,直到没有灰色对象
↓
剩余的白色对象 → 可回收

漏标问题(Dijkstra 算法):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 并发标记时,用户线程在运行,可能导致「漏标」:

初始状态:
A(黑) → B(灰) → C(白)

用户线程执行:
A.field = C; // 黑色对象 A 引用了白色对象 C
B.field = null; // 灰色对象 B 取消对 C 的引用

标记线程继续:
→ B 被标记为黑色(它的引用都扫描过了)
→ C 仍然是白色(因为 A 是黑色,不会被扫描)
→ C 被误回收!(但实际上 A 还引用着 C)

两种解决方案:

① 增量更新(Incremental Update,CMS 使用)

1
2
3
4
5
// 原理:当黑色对象的引用变化时,把它重新标记为灰色
// → A.field = C 时,A 被标记为灰色
// → 重新扫描 A 的引用,C 就会被标记

// 缺点:需要重新扫描黑色对象(可能重复工作)

② 原始快照(SATB,Snapshot-At-The-Beginning,G1 使用)

1
2
3
4
5
6
// 原理:当灰色对象的引用被删除时,记录下这个引用(理解为:默认引用还存在)
// → B.field = null 时,记录下「B 曾经引用过 C」
// → 继续把 C 标记为灰色(即使实际已经没有引用)

// 优点:不需要重新扫描黑色对象
// 缺点:可能「过度标记」(实际上已经不再引用的对象,仍然被标记为存活)

面试加分回答

「三色标记是并发 GC 的基础理论。面试时可能会追问:为什么 G1 用 SATB 而不是增量更新? 答案是:SATB 的写屏障(Write Barrier)开销更小(只需要记录删除的引用,不需要重新扫描黑色对象)。另外,ZGC 使用的是染色指针,不需要写屏障,所以 ZGC 的性能更好(对应用吞吐量的影响 < 15%,而 G1 可能影响 20%~30%)。」


第 16 题:记忆集(Remembered Set)和卡表(Card Table)?

一句话结论

记忆集(RSet)= 记录「其他 Region/代 中哪些对象引用了本 Region/代 的对象」。卡表(Card Table)= 记忆集的实现方式,把内存划分为卡页,标记哪些卡页中有跨代/跨 Region 引用。


深度解析

问题背景(分代收集):

1
2
3
4
5
Young GC 需要扫描:
→ 新生代(必然要扫描)
→ 老年代(因为老年代对象可能引用新生代对象!)

问题:如果每次 Young GC 都扫描整个老年代 → 太慢了!

卡表(Card Table):

1
2
3
4
5
6
7
8
卡表:
→ 把老年代划分为 512 字节的「卡页(Card Page)」
→ 每个卡页对应卡表中的 1 个 bit
→ 如果某个卡页中有对象引用了新生代对象 → 标记为「脏卡(Dirty Card)」

Young GC 时:
→ 只需要扫描「脏卡」对应的卡页
→ 不需要扫描整个老年代!

图解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
老年代(Old Gen):
┌────────┬────────┬────────┬────────┐
│ Card 0 │ Card 1 │ Card 2 │ Card 3 │ ...
└────────┴────────┴────────┴────────┘
(512B) (512B) (512B) (512B)

卡表(Card Table):
┌───┬───┬───┬───┬───┐
│ 0 │ 1 │ 0 │ 1 │...│
└───┴───┴───┴───┴───┘
│ │ │
│ 脏卡 │
│ │
↓ ↓
扫描 Card 1 扫描 Card 3

记忆集(Remembered Set,RSet):

1
2
3
4
5
6
7
8
G1 中的 RSet:
→ 每个 Region 有一个 RSet
→ RSet 记录了「哪些其他 Region 的对象引用了本 Region 的对象」

Why G1 需要 RSet?
→ G1 不再物理分代,而是把堆分成多个 Region
→ Young GC 时,需要找到「所有引用了新生代 Region 的对象」
→ RSet 就是这个索引!

RSet 的结构:

1
2
3
4
5
6
Region A 的 RSet:
→ 记录了:Region B、Region C、Region D 中有对象引用了 Region A 的对象

GC 时:
→ 只需要扫描 Region B、C、D 中的对应对象
→ 不需要扫描整个堆!

面试加分回答

「卡表和 RSet 是 JVM 性能优化的的重要技术。面试时可能会追问:写屏障(Write Barrier)是什么? 答案是:当程序修改对象引用时(如 obj.field = newObj),JVM 会执行一段额外的代码(写屏障),用来标记卡表(如果是跨代引用)或更新 RSet(如果是跨 Region 引用)。写屏障会有一定的性能开销,但比起扫描整个老年代/整个堆,这个开销是值得的。」


第 17 题:并发标记的问题(漏标、误标)?

一句话结论

并发标记的核心问题:1. 漏标(对象应该存活但被回收了,严重!)→ 用增量更新或 SATB 解决;2. 误标(对象应该被回收但仍然存活,无害)→ 下次 GC 会回收。


深度解析

漏标(Missing Mark,严重问题):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 场景:
初始状态(并发标记期间):
A(已标记,黑色) → B(已标记,灰色) → C(未标记,白色)

用户线程执行:
A.x = C; // 黑色对象 A 引用了白色对象 C
B.x = null; // 灰色对象 B 取消对 C 的引用

标记线程继续:
→ B 扫描完成,标记为黑色
→ C 仍然是白色(A 是黑色,不会扫描 A 的引用)
→ C 被回收!

问题:A.x 仍然引用着 C,但 C 已经被回收了 → 程序崩溃!

漏标是一个”时序问题”,光看文字很难建立直觉,下面用三个时间点拆解同一个场景:

flowchart TB
    subgraph t0["t0:并发标记开始前"]
        direction LR
        A0["A(黑色)\n已标记,已扫描完引用"] -->|引用| B0["B(灰色)\n已标记,引用还没扫描完"]
        B0 -->|引用| C0["C(白色)\n尚未标记"]
    end

    subgraph t1["t1:并发标记进行中,用户线程并发修改引用"]
        direction LR
        A1["A(黑色)"] -->|"新增引用 A.field = C"| C1["C(白色)"]
        B1["B(灰色)"] -.->|"引用被置空 B.field = null"| C1
    end

    subgraph t2["t2:标记结束,若没有屏障介入"]
        direction LR
        A2["A(黑色,不会再被扫描)"] -->|"引用仍指向 C"| C2["C(依然是白色 = 未标记)"]
        C2 -->|"清除阶段"| GONE["❌ 被当作垃圾误回收\n但 A 仍在引用 C → 悬挂指针 / 崩溃"]
    end

    t0 --> t1 --> t2

漏标发生需要同时满足两个条件(缺一不可):

  • 条件 1:至少有一个黑色对象新增了对白色对象的引用(如上图 A.field = C)
  • 条件 2:至少有一个灰色对象删除了对该白色对象的引用(如上图 B.field = null)

两种解决方案,分别”堵”住上面两个条件之一:

flowchart LR
    subgraph SATB["SATB(原始快照,G1 使用)"]
        direction TB
        S1["拦住条件 2:\n灰色对象删除引用时"] --> S2["写前屏障记录\n被删除的旧引用"] --> S3["旧引用指向的对象\n本轮当作存活处理\n(哪怕已经不可达)"]
    end

    subgraph INC["增量更新(CMS 使用)"]
        direction TB
        I1["拦住条件 1:\n黑色对象新增引用时"] --> I2["写后屏障把该\n黑色对象重新标记为灰色"] --> I3["灰色对象会被\n重新扫描一遍引用"]
    end

一句话区分:SATB 记录的是”删除前”的旧引用(保证开始并发标记那一刻的对象图快照里可达的对象都不会被漏掉);增量更新记录的是”新增”的引用(让黑色对象重新变灰、重新扫描)。

解决方案(见第 15 题):

  • 增量更新(CMS):黑色对象新增引用时,重新标记为灰色
  • 原始快照 SATB(G1):灰色对象删除引用时,记录旧引用,确保对象不被漏标

误标(Over-Marking,无害问题):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 场景:
初始状态:
A(黑色) → B(灰色) → C(白色)

用户线程执行:
B.x = null; // B 不再引用 C

标记线程继续:
→ 因为 B 是灰色,C 会被标记为存活
→ 但实际上 C 已经没有被引用了!

结果:C 被误标记为存活,本次 GC 不会回收 C
→ 无害!下次 GC 时,C 会被正确回收

误标是无害的,因为垃圾只是「晚一点回收」,不会导致程序错误。
漏标是有害的,会导致程序访问已回收的对象(悬挂指针),造成崩溃或数据错误。


面试加分回答

「并发标记的漏标问题是设计并发 GC 时的核心挑战。CMS 和 G1 用了不同的方案:CMS 用增量更新(记录新增的引用),G1 用SATB(记录删除的引用)。这两种方案各有优劣:增量更新可能导致重复扫描(黑色对象被重新标记为灰色,需要重新扫描);SATB 可能导致过度标记(实际上已经不再引用的对象,仍然被标记为存活)。G1 选择 SATB 的原因是:SATB 的写屏障开销更小,更适合大堆场景。」


四、类加载机制(18-21)


第 18 题:类加载的过程?

一句话结论

类加载 = 加载(Loading)→ 验证(Verification)→ 准备(Preparation)→ 解析(Resolution)→ 初始化(Initialization)。其中验证、准备、解析合称「连接(Linking)」。


深度解析

完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
类加载的全过程(5 个阶段):
┌─────────────────────────────────────────────┐
│ 阶段 1:加载(Loading) │
│ → 通过类的全限定名获取 .class 文件的二进制│
│ 字节流 │
│ → 将字节流所代表的静态存储结构转化为方法│
│ 区的运行时数据结构 │
│ → 在内存中生成一个 java.lang.Class 对象 │
│ (作为方法区这个类的各种数据的访问入口)│
├─────────────────────────────────────────────┤
│ 阶段 2:验证(Verification) │
│ → 确保 .class 文件的字节流符合 JVM 规范│
│ → 防止恶意字节码攻击 │
│ → 包括:文件格式验证、元数据验证、字节码│
│ 验证、符号引用验证 │
├─────────────────────────────────────────────┤
│ 阶段 3:准备(Preparation) │
│ → 为类变量(static 变量)分配内存 │
│ → 并设置初始值(零值) │
│ → 注意:不是代码中赋的值! │
│ → 如:static int a = 10; │
│ 准备阶段:a = 0(零值) │
│ 初始化阶段:a = 10(代码中赋的值)│
├─────────────────────────────────────────────┤
│ 阶段 4:解析(Resolution) │
│ → 将常量池中的符号引用替换为直接引用 │
│ → 符号引用:"java.lang.Object" │
│ → 直接引用:指向对象的指针 │
├─────────────────────────────────────────────┤
│ 阶段 5:初始化(Initialization) │
│ → 执行类构造器 <clinit>() 方法 │
│ → <clinit>() 方法是编译器自动收集类中 │
│ 所有类变量的赋值动作和静态代码块合并 │
│ 产生的 │
│ → 什么时候执行初始化? │
│ * 遇到 new、getstatic、putstatic、│
│ invokestatic 指令时 │
│ * 使用反射调用时 │
│ * 初始化子类时,父类要先初始化 │
│ * 主类(main 方法所在类) │
└─────────────────────────────────────────────┘

用图直观看”准备”和”初始化”两个阶段对同一个静态变量做了什么(这是高频考点,一定要记住 a 经历了两次赋值):

flowchart LR
    L["① 加载 Loading\n读取 .class 字节流\n生成 Class 对象"] --> V["② 验证 Verification\n校验字节码合法性"]
    V --> P["③ 准备 Preparation\nstatic int a = 10;\n此时 a = 0(赋零值)\n(static final 常量除外,直接赋真值)"]
    P --> R["④ 解析 Resolution\n符号引用 → 直接引用\n如 #quot;java.lang.Object#quot; → 指针"]
    R --> I["⑤ 初始化 Initialization\n执行 #60;clinit#62;() 方法\n此时 a = 10(赋代码中的真实值)"]

准备阶段 vs 初始化阶段(重要!):

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test {
// 准备阶段:value = 0(零值)
// 初始化阶段:value = 10(代码中赋的值)
private static int value = 10;

// 准备阶段:obj = null(引用类型的零值是 null)
// 初始化阶段:obj = new Object()
private static Object obj = new Object();

// 特殊情况:常量(static final)在准备阶段就赋值!
// 准备阶段:CONST = 20(因为常量在编译时就已经确定了)
private static final int CONST = 20;
}

面试加分回答

「类加载的初始化阶段是面试高频追问点。需要注意:() 方法是线程安全的(JVM 会保证多线程环境下,只有一个线程能执行 () 方法,其他线程会阻塞等待)。这也是静态内部类实现单例模式的线程安全保证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Singleton {
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // 首次调用时,触发 Holder 类的初始化
}
}
```」

---

### 第 19 题:双亲委派模型?

#### 一句话结论

**双亲委派模型 = 类加载器收到加载请求时,先委托父加载器加载,父加载器无法加载时才自己加载。防止核心类被篡改(如自定义 java.lang.Object)。**

---

#### 深度解析

**类加载器的层次结构:**

双亲委派模型(Parent Delegation Model):
┌─────────────────────────────────────────────┐
│ 启动类加载器(Bootstrap ClassLoader) │
│ → C++ 实现,是 JVM 的一部分 │
│ → 加载 /jre/lib 下的核心类库│
│ → 如:java.lang.、java.util. │
└─────────────────┬───────────────────────────┘
│ 子加载器
┌─────────────────┴───────────────────────────┐
│ 扩展类加载器(Extension ClassLoader,Java 8)│
│ → Java 实现,sun.misc.Launcher$ExtClassLoader│
│ → 加载 /jre/lib/ext 下的扩展类│
└─────────────────┬───────────────────────────┘
│ 子加载器
┌─────────────────┴───────────────────────────┐
│ 应用程序类加载器(Application ClassLoader) │
│ → Java 实现,sun.misc.Launcher$AppClassLoader│
│ → 加载用户类路径(ClassPath)上的类 │
│ → 是程序中默认的类加载器 │
└─────────────────┬───────────────────────────┘
│ 子加载器
┌─────────────────┴───────────────────────────┐
│ 自定义类加载器(Custom ClassLoader) │
│ → 继承 java.lang.ClassLoader │
│ → 可以重写 loadClass() 或 findClass() │
└─────────────────────────────────────────────┘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

**用图区分「向上委托」(实线,先问父加载器)和「向下回退」(虚线,父加载器失败后自己加载)这两条路径,并标出打破双亲委派的例外路径:**

```mermaid
flowchart TB
Boot["启动类加载器\nBootstrap ClassLoader"]
Ext["扩展/平台类加载器\nExtension / Platform ClassLoader"]
App["应用程序类加载器\nApplication ClassLoader"]
Custom["自定义类加载器\nCustom ClassLoader"]

Custom -- "① 向上委托:先问父加载器" --> App
App -- "① 向上委托:先问父加载器" --> Ext
Ext -- "① 向上委托:先问父加载器" --> Boot

Boot -. "② 父加载器加载失败\n→ 向下回退,自己加载" .-> Ext
Ext -. "② 父加载器加载失败\n→ 向下回退,自己加载" .-> App
App -. "② 父加载器加载失败\n→ 向下回退,自己加载" .-> Custom

SPI["线程上下文类加载器\nThread Context ClassLoader\n(SPI,如 JDBC Driver)"]
Tomcat["Web 容器自定义加载器\n(如 Tomcat WebAppClassLoader)\n每个 Web 应用独立、优先自己加载"]

App -. "打破双亲委派例外路径" .-> SPI
Custom -. "打破双亲委派例外路径\n不先问父加载器" .-> Tomcat

双亲委派的工作流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 伪代码:
protected Class<?> loadClass(String name, boolean resolve) {
// 1. 检查类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 2. 先委托父加载器加载
c = parent.loadClass(name, false);
} else {
// 3. 如果没有父加载器,使用启动类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载
}

if (c == null) {
// 4. 父加载器无法加载,自己加载
c = findClass(name);
}
}
return c;
}

双亲委派的好处:

1
2
3
4
5
6
7
8
9
10
// 好处 1:防止核心类被篡改
// 如果自定义一个 java.lang.Object 类:
// → 双亲委派会先委托父加载器(启动类加载器)加载
// → 启动类加载器已经加载了真正的 java.lang.Object
// → 自定义的 java.lang.Object 不会被加载!
// → 防止核心 API 被篡改

// 好处 2:避免类的重复加载
// → 父加载器加载过的类,子加载器不需要再加载
// → 保证类的唯一性

面试加分回答

「双亲委派模型不是强制约束,而是一个推荐的最佳实践。Java 中有些场景就打破了双亲委派:1. SPI(Service Provider Interface):如 java.sql.Driver(JDBC 驱动),核心接口由启动类加载器加载,但实现类由应用程序类加载器加载,启动类加载器无法加载应用程序类路径上的类 → 使用线程上下文类加载器(Thread Context ClassLoader) 来解决;2. OSGi:为了实现模块化,每个模块有自己的类加载器,不再遵循双亲委派。」


第 20 题:如何打破双亲委派模型?

一句话结论

打破双亲委派 = 重写 loadClass() 方法(不推荐)或重写 findClass() 方法(推荐)。典型场景:Tomcat 的 WebAppClassLoader、SPI 机制、OSGi。


深度解析

方式 ①:重写 loadClass()(不推荐,会破坏双亲委派)

1
2
3
4
5
6
7
8
9
10
11
12
public class CustomClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 不委托父加载器,自己先加载
Class<?> c = findClass(name);
if (c == null) {
// 自己加载不了,再委托父加载器
c = super.loadClass(name);
}
return c;
}
}

为什么不推荐重写 loadClass()?
→ 会破坏双亲委派模型,可能导致核心类被篡改!
→ Java 官方推荐:重写 findClass(),不要重写 loadClass()


方式 ②:重写 findClass()(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CustomClassLoader extends ClassLoader {
private String classPath;

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 1. 从自定义路径读取 .class 文件的字节码
byte[] classData = getClassData(name);
// 2. 调用 defineClass() 将字节码转换为 Class 对象
return defineClass(name, classData, 0, classData.length);
} catch (Exception e) {
throw new ClassNotFoundException(name);
}
}

private byte[] getClassData(String className) {
// 从 classPath 中读取 .class 文件
// ...
}
}

典型场景:Tomcat 的 WebAppClassLoader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Tomcat 的类加载器结构(打破了双亲委派):
┌─────────────────────────────────────────────┐
│ 启动类加载器(Bootstrap) │
├─────────────────────────────────────────────┤
│ 扩展类加载器(Extension) │
├─────────────────────────────────────────────┤
│ 应用程序类加载器(Application) │
├─────────────────────────────────────────────┤
│ WebAppClassLoader(每个 Web 应用一个) │
│ → 先加载自己 Web 应用下的类 │
│ → 加载不了,再委托父加载器 │
│ → 这样,不同 Web 应用可以使用不同版本的 │
│ 依赖库(如 Spring) │
└─────────────────────────────────────────────┘

面试加分回答

「打破双亲委派的经典场景是 Tomcat 的 WebAppClassLoader。如果每个 Web 应用都遵循双亲委派,那么所有 Web 应用都只能使用同一个版本的库(在 ClassPath 上的版本)。但实际情况是:不同 Web 应用可能依赖同一个库的不同版本。Tomcat 的解决方案是:每个 Web 应用有自己的 WebAppClassLoader,先加载自己应用下的类,加载不了再委托父加载器,从而打破了双亲委派。」


第 21 题:自定义类加载器的场景?

一句话结论

自定义类加载器的典型场景:热部署(Hot Swap)、模块化(如 OSGi)、字节码加密/解密、从非标准位置加载类(如网络、数据库)。


深度解析

场景 ①:热部署(Hot Swap)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 场景:线上服务不重启,更新代码
// 实现:自定义类加载器,每次热部署时,创建一个新的类加载器加载新的类

public class HotSwapClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) {
// 从指定路径读取最新的 .class 文件
byte[] classData = loadClassData(name);
return defineClass(name, classData, 0, classData.length);
}
}

// 使用:
// 1. 第一次加载
ClassLoader loader1 = new HotSwapClassLoader();
Class<?> clazz1 = loader1.loadClass("com.example.MyService");
Object service1 = clazz1.newInstance();

// 2. 代码更新后,创建新的类加载器(旧的类加载器可以被 GC 回收)
ClassLoader loader2 = new HotSwapClassLoader();
Class<?> clazz2 = loader2.loadClass("com.example.MyService");
Object service2 = clazz2.newInstance();

场景 ②:字节码加密/解密

1
2
3
4
5
6
7
8
9
10
11
// 场景:防止 .class 文件被反编译
// 实现:编译后对 .class 文件加密,自定义类加载器在加载时解密

public class EncryptedClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) {
byte[] encryptedData = readEncryptedClass(name);
byte[] decryptedData = decrypt(encryptedData); // 解密
return defineClass(name, decryptedData, 0, decryptedData.length);
}
}

场景 ③:从网络/数据库加载类

1
2
3
4
5
6
7
8
9
10
11
// 场景:Java Card、Java Web Start(已废弃)等
// 实现:从网络下载 .class 字节码,或者在数据库中存储字节码

public class NetworkClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) {
// 从网络下载 .class 文件的字节码
byte[] classData = downloadClassData(name);
return defineClass(name, classData, 0, classData.length);
}
}

面试加分回答

「自定义类加载器的一个重要细节:同一个类,被不同的类加载器加载,JVM 会认为它们是不同的类。即使类的全限定名相同,如果类加载器不同,它们也是不兼容的(赋值时会报 ClassCastException)。这也是为什么在热部署时,需要创建一个新的类加载器来加载新版本的类,而不能直接使用旧的类加载器。」


五、JVM 调优(22-26)


第 22 题:常见的 JVM 调优参数?

一句话结论

JVM 调优参数 = 堆内存设置(-Xms、-Xmx)+ 垃圾收集器选择(-XX:+UseG1GC 等)+ gc 日志(-Xlog:gc)+ 溢出时转储(-XX:+HeapDumpOnOutOfMemoryError)。*


深度解析

① 堆内存设置(最常用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 初始堆大小(建议和 -Xmx 设置成一样,避免动态扩容)
-Xms4g

# 最大堆大小
-Xmx4g

# 新生代大小(推荐设置为堆的 1/3 ~ 1/2)
-Xmn2g

# 元空间初始大小
-XX:MetaspaceSize=128m

# 元空间最大大小
-XX:MaxMetaspaceSize=512m

# 直接内存大小
-XX:MaxDirectMemorySize=1g

② 垃圾收集器选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Serial 收集器(客户端模式)
-XX:+UseSerialGC

# Parallel 收集器(吞吐量优先,Java 8 默认)
-XX:+UseParallelGC

# CMS 收集器(低延迟,Java 8 常用,Java 14 移除)
-XX:+UseConcMarkSweepGC

# G1 收集器(Java 8+ 推荐,Java 9+ 默认)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿时间

# ZGC(JDK 11 引入,JDK 15+ 生产可用,超低延迟)
-XX:+UseZGC

# Shenandoah(Java 12+,类似 ZGC)
-XX:+UseShenandoahGC

③ GC 日志(调优必备):

1
2
3
4
5
6
7
8
9
# Java 8 及之前:
-verbose:gc
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/path/to/gc.log

# Java 9+(统一日志框架,JEP 158):
-Xlog:gc*
-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags

④ OOM 时转储堆内存(生产环境必备):

1
2
3
4
5
6
7
8
# OOM 时自动转储堆内存到文件
-XX:+HeapDumpOnOutOfMemoryError

# 转储文件路径
-XX:HeapDumpPath=/path/to/heapdump.hprof

# OOM 时执行脚本(如发送告警)
-XX:OnOutOfMemoryError="sh /path/to/alert.sh"

面试加分回答

「JVM 调优参数的设置需要结合应用场景。面试时可能会问:你们的生产环境用的什么参数? 典型回答(Java 8 + G1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
```」

---

### 第 23 题:如何排查 CPU 飙高问题?

#### 一句话结论

**CPU 飙高排查流程 = `top` 找 CPU 高的进程 → `top -Hp pid` 找 CPU 高的线程 → `jstack pid | grep 线程 ID(16 进制)` 找对应的线程栈 → 分析线程栈找出问题代码。**

---

#### 深度解析

**完整排查步骤:**

```bash
# 步骤 1:找出 CPU 使用率高的 Java 进程
top
# 找到 PID(如:12345)

# 步骤 2:找出该进程中 CPU 使用率高的线程
top -Hp 12345
# 找到线程 ID(如:12367)

# 步骤 3:把线程 ID 转换成 16 进制(因为 jstack 中的线程 ID 是 16 进制)
printf "%x\n" 12367
# 输出:304f

# 步骤 4:用 jstack 导出线程栈,并查找该线程
jstack 12345 | grep -A 20 "304f"
# -A 20:显示匹配行之后的 20 行(完整的线程栈)

# 步骤 5:分析线程栈
# → 如果是自己的业务代码:检查是否有死循环、密集计算
# → 如果是 GC 线程:可能是频繁 GC(内存不足、内存泄漏)
# → 如果是某个第三方库的代码:检查使用方式是否正确

常见原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 原因 1:死循环
while (true) {
// 没有 sleep、没有 break → CPU 100%
}

// 原因 2:密集计算(如:大循环、复杂算法)
for (int i = 0; i < 100_000_000; i++) {
Math.sqrt(i); // 密集计算 → CPU 高
}

// 原因 3:频繁 GC
// → 内存不足,频繁 Full GC → GC 线程 CPU 高
// → 解决:调大堆内存、检查内存泄漏

// 原因 4:自旋锁
// → 无限 CAS 自旋 → CPU 高

面试加分回答

「CPU 飙高问题排查是 Java 工程师的必备技能。除了 top + jstack,还可以用 arthas(阿里开源的 Java 诊断工具)来排查:

1
2
3
4
5
6
7
8
# 启动 arthas
java -jar arthas-boot.jar
# 查看 CPU 最高的线程
thread -n 5
# 反编译有问题的类
jad com.example.MyService
# 查看方法执行耗时
trace com.example.MyService methodName

arthas 比 jstack 更强大,生产环境必备!」


第 24 题:如何排查内存泄漏?

一句话结论

内存泄漏排查 = jmap -histo:live pid 查看堆中对象分布 → 找到异常多的类 → jmap -dump:format=b,file=heap.hprof pid 导出堆转储 → 用 MAT(Memory Analyzer Tool)分析泄漏点。


深度解析

完整排查步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 步骤 1:查看堆中对象分布(不需要 dump,快速查看)
jmap -histo:live 12345 | head -50
# 输出:
# num #instances #bytes class name
# ----------------------------------------------
# 1: 1000000 80000000 java.lang.String
# 2: 500000 40000000 java.util.HashMap$Node
# 3: 200000 16000000 com.example.MyObject ← 异常!

# 如果某个自定义类的实例数异常多 → 可能内存泄漏

# 步骤 2:导出堆转储文件(用于详细分析)
jmap -dump:format=b,file=heap.hprof 12345

# 步骤 3:用 MAT 打开 heap.hprof
# → MAT 会自动分析泄漏点(Leak Suspects Report)
# → 查看 Dominator Tree(支配树),找到占用内存最多的对象
# → 查看 GC Roots Path,找到为什么这些对象无法被回收

# 步骤 4:根据 MAT 的分析结果,修复代码

常见内存泄漏场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 场景 1:集合中的对象没有及时清理
static List<Object> list = new ArrayList<>();
public void add(Object obj) {
list.add(obj); // 对象不断加入,但从不移除 → 内存泄漏
}

// 场景 2:监听器没有注销
public class MyListener implements SomeListener {
public MyListener() {
SomeEventSource.addListener(this); // 注册监听器
// 但忘记注销!→ 监听器对象无法被回收
}
}

// 场景 3:ThreadLocal 没有 remove()
// → 见第 9 题的 ThreadLocal 内存泄漏问题

// 场景 4:连接没有关闭(数据库连接、网络连接)
// → 应该用 try-with-resources 或 finally 中关闭

面试加分回答

「内存泄漏的排查,除了 jmap + MAT,也可以用 arthas 的 dashboard 命令实时监控堆内存使用情况,用 heapdump 命令在线导出堆转储。另外,Java 8 的 jmap 命令在执行时会导致 JVM 停顿(STW),如果堆很大(如 32GB),停顿时间可能很长(几十秒)。解决方式:使用 G1 的 -XX:+G1HeapDumpBeforeFullGC,在 Full GC 之前自动 dump 堆,不需要手动执行 jmap。」


第 25 题:JVM 调优的思路?

一句话结论

JVM 调优 = 明确目标(吞吐量 vs 延迟) → 收集 GC 日志 → 分析 GC 日志(用 GCViewer 或 GCEasy) → 调整参数 → 验证效果。没有银弹,需要结合应用场景。


深度解析

调优步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
JVM 调优的完整流程:
┌─────────────────────────────────────────────┐
│ 步骤 1:明确调优目标 │
│ → 吞吐量优先?(后台计算、批处理) │
│ → 延迟优先?(Web 服务、API 服务) │
├─────────────────────────────────────────────┤
│ 步骤 2:收集 GC 日志 │
│ → -Xlog:gc* 开启 GC 日志 │
│ → 让程序运行一段时间(如 24 小时) │
├─────────────────────────────────────────────┤
│ 步骤 3:分析 GC 日志 │
│ → 使用工具:GCViewer、GCEasy(在线) │
│ → 关注指标: │
│ * GC 总耗时 │
│ * 单次 GC 最大停顿时间 │
│ * GC 频率 │
│ * 堆内存使用情况 │
├─────────────────────────────────────────────┤
│ 步骤 4:调整参数 │
│ → 如果 Full GC 频繁:调大堆内存 │
│ → 如果 Young GC 频繁:调大新生代 │
│ → 如果停顿时间长:换低延迟收集器(G1、ZGC)│
├─────────────────────────────────────────────┤
│ 步骤 5:验证效果 │
│ → 重新收集 GC 日志 │
│ → 对比调优前后的指标 │
└─────────────────────────────────────────────┘

调优案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 案例 1:Full GC 频繁
# 现象:应用程序运行一段时间后,频繁 Full GC
# 原因:堆内存不足,或内存泄漏
# 解决:
-Xms8g -Xmx8g # 调大堆内存
-XX:+HeapDumpOnOutOfMemoryError # 如果发生 OOM,dump 堆用于分析

# 案例 2:Young GC 频繁,但单次停顿短
# 现象:Young GC 每秒一次,但每次只停顿 10ms
# 原因:新生代太小,对象很快填满 Eden 区
# 解决:
-Xmn4g # 调大新生代(从 1g → 4g)

# 案例 3:GC 停顿时间长(如 > 500ms)
# 现象:GC 日志中,[Full GC ...] 停顿时间很长
# 原因:使用了 Serial Old 或 Parallel Old(标记-整理算法,STW 时间长)
# 解决:换低延迟收集器
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

面试加分回答

「JVM 调优的最重要原则:不要过早优化(Premature Optimization)。在调优之前,先确认:1. 是否是代码问题(如内存泄漏、死循环);2. 是否是数据库问题(如慢查询)。很多所谓的「JVM 调优」,实际上应该是「代码优化」或「数据库优化」。另外,GCEasy(https://gceasy.io/)是在线的 GC 日志分析工具,上传 GC 日志后,会自动生成分析报告(包括 GC 频率、停顿时间、内存使用情况等),非常方便。」


第 26 题:逃逸分析?

一句话结论

逃逸分析(Escape Analysis)= JVM 的一种分析技术,判断对象是否「逃逸」出方法(即是否被其他线程或外部方法访问)。如果没有逃逸,JVM 可以做标量替换、栈上分配、锁消除等优化。


深度解析

什么是「逃逸」?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 对象逃逸了(不能被优化):
public Object escape1() {
Object obj = new Object();
return obj; // obj 被返回 → 逃逸出方法
}

public void escape2() {
Object obj = new Object();
someMethod(obj); // obj 被传入其他方法 → 可能逃逸
}

// 对象没有逃逸(可以被优化):
public void noEscape() {
Object obj = new Object();
System.out.println(obj); // obj 只在方法内部使用
// 方法结束后,obj 可以被安全回收
}

逃逸分析的优化:

① 栈上分配(Stack Allocation)

1
2
3
4
5
6
7
8
9
10
// 没有逃逸分析时:
public void method() {
for (int i = 0; i < 1000000; i++) {
Object obj = new Object(); // 100 万个对象在堆上分配 → GC 压力大
}
}

// 开启逃逸分析后(-XX:+DoEscapeAnalysis,默认开启):
// JVM 发现 obj 没有逃逸 → 直接在栈上分配(不需要 GC!)
// 方法结束时,栈帧出栈,对象自动销毁

② 标量替换(Scalar Replacement)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 标量(Scalar):不能被进一步分解的量(如基本类型 int、long)
// 聚合量(Aggregate):可以分解的量(如对象)

public void method() {
Point p = new Point(1, 2); // Point 是一个对象(聚合量)
System.out.println(p.x + p.y);

// 开启标量替换后,JVM 会把 Point 对象拆成两个局部变量:
// int x = 1;
// int y = 2;
// System.out.println(x + y);
// → 不需要在堆上分配 Point 对象!
}

③ 锁消除(Lock Elision)

1
2
3
4
5
6
7
8
9
10
// 开启锁消除后(-XX:+EliminateLocks,默认开启):
public void method() {
synchronized (new Object()) { // 锁对象没有逃逸 → 可以消除锁!
// 同步块
}
}

// JVM 会发现:锁对象(new Object())没有逃逸出方法
// → 其他线程无法访问这个锁
// → 同步是没有意义的 → 消除锁(不需要加锁)

面试加分回答

「逃逸分析是 JVM 的很致优化之一。但需要注意的是:逃逸分析本身是有成本的(需要分析对象是否逃逸)。对于短方法(如 getter/setter),逃逸分析的成本可能高于优化带来的收益。另外,标量替换 和 栈上分配 在 HotSpot 中并没有完全实现(主要是标量替换),因为实现栈上分配需要修改垃圾收集器,成本很高。面试时可能会追问:Java 中的对象一定在堆上分配吗? 答案是:理论上可以栈上分配,但 HotSpot 目前主要使用标量替换。」


六、实战问题(27-30)


第 27 题:方法区、栈、堆分别存什么?

一句话结论

方法区(元空间)存:类元数据、常量、静态变量、JIT 缓存;栈存:局部变量、方法参数、引用(对象地址);堆存:对象实例、数组。


深度解析

各区域存储内容的详细对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────────────────────────────┐
│ 区域 │ 存储的内容 │
├─────────────────────────────────────────────────────────────┤
│ 元空间 │ 1. 类的元数据(Class 对象、字段、方法) │
│ (Metaspace) │ 2. 运行时常量池(Runtime Constant Pool) │
│ │ 3. 静态变量(static 变量) │
│ │ 4. 常量(static final) │
│ │ 5. JIT 编译后的本地代码缓存 │
├─────────────────────────────────────────────────────────────┤
│ 虚拟机栈 │ 1. 局部变量(基本类型的值、引用类型的地址)│
│ (Stack) │ 2. 方法参数 │
│ │ 3. 操作数栈 │
│ │ 4. 动态链接 │
│ │ 5. 方法返回地址 │
├─────────────────────────────────────────────────────────────┤
│ 堆 │ 1. 对象实例(new 出来的对象) │
│ (Heap) │ 2. 数组 │
│ │ 3. 字符串常量池(Java 7+ 移到堆中) │
└─────────────────────────────────────────────────────────────┘

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
public class Test {
private static int staticVar = 10; // staticVar → 元空间
private static final String CONST = "hello"; // CONST → 元空间(运行时常量池)

public void method() {
int localVar = 20; // localVar → 栈(局部变量表)
Object obj = new Object(); // obj(引用)→ 栈
// new Object()(对象实例)→ 堆
String s = "world"; // s(引用)→ 栈
// "world"(字符串常量)→ 堆(字符串常量池)
}
}

面试加分回答

「方法区(元空间)中存储的内容,面试时经常追问:字符串常量池在哪里? 答案是:Java 7 之前在永久代(方法区),Java 7 开始移到堆中。静态变量在哪里? 这里要小心,很多人会答错:静态变量本身(变量槽)从未搬迁过,一直随 Class 元数据存放在方法区/元空间,与字符串常量池的迁移无关;只有当静态变量是引用类型时,它指向的对象实例才存放在堆中——这一点在任何版本都成立,不要和”字符串常量池 Java 7 后移到堆”混为一谈。这些细节反映了 JVM 内存模型的演进。」


第 28 题:synchronized 的锁升级过程?

一句话结论

synchronized 的锁升级 = 无锁 → 偏向锁(单线程访问)→ 轻量级锁(多线程交替访问)→ 重量级锁(多线程同时访问)。锁升级后不能降级(但偏向锁可以撤销)。


深度解析

锁的状态(存储在对象头的 Mark Word 中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
synchronized 的锁升级过程:
┌─────────────────────────────────────────────┐
│ 状态 1:无锁(No Lock) │
│ → 对象没有被任何线程锁定 │
├─────────────────────────────────────────────┤
│ 状态 2:偏向锁(Biased Lock) │
│ → 假设:只有一个线程会访问同步块 │
│ → 原理:在对象头中记录「偏向线程 ID」 │
│ → 这个线程再次进入同步块时,不需要加锁!│
│ → 只需要检查对象头中的线程 ID 是否是自己│
│ → 性能接近无锁! │
├─────────────────────────────────────────────┤
│ 状态 3:轻量级锁(Lightweight Lock) │
│ → 假设:多线程交替访问同步块(没有竞争) │
│ → 原理:在栈帧中创建「锁记录」(Lock Record)│
│ → 用 CAS 把对象头的 Mark Word 替换为指向│
│ 锁记录的指针 │
│ → 如果 CAS 成功 → 获取轻量级锁 │
│ → 如果 CAS 失败(有竞争)→ 升级为重量级锁│
├─────────────────────────────────────────────┤
│ 状态 4:重量级锁(Heavyweight Lock) │
│ → 假设:多线程同时访问同步块(有竞争) │
│ → 原理:使用操作系统的 Mutex Lock(互斥锁)│
│ → 获取锁失败的线程会被阻塞(挂起) │
│ → 唤醒阻塞的线程需要上下文切换(成本高) │
└─────────────────────────────────────────────┘

锁升级的触发条件:

1
2
3
4
5
6
7
8
9
10
// 偏向锁 → 轻量级锁:
// 当有另一个线程尝试获取偏向锁时
// → 偏向锁升级为轻量级锁(需要 STW,撤销偏向锁)

// 轻量级锁 → 重量级锁:
// 当轻量级锁的 CAS 操作失败(有竞争)时
// → 轻量级锁升级为重量级锁(自旋一定次数后,升级)

// 注意:锁升级后,不能降级!
// (但偏向锁可以撤销,回到无锁状态)

锁升级的性能影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
性能排序(从快到慢):
无锁 > 偏向锁 > 轻量级锁 > 重量级锁

偏向锁:
→ 只有一个线程访问同步块时,性能接近无锁
→ 适合单线程场景

轻量级锁:
→ 多线程交替访问时,用 CAS 竞争锁(自旋锁)
→ 适合线程交替执行同步块的场景

重量级锁:
→ 多线程同时竞争锁时,线程会被阻塞(挂起)
→ 上下文切换成本高,性能最差

面试加分回答

「锁升级是 JVM 对 synchronized 的重要优化(Java 6 引入)。面试时可能会追问:为什么有了 synchronized,还需要 java.util.concurrent.locks.Lock(如 ReentrantLock)? 答案是:1. ReentrantLock 可以尝试获取锁(tryLock()),而 synchronized 会一直阻塞;2. ReentrantLock 可以中断(lockInterruptibly()),而 synchronized 不支持中断;3. ReentrantLock 可以绑定多个条件变量(Condition),而 synchronized 只有一个等待队列。但 Java 6 之后,synchronized 的性能已经和 ReentrantLock 接近,优先使用 synchronized(更简洁)。」


第 29 题:JVM 有哪些命令行工具?

一句话结论

JVM 命令行工具 = jps(查看 Java 进程)、jstat(查看 GC 情况)、jmap(查看堆内存)、jstack(查看线程栈)、jinfo(查看 JVM 参数)、jcmd(全能工具)。


深度解析

常用工具详解:

① jps(JVM Process Status):查看 Java 进程

1
2
3
4
5
6
7
jps
# 输出:
# 12345 MyApp
# 23456 Jps

jps -v # 显示 JVM 参数
jps -l # 显示全限定类名

② jstat(JVM Statistics Monitoring Tool):查看 GC 情况和类加载情况

1
2
3
4
5
6
7
8
9
10
11
# 查看 GC 情况(每 1000ms 输出一次,共输出 10 次)
jstat -gc 12345 1000 10
# 输出:
# S0C S1C S0U S1U EC EU OC OU MC MU ...
# 0.0 0.0 0.0 0.0 4096.0 2048.0 10240.0 5120.0 5120.0 4800.0 ...
# ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
# From To From To Eden Eden 老年代 老年代 元空间 元空间
# 容量 容量 使用量 使用量 容量 使用量 容量 使用量 容量 使用量

# 查看类加载情况
jstat -class 12345

③ jmap(Memory Map for Java):查看堆内存使用情况,导出堆转储

1
2
3
4
5
6
7
8
# 查看堆内存使用情况
jmap -heap 12345

# 查看堆中对象分布
jmap -histo:live 12345 | head -50

# 导出堆转储文件
jmap -dump:format=b,file=heap.hprof 12345

④ jstack(Stack Trace for Java):查看线程栈

1
2
3
4
5
6
7
8
# 查看线程栈
jstack 12345

# 查看线程栈(带锁信息)
jstack -l 12345

# 自动诊断死锁
jstack 12345 | grep -A 10 "deadlock"

⑤ jinfo(Configuration Info for Java):查看和修改 JVM 参数

1
2
3
4
5
6
7
8
# 查看 JVM 参数
jinfo -flags 12345

# 查看某个具体参数的值
jinfo -flag MaxHeapSize 12345

# 动态修改某个参数(部分参数支持动态修改)
jinfo -flag +HeapDumpOnOutOfMemoryError 12345

⑥ jcmd(JVM Command):JDK 7+ 的全能工具

1
2
3
4
5
6
7
8
9
10
11
12
# 查看 Java 进程
jcmd -l

# 查看 JVM 参数
jcmd 12345 VM.flags

# 导出堆转储
jcmd 12345 GC.heap_dump /path/to/heap.hprof

# 执行 GC
jcmd 12345 GC.run_finalization
jcmd 12345 GC.run

面试加分回答

「JVM 命令行工具是排查问题的基础。但生产环境中,这些工具可能无法正常使用(如目标进程没有响应)。这时可以用 jcmd 或 JMX(Java Management Extensions)。另外,arthas(阿里开源)是比这些命令行工具更强大的诊断工具,支持在线反编译、查看方法执行耗时、监控 JVM 状态等,生产环境必备!」


第 30 题:线上 OOM 如何快速止损?

一句话结论

线上 OOM 快速止损 = 保留现场(dump 堆、保存 GC 日志)→ 快速重启(恢复服务)→ 分析现场(用 MAT 分析 heap dump)→ 修复代码 → 验证。


深度解析

完整止损流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
线上 OOM 快速止损:
┌─────────────────────────────────────────────┐
│ 步骤 1:保留现场(最重要!) │
│ → 如果配置了 -XX:+HeapDumpOnOutOfMemory │
│ Error,OOM 时会自动 dump 堆 │
│ → 如果没有配置,手动 dump: │
│ jmap -dump:format=b,file=heap.hprof │
│ pid │
│ → 保存 GC 日志、应用日志 │
├─────────────────────────────────────────────┤
│ 步骤 2:快速重启(恢复服务) │
│ → 如果是集群部署,先重启一台机器 │
│ → 观察重启后是否正常 │
│ → 如果正常,滚动重启所有机器 │
├─────────────────────────────────────────────┤
│ 步骤 3:分析现场 │
│ → 用 MAT 打开 heap.hprof │
│ → 查看 Leak Suspects Report │
│ → 找到内存泄漏的点 │
├─────────────────────────────────────────────┤
│ 步骤 4:修复代码 │
│ → 根据 MAT 的分析结果,修复代码 │
│ → 如:及时清理集合、关闭连接、 │
│ ThreadLocal.remove() 等 │
├─────────────────────────────────────────────┤
│ 步骤 5:验证 │
│ → 本地复现问题 │
│ → 修复后,压测验证 │
│ → 发布上线 │
└─────────────────────────────────────────────┘

OOM 的常见原因和解决方案:

1
2
3
4
5
6
7
8
9
10
11
// 原因 1:堆内存不足(java.lang.OutOfMemoryError: Java heap space)
// → 解决:调大堆内存(-Xmx),或修复内存泄漏

// 原因 2:元空间不足(java.lang.OutOfMemoryError: Metaspace)
// → 解决:调大元空间(-XX:MaxMetaspaceSize),或检查是否加载了太多类

// 原因 3:直接内存不足(java.lang.OutOfMemoryError: Direct buffer memory)
// → 解决:调大直接内存(-XX:MaxDirectMemorySize),或及时释放 DirectByteBuffer

// 原因 4:GC 开销过大(java.lang.OutOfMemoryError: GC overhead limit exceeded)
// → 解决:检查内存泄漏,或调大堆内存

面试加分回答

「线上 OOM 的最重要原则:先恢复服务,再排查问题。很多新手会先在线上排查问题(如用 jmap dump 堆),但这可能导致服务长时间不可用。正确的做法:1. 如果配置了自动 dump,直接重启;2. 如果没有自动 dump,在重启之前手动 dump(如果 dump 时间不影响服务可用性的话);3. 重启后,在测试环境复现问题,用 dump 文件分析。另外,-XX:+HeapDumpOnOutOfMemoryError 一定要开启(性能影响极小),这是线上排查 OOM 的唯一依据!」


总结:JVM 面试复习 Checklist

模块 题号 核心考点
JVM 内存区域 1-6 内存区域划分、对象创建过程、对象内存布局、OOM 类型
垃圾回收基础 7-10 可达性分析、GC Roots、四种引用、GC 算法
垃圾收集器 11-17 收集器对比、CMS、G1、ZGC、三色标记、RSet
类加载机制 18-21 类加载过程、双亲委派、打破双亲委派、自定义类加载器
JVM 调优 22-26 调优参数、CPU 飙高排查、内存泄漏排查、调优思路、逃逸分析
实战问题 27-30 各区域存储内容、synchronized 锁升级、JVM 工具、OOM 止损

最后的建议:
JVM 的知识点很多,但面试中最高频的是:垃圾收集器(CMS/G1/ZGC)、类加载机制、JVM 调优、OOM 排查。
建议把这几块深入理解,并结合实际场景(如:你们的线上服务用的什么收集器?为什么?)来准备,这样面试时更有说服力。
祝你面试顺利!🚀


如果觉得这篇文章对你有帮助,欢迎分享给更多的小伙伴!

补充篇:BAT 高频遗漏题(31-38题)

第 31 题:字符串常量池(String Pool)是什么?

一句话总结:字符串常量池是 JVM 为了复用字符串对象而维护的一块特殊内存区域;"abc" 字面量自动入池,new String("abc") 强制在堆里新建对象。

深度解析:

1
2
3
4
5
6
7
字符串常量池的位置变化:
JDK 6 及之前:方法区(永久代)
JDK 7 开始:移到堆里(可以被 GC 回收)

为什么会移动?
→ 方法区的永久代空间有限,容易 OOM
→ 堆里可以自动扩容,且可以被 GC

intern() 方法(面试高频!):

1
2
3
4
5
6
String s1 = new String("abc");
String s2 = s1.intern();
String s3 = "abc";

System.out.println(s2 == s3); // true(都指向常量池里的 "abc")
System.out.println(s1 == s2); // false(s1 指向堆,s2 指向常量池)

面试加分回答:

“我们项目里有一处坑:从数据库查出大量字符串,每次 new String(rs.getString(1)),导致堆里大量重复字符串对象。后来改成直接用 rs.getString(1).intern(),让重复字符串共享常量池里的对象,节约了约 30% 的堆内存。但要注意:intern() 有性能开销(要查常量池),且 JDK 7+ 常量池在堆里,太多唯一字符串会导致堆压力大。”


第 32 题:直接内存(Direct Memory)是什么?为什么 NIO 用它?

一句话总结:直接内存是堆外内存(不受 GC 直接管理),NIO 的 DirectByteBuffer 用它来避免用户态和内核态之间的内存拷贝,提升 I/O 性能。

深度解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
普通 I/O(不带直接内存):
应用程序缓冲区(堆内)
↓ 拷贝(用户态 → 内核态)
内核缓冲区
↓ 拷贝(内核态 → 硬件)
磁盘/网卡

共 2 次拷贝!

直接内存 I/O(DirectByteBuffer):
直接内存(堆外,和内核共享)
↓ 零拷贝(mmap 或 sendfile)
内核缓冲区
↓ 零拷贝
磁盘/网卡

共 0~1 次拷贝!快很多!

直接内存的坑(面试高频!):

1
2
3
4
5
6
7
8
9
// 分配 1GB 直接内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);

// 直接内存不受 -Xmx 限制!
// 如果分配过大,会报 OOM:Direct buffer memory

// 回收直接内存:
// ① 调用 Cleaner.clean()(内部机制)
// ② 或等待 GC(但 GC 不直接回收直接内存,有延迟)

面试加分回答:

“我们 Netty 项目里用到了大量直接内存(Netty 默认用 DirectByteBuffer 做 I/O 缓冲区)。有一次线上出现 OOM: Direct buffer memory,排查发现是 Netty 的 PlatformDependent.directBufferPreferred() 返回 true,但直接内存没设上限(-XX:MaxDirectMemorySize=2g),默认跟堆内存一样大,加上堆的 8G,物理内存 16G 不够用了。解决方案:显式设置 -XX:MaxDirectMemorySize=4g。”


第 33 题:对象访问定位有哪几种方式?

一句话总结:Java 通过栈上的 reference 访问堆里的对象,有两种方式:句柄访问和直接指针访问;HotSpot 用的是直接指针访问。

深度解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
方式 1:句柄访问(类似二级指针)
[栈] reference → [堆] 句柄池(到对象实例数据的指针、到对象类型数据的指针) → [堆] 对象实例数据
↓
[方法区] 对象类型数据(Class 信息)

优势:对象移动时(GC 复制算法),只需改句柄里的指针,栈里的 reference 不用改
劣势:多一次寻址,慢一点

方式 2:直接指针访问(HotSpot 用的这个!)
[栈] reference → [堆] 对象实例数据(里面存了到对象类型数据的指针)
↓
[方法区] 对象类型数据(Class 信息)

优势:少一次寻址,快!
劣势:对象移动时(GC),栈里的 reference 也要更新(但 HotSpot 的 GC 会做这个,如 G1 的 RSet)

面试加分回答:

“HotSpot 用直接指针访问,主要是性能考虑(少一次寻址)。但很多人不知道:这个设计导致 GC 做标记时,除了标记堆里的对象,还要扫描栈里的 reference——如果栈很深(如几千层递归),GC roots 扫描会非常慢。这也是为什么大栈(如 -Xss 设太大)会影响 GC 性能。”


第 34 题:ZGC 有哪些改进?和 G1 比有什么优势?

一句话总结:ZGC 于 JDK 11 引入(实验特性),JDK 15 转正为生产可用,JDK 21 引入分代 ZGC 并成为默认模式;核心改进是染色指针 + 内存多重映射 + 读屏障,停顿时间和堆大小基本无关。

深度解析:

对比项 G1 ZGC(JDK 11 实验,JDK 15+ 生产可用)
目标停顿时间 < 200ms(可设 -XX:MaxGCPauseMillis) < 10ms(不随堆大小增加)
算法 区域化 + 标记整理 并发标记 + 染色指针 + 转移
内存占用 约堆的 10~20% 作为 Region 开销 约堆的 3% 作为染色指针开销
适用堆大小 4GB ~ 32 GB(太大 G1 也会卡) 8 GB ~ 16 TB(真的!)
JDK 版本 JDK 7+ JDK 11 引入,JDK 15+ 生产推荐,JDK 21 起默认分代 ZGC

ZGC 的三大黑科技:

1
2
3
4
5
6
7
8
9
10
11
12
13
① 染色指针(Colored Pointer)
→ 在指针里存元数据(Marked0、Marked1、Remapped、Finalizable)
→ 不用额外的标记位(如 G1 的 Remember Set)
→ 内存多重映射:同一个物理页映射到 3 个虚拟地址,通过指针的"颜色"判断对象状态

② 读屏障(Load Barrier)
→ 访问对象时,如果对象已经被转移(移动了位置),读屏障会自动修正指针
→ 这是在"读对象"时发生的,所以叫"读屏障"
→ 优点:转移过程和应用程序并发执行,STW 极短

③ 内存多重映射(Multi-Mapping)
→ 同一个物理内存页,映射到 3 个虚拟地址(对应指针的 3 种"颜色")
→ 切换"视图"时,不用真的拷贝内存,只改虚拟地址映射(极快!)

面试加分回答:

“我们公司从 JDK 11 升级到 JDK 17 的主要原因就是 ZGC。原来 G1 在堆 16G 时,Full GC 偶尔会停顿 23 秒,对实时交易系统来说不可接受。切换到 ZGC 后,99.99% 的停顿都在 10ms 以内,GC 日志里甚至基本看不到 STW 日志。但要注意:ZGC 的读屏障会有一定的吞吐量开销(约 515%),如果应用是吞吐量优先(如离线计算),G1 可能更合适。”


第 35 题:G1 的 Humongous Object(大对象)是什么?

一句话总结:G1 中超过 Region 大小 50% 的对象是大对象,直接进入 Humongous Region,避免多次拷贝,但会独占整个 Region。

深度解析:

1
2
3
4
5
6
7
8
9
10
11
12
大对象(Humongous Object)定义:
→ 对象大小 > Region 大小的 50%
→ 例如:Region = 4MB,对象 > 2MB 就算大对象

存储方式:
→ 不用 Eden 区,直接分配到 Old 区(Humongous Region)
→ 连续分配多个 Region(如果对象 > 1 个 Region)
→ 不会被拷贝(省去了 YGC 时的拷贝开销)

问题:
→ 大对象占用大量连续 Region,可能导致碎片
→ 大对象太多,会触发 Full GC(G1 最怕这个!)

面试加分回答:

“我们项目里有一个坑:用 ArrayList<byte[]> 缓存大文件,每个 byte[] 是 3MB,Region=4MB,所以每个 byte[] 都是大对象,直接进 Old 区,很快触发 Full GC。解决方案:① 调大 Region 大小(-XX:G1HeapRegionSize=8m)② 或者把大对象拆成小块(< 2MB)③ 或者不用堆内存,用直接内存(DirectByteBuffer)。”


第 36 题:JVM 有哪些性能监控和故障排查工具?

一句话总结:命令行工具有 jps、jstat、jmap、jstack、jinfo;GUI 工具有 JConsole、VisualVM、JProfiler、Arthas。

深度解析:

工具 作用 使用场景
jps 查看正在运行的 Java 进程 快速找到 Java 进程 ID
jstat 监控 JVM 统计信息(GC、类加载) 线上看 GC 频率和耗时
jmap 生成堆转储快照(heap dump) OOM 后分析内存泄漏
jstack 生成线程快照 排查死锁、线程死循环
jinfo 查看/修改 JVM 参数 动态调整参数(不用重启)
Arthas 在线诊断工具(阿里开源) 线上排查问题(无需重启)

Arthas 常用命令(面试高频!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 启动 Arthas,选择进程
java -jar arthas-boot.jar

# 2. 查看最耗 CPU 的线程
thread -n 3

# 3. 查看某个类的某个方法的耗时
trace com.example.service UserService getUserById

# 4. 反编译某个类(看是不是被代理类坑了)
jad com.example.service.UserService

# 5. 查看 JVM 参数
options

面试加分回答:

“我们线上排查问题首选 Arthas,不用重启应用。曾经遇到一个 CPU 100% 的问题,用 Arthas 的 thread -n 3 找到最耗 CPU 的线程,再用 jad 反编译对应的类,发现是同事写了个死循环。另外,jmap -histo:live pid 可以强制触发 Full GC 后再统计对象(live 参数),用来判断是不是有内存泄漏很有效。”


第 37 题:JIT 编译器(即时编译)是什么?C1 和 C2 编译器有什么区别?

一句话总结:JIT(Just-In-Time)把热点代码(频繁执行的方法/循环)编译成机器码,提升执行速度;**C1(Client 编译器)**启动快,**C2(Server 编译器)**峰值性能好。

深度解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
为什么需要 JIT?
→ 解释执行:JVM 读字节码 → 逐条解释成机器码 → 慢
→ JIT 编译:热点代码 → 直接编译成机器码(存在 CodeCache)→ 快(接近 C++)

热点代码 detection(热点探测):
→ 方法调用计数器(Invocation Counter):方法被调用多次
→ 回边计数器(BackEdge Counter):循环执行多次

C1 vs C2:
C1(Client Compiler):
→ 编译快,优化少(简单优化)
→ 适合客户端应用(启动要快)

C2(Server Compiler):
→ 编译慢,优化多(逃逸分析、锁消除、标量替换等)
→ 适合服务端应用(峰值性能重要)

分层编译(Tiered Compilation,JDK 7+ 默认开启):

五个 Level 的区别不是优化程度递增,而是是否携带 profiling(性能监控)信息——这是最容易答错的点。

1
2
3
4
5
6
7
8
9
10
11
12
Level 0:解释执行
Level 1:C1 编译,全部优化但不带 profiling
→ 用于简单方法:C1、C2 编译结果差不多时,直接用 C1 结果,省去 profiling 开销
Level 2:C1 编译,带有限 profiling
→ 只统计方法调用计数器、回边计数器
Level 3:C1 编译,带完整 profiling
→ 为 C2 收集详细的类型、分支等运行时数据,运行时开销比 Level 1 反而更大
Level 4:C2 编译,最高级别优化(逃逸分析、内联等),不带 profiling

→ 典型路径:解释执行(0) → C1+完整profiling(3) 收集数据 → 热点方法交给 C2(4) 做最高优化
→ 简单方法可能直接走 0 → 1,不需要 C2 介入
→ 兼顾启动速度和峰值性能!

面试加分回答:

“JIT 的逃逸分析(Escape Analysis)是很多优化的基础——如果对象只在方法内使用(没有逃逸出方法),JIT 可以做:① 栈上分配(不在堆里分配,方法结束自动销毁,不用 GC)② 锁消除(如果对象没有逃逸,加的 synchronized 可以去掉)③ 标量替换(把对象拆成基本类型,存在寄存器里,更快)。可以用 -XX:+DoEscapeAnalysis 开启(JDK 7+ 默认开启)。”


第 38 题:Java 内存模型(JMM)的八大原子操作是什么?

一句话总结:JMM 定义了主内存和工作内存之间的交互协议,有 8 种原子操作:lock、unlock、read、load、use、assign、store、write。

深度解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
JMM 内存划分:
→ 主内存(Main Memory):所有线程共享,存储对象实例、类信息
→ 工作内存(Working Memory):每个线程私有的内存,存储该线程用到的变量副本

8 种原子操作(必须成对出现!):

主内存 工作内存
│ │
│ read(读取) │
│────────────────►│
│ │ load(载入)→ 变量副本
│ │
│ │ use(使用)→ 执行引擎
│ │
│ │ assign(赋值)← 执行引擎
│ store(存储) │
│◄────────────────│
│ write(写入) │
│────────────────►│
│ │

另外:lock(锁定)/ unlock(解锁):作用于主内存,给其他线程发信号

volatile 的底层实现(连接 8 种操作):

1
2
3
4
5
6
7
volatile 变量的特殊规则:
→ read/load/use 必须连续出现(保证每次用都从主内存读,不缓存)
→ assign/store/write 必须连续出现(保证每次改都立即刷新到主内存)

→ 这就保证了:
① 可见性(一个线程改了,其他线程立刻能看到)
② 禁止指令重排序(内存屏障)

面试加分回答:

“JMM 的 8 种原子操作是理解 volatile、synchronized 底层的关键。有个易错点:read 和 load 必须连续执行(中间不能插入其他指令),这就是 volatile 保证’每次都从主内存读’的底层实现。另外,JDK 5 修正了 JMM 的定义(JSR-133),之前的 JMM 有缺陷(volatile 的语义不够强),现在的是正确的。”


JVM 38 题完结!


补充篇(续):BAT 高频遗漏题(39-45题)


第 39 题:Compact String(Java 9+ 字符串优化)是什么?

一句话结论

Java 9 引入了 Compact String,将 String 的内部存储从 char[](每个字符 2 字节)改为 byte[](根据编码用 1 或 2 字节),节省约 50% 内存。


深度解析

Java 8 及之前的 String 存储:

1
2
3
4
5
6
7
8
// Java 8 及之前
public final class String {
private final char[] value; // char 是 UTF-16,每个字符 2 字节
}

// 问题:
// ① 如果是纯英文(ASCII),每个字符只需要 1 字节,但 char[] 用 2 字节存储 → 浪费 50% 内存!
// ② 如果是中文,UTF-16 确实需要 2 字节/字符 → 没问题

Java 9+ 的 Compact String:

1
2
3
4
5
6
7
8
9
// Java 9 及之后
public final class String {
private final byte[] value; // byte[],根据编码动态选择
private final byte coder; // 编码标志:0 = LATIN1(1 字节),1 = UTF16(2 字节)
}

// 优势:
// ① 纯英文(LATIN1):每个字符 1 字节 → 节省 50% 内存!
// ② 包含中文(UTF16):每个字符 2 字节 → 和之前一样

性能对比:

场景 Java 8(char[]) Java 9+(byte[]) 内存节省
纯英文 “abc” 6 字节 3 字节 50%
中英文混合 “你好abc” 10 字节 8 字节(你2+好2+3) 20%
纯中文 “你好” 4 字节 4 字节 0%

面试加分回答

「Compact String 是 Java 9 的一个重要优化。在生产环境中,如果应用有大量纯英文的字符串(如 JSON、XML、日志),升级到 Java 9+ 可以节省约 30%~50% 的堆内存(字符串通常占堆的很大比例)。另外,Compact String 是默认开启的,不需要任何参数配置。如果想禁用(调试时),可以用 -XX:-CompactStrings。」


第 40 题:安全点(Safepoint)和安全区域(Safe Region)?

一句话结论

安全点(Safepoint)= 线程可以安全暂停的位置(如方法调用、循环跳转);安全区域(Safe Region)= 一段代码内,线程可以随时暂停。GC、锁降级等需要所有线程到达安全点才能执行。


深度解析

为什么需要安全点?

1
2
3
4
5
6
7
8
9
场景:JVM 要执行 GC(需要 Stop-The-World)
→ 如果线程正在执行 native 代码,JVM 无法中断
→ 所以需要「安全点」:线程执行到某个位置时,检查是否需要暂停

安全点的选择:
① 方法调用(Method Call)
② 循环跳转(Loop Back Edge)
③ 异常抛出(Exception Throw)
→ 这些位置,JVM 会插入「安全点检查」的代码

安全点的工作原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 伪代码:
public void method() {
// 安全点检查(JVM 插入)
if (needSafepoint) {
pauseThread();
}

int a = 1; // 不是安全点

// 安全点检查(JVM 插入)
if (needSafepoint) {
pauseThread();
}
method2(); // 方法调用 → 安全点!
}

安全区域(Safe Region):

1
2
3
4
5
6
7
8
问题:如果线程在等锁(synchronized),它一直在 native 代码(mutex lock)
→ JVM 无法插入安全点检查
→ 线程永远到不了安全点 → GC 无法执行(一直等)

解决:安全区域
→ 线程进入「安全区域」时,标记自己
→ JVM 执行 GC 时,不需要等「安全区域」内的线程(它们已经标记了)
→ 线程离开「安全区域」时,检查是否 GC 完成,没完成就继续等

面试加分回答

「安全点是 JVM 实现 Stop-The-World 的基础。面试时可能会追问:为什么安全点不选在每条字节码指令后面? 答案是:如果每条指令都检查安全点,性能开销太大!所以只选在「方法调用」和「循环跳转」这些「不那么频繁」的位置。另外,JVM 参数 -XX:+UseCountedLoopSafepoints 可以让「计数循环」(如 for (int i = 0; i < 1000000; i++))也在每次循环结束时检查安全点,避免大循环导致 GC 停顿时间过长。」


第 41 题:写屏障(Write Barrier)和读屏障(Read Barrier)?

一句话结论

写屏障(Write Barrier)= 当程序修改对象引用时,JVM 插入一段额外代码(用来更新卡表、Remember Set 等);读屏障(Read Barrier)= 当程序读取对象引用时,JVM 插入一段额外代码(用来支持并发 GC,如 ZGC)。


深度解析

写屏障(Write Barrier):

1
2
3
4
5
6
7
8
9
10
11
12
场景:CMS 的并发标记、G1 的 Remember Set 维护
→ 当程序修改对象引用时(如 obj.field = newObj)
→ JVM 需要记录这个变化(用于 GC)

写屏障的类型:
① 写前屏障(Pre-Write Barrier)
→ 在修改引用之前执行
→ G1 的 SATB(Snapshot-At-The-Beginning)用到

② 写后屏障(Post-Write Barrier)
→ 在修改引用之后执行
→ CMS 的卡表更新、G1 的 Remember Set 更新用到

代码示例(G1 的写后屏障):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 程序代码:
obj.field = newObj;

// JVM 实际执行的(伪代码):
WriteBarrierPost(obj, "field", newObj) {
// 1. 更新卡表(Card Table)或 Remember Set
cardTable[obj >> 9] = DIRTY; // 标记卡页为脏

// 2. 更新 G1 的 Remember Set
rememberSet[obj.region].add(newObj.region);
}

obj.field = newObj; // 真正的赋值

读屏障(Read Barrier,ZGC 用到):

1
2
3
4
5
6
7
8
场景:ZGC 的并发转移(Concurrent Relocation)
→ ZGC 在转移对象时,对象的地址会变
→ 如果程序读取旧地址,需要自动跳转到新地址

读屏障的作用:
→ 当程序读取对象引用时(如 Object o = obj.field)
→ JVM 检查这个引用是否指向「已转移的对象」
→ 如果是,自动修正为「转发指针」(Forwarding Pointer)指向的新地址

代码示例(ZGC 的读屏障):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 程序代码:
Object o = obj.field;

// JVM 实际执行的(伪代码):
Object o = ReadBarrier(obj, "field") {
Object result = obj.field;

// 检查:result 是否指向「已转移的对象」?
if (isForwarded(result)) {
return getForwardingPointer(result); // 返回新地址
}

return result;
}

面试加分回答

「写屏障是分代 GC和分区 GC的基础(需要记录跨代/跨 Region 引用)。读屏障是并发转移 GC(如 ZGC、Shenandoah)的基础(需要修正旧引用)。面试时可能会追问:写屏障的性能开销有多大? 答案是:写屏障会有一定的性能开销(每次修改引用都要执行额外代码),但比起扫描整个老年代/整个堆,这个开销是值得的。ZGC 的读屏障开销更大(每次读取引用都要检查),但 ZGC 通过染色指针(把标记信息存在指针里)来减少读屏障的开销。」


第 42 题:Code Cache(代码缓存)是什么?如何调优?

一句话结论

Code Cache = JIT 编译器生成的本地代码(机器码)的存储区域。如果 Code Cache 满了,JIT 编译会停止,导致性能下降。


深度解析

Code Cache 的作用:

1
2
3
4
5
6
7
8
JVM 执行字节码的方式:
① 解释执行(Interpreter):逐条解释字节码 → 慢
② JIT 编译(Just-In-Time):热点代码编译成机器码 → 快

Code Cache:
→ 存储 JIT 编译后的机器码
→ 当方法被调用「足够多次」(如 10000 次),JIT 会把它编译成机器码,存入 Code Cache
→ 之后调用这个方法,直接执行机器码(不需要解释)→ 快!

Code Cache 的大小:

1
2
3
4
5
6
7
# 查看 Code Cache 大小
java -XX:+PrintFlagsFinal -version | grep CodeCache

# 输出:
# uintx ReservedCodeCacheSize = 240M # 保留大小(默认 240MB)
# uintx InitialCodeCacheSize = 2496K # 初始大小(默认 ~2.5MB)
# uintx CodeCacheExpansionSize = 64K # 扩展大小(每次扩展 64KB)

Code Cache 满了会怎样?

1
2
3
4
5
6
7
现象:
→ JVM 输出日志:[CodeCache is full. Compiler has been disabled.]
→ JIT 编译停止(不再编译新方法)
→ 新方法只能用解释执行 → 性能下降!

解决:调大 Code Cache
-XX:ReservedCodeCacheSize=512m # 设置为 512MB

Code Cache 的调优参数:

1
2
3
4
5
6
7
8
# 1. 初始大小
-XX:InitialCodeCacheSize=32m

# 2. 最大大小(重要!)
-XX:ReservedCodeCacheSize=512m

# 3. Code Cache 的清理(Java 9+)
-XX:+UseCodeCacheFlushing # 允许清理不再用的机器码(默认开启)

面试加分回答

「Code Cache 是 JIT 编译器的「工作区」。在生产环境中,如果应用有很多热点方法(如大型电商系统的商品搜索、订单提交等),Code Cache 可能会满。建议:监控 Code Cache 的使用率(可以用 JConsole 或 VisualVM 查看),如果使用率 > 80%,就调大 -XX:ReservedCodeCacheSize。另外,Java 9+ 引入了 AOT 编译(Ahead-Of-Time),可以在启动前就编译好机器码,减少启动后的 JIT 编译压力。」


第 43 题:压缩指针(Compressed Oops)的详细原理?

一句话结论

压缩指针(Compressed Oops)= 在 64 位 JVM 上,用 32 位存储对象指针(而不是 64 位),节省约 50% 的内存(对象引用占的空间)。前提是堆内存 < 32GB。


深度解析

为什么需要压缩指针?

1
2
3
4
5
6
7
8
9
10
11
12
64 位 JVM 的问题:
→ 对象指针(Oop,Ordinary Object Pointer)占 8 字节(64 位)
→ 如果堆中有 10 亿元素的对象数组,每个元素是一个指针(8 字节)
→ 光是指针就占了 8GB 内存!

压缩指针:
→ 用 32 位存储对象指针
→ 但 32 位只能表示 4GB 内存 → 不够用?

解决:对齐(Alignment)
→ JVM 要求对象地址是 8 的倍数(因为对象头是 8 字节对齐)
→ 所以指针的「低 3 位」永远是 0 → 可以存 2^35 字节 = 32GB!

压缩指针的原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
假设:堆内存 = 32GB(< 32GB 才能用压缩指针)

对象地址的范围:0 ~ 2^35 - 1
→ 用 32 位存储时,实际存的是「真实地址 / 8」
→ 读取时,左移 3 位(× 8)得到真实地址

示例:
真实地址:0x00000000 (0)
压缩指针:0x00000000 (0)

真实地址:0x00000008 (8)
压缩指针:0x00000001 (1)

真实地址:0x00000010 (16)
压缩指针:0x00000002 (2)

→ 用 32 位,可以表示 0 ~ 2^32-1 = 4294967295 个「压缩指针值」
→ 对应真实地址:0 ~ 4294967295 × 8 = 34359738360 字节 ≈ 32GB

如何开启/关闭压缩指针?

1
2
3
4
5
# 开启(默认开启,堆 < 32GB 时)
-XX:+UseCompressedOops

# 关闭(堆 >= 32GB 时,自动关闭)
-XX:-UseCompressedOops

压缩指针的效果:

对比项 开启压缩指针 关闭压缩指针
对象指针大小 4 字节 8 字节
堆内存限制 < 32GB 无限制
内存占用 少 ~50% 多 ~50%
适用场景 堆 < 32GB(大多数场景) 堆 >= 32GB(大内存场景)

面试加分回答

「压缩指针是 JVM 内存优化的重要手段。在生产环境中,如果堆内存 < 32GB,一定要开启压缩指针**(-XX:+UseCompressedOops,默认开启),可以节省约 30%~50% 的内存(对象引用占的空间)。但如果堆内存 >= 32GB,压缩指针会自动关闭(因为 32 位最多表示 32GB),这时可以考虑升级到 64GB 堆**(虽然指针占更多内存,但总内存更多,可能性能更好)。另外,Compressed Class Pointers(压缩类指针,-XX:+UseCompressedClassPointers)自 JDK 8 引入元空间时就已经存在并默认开启,用来压缩指向元空间中类元数据的指针,可以进一步节省内存,和 -XX:+UseCompressedOops 是两个独立的开关。」


第 44 题:On-Stack Replacement(OSR,栈上替换)是什么?

一句话结论

OSR(On-Stack Replacement)= JIT 编译器可以在方法执行过程中,把「解释执行」的热循环替换成「编译后的机器码」,不需要等方法调用结束。


深度解析

为什么需要 OSR?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
场景:一个长时间运行的循环
public void method() {
for (int i = 0; i < 100_000_000; i++) {
// 这个循环会执行 1 亿次!
// 但 JIT 通常只在「方法调用」时编译
// → 这个方法不会被编译(因为方法只调用一次)
// → 循环只能用解释执行 → 很慢!
}
}

OSR 的作用:
→ JIT 发现「循环」是热代码(执行了很多次)
→ 在循环执行过程中,把「解释执行的循环」替换成「编译后的机器码」
→ 不需要等方法调用结束 → 提升性能!

OSR 的工作原理:

1
2
3
4
5
6
7
8
步骤:
1. JIT 监控循环的执行次数
2. 如果循环执行次数 > 阈值(如 10000 次)
→ JIT 编译这个循环(生成机器码)
3. 在循环的某个「安全点」(通常是循环末尾)
→ 替换栈帧(Stack Frame)的内容
→ 把「解释执行的栈帧」替换成「编译后的栈帧」
4. 之后循环用编译后的机器码执行 → 快!

OSR 的适用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 场景 1:大循环(最典型)
public void process() {
for (int i = 0; i < 100_000_000; i++) {
// 这个循环会被 OSR 编译
}
}

// 场景 2:服务器启动时的初始化代码(长时间运行的循环)
public void init() {
while (true) {
// 这个循环也会被 OSR 编译
}
}

面试加分回答

「OSR 是 JIT 编译器的「黑科技」之一。在没有 OSR 之前,如果热代码在一个「大循环」里,JIT 无法编译它(因为 JIT 通常只在「方法调用」时编译)。有了 OSR 之后,即使热代码在循环里,JIT 也能编译它。面试时可能会追问:OSR 和普通的 JIT 编译有什么区别? 答案是:普通的 JIT 编译是在「方法调用」时替换(从解释执行换成机器码执行);OSR 是在「循环执行过程中」替换(不需要等方法调用结束)。另外,OSR 编译后的代码和普通的 JIT 编译代码是分开存储的(因为栈帧布局不同)。」


第 45 题:JVM 调优实战案例(高频!)

一句话结论

JVM 调优实战 = 明确目标(吞吐量 vs 延迟) → 收集 GC 日志 → 分析 GC 日志(用 GCViewer 或 GCeasy) → 调整参数 → 验证效果。


深度解析

案例 1:Full GC 频繁(最常见)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
现象:
→ 应用程序运行一段时间后,频繁 Full GC
→ GC 日志里有很多 [Full GC (Ergonomics)] 或 [Full GC (Allocation Failure)]
→ 应用响应变慢

原因:
① 堆内存不足(可能是内存泄漏)
② 元空间不足(加载了太多类)
③ 老年代太小(对象提前晋升)

解决:
# 1. 调大堆内存
-Xms8g -Xmx8g

# 2. 调大元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

# 3. 调大新生代(减少提前晋升)
-Xmn4g

# 4. 如果是内存泄漏,用 jmap + MAT 分析

案例 2:GC 停顿时间长(影响响应时间)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
现象:
→ GC 日志里,[GC pause] 的时间很长(如 > 500ms)
→ 应用响应时间变慢(P99 延迟升高)

原因:
① 使用了 Serial Old 或 Parallel Old(标记-整理算法,STW 时间长)
② 堆内存太大(如 32GB),GC 时需要扫描整个堆

解决:
# 1. 换低延迟的 GC(如 G1 或 ZGC)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200

# 或(Java 11+)
-XX:+UseZGC # 停顿时间 < 10ms

# 2. 如果是 G1,调大堆内存(减少 GC 频率)
-Xms16g -Xmx16g

案例 3:CPU 飙高(GC 线程占用了太多 CPU)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
现象:
→ top 命令看到 Java 进程的 CPU 使用率 > 100%
→ 用 top -Hp pid 看到有很多 GC 线程

原因:
① 使用了 CMS(并发标记会占用 CPU)
② 堆内存不足,GC 频繁

解决:
# 1. 调大堆内存(减少 GC 频率)
-Xms8g -Xmx8g

# 2. 换 G1(GC 效率更高)
-XX:+UseG1GC

# 3. 如果是 ZGC(Java 11+),CPU 开销会小很多
-XX:+UseZGC

案例 4:OOM:Java heap space(堆内存不足)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
现象:
→ 应用抛出 java.lang.OutOfMemoryError: Java heap space
→ 应用崩溃

原因:
① 堆内存真的不足(需要调大)
② 内存泄漏(对象无法被回收)

解决:
# 1. 调大堆内存
-Xms8g -Xmx8g

# 2. 开启 OOM 时自动 dump 堆
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof

# 3. 用 MAT 分析 heapdump.hprof,找到内存泄漏点

面试加分回答

「JVM 调优的最重要原则:不要过早优化(Premature Optimization)。在调优之前,先确认:1. 是否是代码问题(如内存泄漏、死循环);2. 是否是数据库问题(如慢查询)。很多所谓的『JVM 调优』,实际上应该是『代码优化』或『数据库优化』。另外,GC 日志分析是调优的基础,推荐用 GCeasy(https://gceasy.io/)在线分析 GC 日志,会自动生成分析报告(包括 GC 频率、停顿时间、内存使用情况等),非常方便。」



第 46 题:JVM 类加载的详细过程是什么?

一句话结论

JVM 类加载 = 加载(Load)→ 连接(Link)→ 初始化(Initialize)。连接又分验证(Verify)、准备(Prepare)、解析(Resolve)三步。


深度解析

类加载的三个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1. 加载(Load)
↓
① 通过类的全限定名获取定义此类的二进制字节流
② 将字节流所代表的静态存储结构转为方法区的运行时数据结构
③ 在内存中生成一个 java.lang.Class 对象(作为方法区这个类的各种数据的访问入口)

2. 连接(Link)
├── 2.1 验证(Verify):确保字节码格式正确(魔数 CAFEBABE、语义合法)
├── 2.2 准备(Prepare):为类变量(static 变量)分配内存并初始化默认值(0 / false / null)
└── 2.3 解析(Resolve):将常量池中的符号引用转为直接引用(可选,可延迟)

3. 初始化(Initialize)
↓
执行类构造器 <clinit>() 方法(由编译器自动收集所有类变量的赋值动作和静态代码块合并产生)

示例:

1
2
3
4
5
6
7
public class LoadTest {
static int a = 10; // 准备阶段:a = 0;初始化阶段:a = 10
static final int B = 20; // 准备阶段:B = 20(final 常量,直接赋值)
static {
System.out.println("静态代码块"); // 初始化阶段执行
}
}

触发类初始化的条件(主动引用):

场景 示例
new 对象 new LoadTest()
访问类的静态变量(非 final) int x = LoadTest.a
访问类的静态方法 LoadTest.staticMethod()
反射调用 Class.forName("LoadTest")
初始化子类 子类初始化时,父类必须先初始化
主类(main 方法所在类) JVM 启动时自动初始化主类

注意:访问 static final 常量(如 B = 20)不会触发类初始化(常量在编译阶段已经放入常量池)。


面试加分回答

类加载的核心是初始化阶段(<clinit>() 方法)。这个方法由编译器自动收集所有类变量的赋值动作和静态代码块合并产生。JVM 会保证在多线程环境下,<clinit>() 方法会被正确地加锁、同步(所以静态代码块在多线程环境下是线程安全的)。另外,类加载是懒惰的(Lazy Initialization):只有在第一次使用类时才会初始化,这也是为什么第一次调用某个类的方法时会稍微慢一点的原因。


第 47 题:双亲委派模型是什么?如何打破双亲委派?

一句话结论

双亲委派:子类加载器先委托父加载器加载,父加载器加载不了时才自己加载。打破双亲委派的典型场景:Tomcat(Web 应用隔离)、SPI(JDBC 驱动加载)、OSGi。


深度解析

双亲委派模型的工作流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
自定义 ClassLoader
↓ 自己不加载,先委托父加载器
应用类加载器(AppClassLoader)
↓ 自己不加载,先委托父加载器
扩展类加载器(ExtClassLoader)
↓ 自己不加载,先委托父加载器
启动类加载器(BootstrapClassLoader)
↓ 没有父加载器了,自己尝试加载
↓ 加载不了,退回给子加载器
扩展类加载器尝试加载
↓ 加载不了,退回给子加载器
应用类加载器尝试加载
↓ 加载不了,抛 ClassNotFoundException

双亲委派的好处:

好处 说明
避免重复加载 父加载器加载过的类,子加载器不会重复加载
核心类不被篡改 你写的 java.lang.String 不会被加载(启动类加载器已经加载了真正的 String)

打破双亲委派的场景一:Tomcat(Web 应用隔离)

1
2
3
4
5
6
7
8
Tomcat 的 ClassLoader 层级:
BootstrapClassLoader(JVM 核心类)
↓
ExtClassLoader(扩展类)
↓
AppClassLoader(Web 应用共享类)
↓
WebAppClassLoader(每个 Web 应用一个,隔离!)

Tomcat 需要打破双亲委派:每个 Web 应用可以有自己版本的依赖(如不同版本的 Spring),不能委托给父加载器(否则会冲突)。

打破双亲委派的场景二:SPI(如 JDBC 驱动)

1
2
3
4
5
6
7
// JDBC 加载驱动(SPI 机制)
// ① Java 核心库(启动类加载器)提供了 java.sql.Driver 接口
// ② 但具体实现(如 MySQL 驱动)在 classpath 下(应用类加载器才能加载)
// ③ 启动类加载器加载不了 MySQL 驱动 → 需要打破双亲委派!

// Java 用线程上下文类加载器(Thread Context ClassLoader)打破双亲委派
ServiceLoader<Driver> drivers = ServiceLoader.load(Driver.class); // 打破双亲委派!

如何自定义 ClassLoader 打破双亲委派?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// ① 先检查是否已经加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
// ② 不委托父加载器,直接自己加载(打破双亲委派!)
c = findClass(name); // 从自定义路径加载
}
if (c == null) {
throw new ClassNotFoundException(name);
}
if (resolve) {
resolveClass(c);
}
return c;
}

@Override
protected Class<?> findClass(String name) {
// 从自定义路径读取 .class 文件
byte[] bytes = loadClassBytes(name);
return defineClass(name, bytes, 0, bytes.length);
}
}

面试加分回答

双亲委派模型是 JVM 类加载的默认机制,但不是强制的。打破双亲委派的核心原因是隔离需求(Tomcat 的每个 Web 应用需要隔离)和SPI 机制(核心库需要加载用户代码)。实际项目中,如果你需要热加载(如开发时的热部署),也需要自定义 ClassLoader 打破双亲委派。另外,Java 9 引入的**模块化系统(JPMS)**对类加载机制做了重构,不再是传统的双亲委派模型,但这是进阶话题,面试中很少问到。


第 48 题:方法内联(JIT 优化)是什么?

一句话结论

方法内联是 JIT 的最重要的优化之一:把频繁调用的小方法的字节码直接复制到调用方,减少方法调用的开销(压栈、跳转、返回)。


深度解析

方法调用的开销:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 没有内联:每次调用 getX() 都要压栈、跳转、返回(开销大)
public class InlineTest {
private int x = 10;

public int getX() { // 小方法(只有一行代码)
return x;
}

public void test() {
int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += getX(); // 100 万次方法调用(开销很大!)
}
}
}

方法内联后(JIT 自动优化):

1
2
3
4
5
6
7
// 内联后:getX() 的字节码直接复制到 test() 中(没有方法调用开销)
public void test() {
int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += x; // 直接访问字段(快!)
}
}

方法内联的条件:

条件 说明
方法体积小 默认:-XX:MaxInlineSize=35 字节码(小于 35 字节码才内联)
热点方法 调用频率高的方法(由热点探测算法判断)
非递归 递归方法不能被内联(会无限展开)
非 native native 方法不能被内联

如何查看方法内联(JVM 参数):

1
2
3
4
5
6
# 打印方法内联的日志
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyApp

# 输出示例:
# @ 1 java.lang.String::length (5 bytes) inline (hot)
# @ 2 com.example.MyClass::getX (2 bytes) inline (hot)

面试加分回答

方法内联是 JIT 优化的核心,它不仅能减少方法调用的开销,还能触发其他优化(如死代码消除、逃逸分析)。实际项目中,写代码时可以把频繁调用的小方法拆出来(提高代码可读性),JIT 会自动帮你内联(不会影响性能)。但如果你用反射调用方法,JIT 就无法内联(反射调用开销大),这也是为什么反射性能差的原因之一。


第 49 题:逃逸分析(JIT 优化)是什么?有哪些优化?

一句话结论

逃逸分析是 JIT 的高级优化:分析对象的作用域,如果对象没有逃逸出方法(即没有被人引用),就可以做栈上分配、锁消除、标量替换等优化。


深度解析

什么是「对象逃逸」?

1
2
3
4
5
6
7
8
9
10
11
12
13
public class EscapeAnalysis {
// 场景一:对象逃逸(不能被优化)
public User escape() {
User user = new User(); // user 对象逃逸了(作为返回值被人引用)
return user;
}

// 场景二:对象未逃逸(可以被优化)
public void noEscape() {
User user = new User(); // user 对象未逃逸(只在方法内部使用)
System.out.println(user.getName());
} // 方法结束后,user 对象不再被引用 → 可以优化!
}

逃逸分析的三大优化:

优化一:栈上分配(Stack Allocation)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 没有逃逸分析:所有对象都在堆上分配(需要 GC)
public void test() {
for (int i = 0; i < 1000000; i++) {
User user = new User(); // 100 万个对象在堆上分配(GC 压力大)
}
}

// 有逃逸分析:未逃逸的对象直接在栈上分配(方法结束,栈帧弹出,对象自动销毁,不需要 GC!)
public void test() {
for (int i = 0; i < 1000000; i++) {
User user = new User(); // 对象在栈上分配(快!不需要 GC)
}
}

优化二:锁消除(Lock Elision)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 没有锁消除:每次循环都要加锁、解锁(开销大)
public void test() {
for (int i = 0; i < 1000000; i++) {
synchronized (new Object()) { // 每次都是新的 Object(锁可以被消除!)
// do something
}
}
}

// 有锁消除:JIT 发现锁对象未逃逸,直接消除锁(快!)
// 编译后等价于:
public void test() {
for (int i = 0; i < 1000000; i++) {
// 锁被消除了(没有同步开销)
// do something
}
}

优化三:标量替换(Scalar Replacement)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 没有标量替换:在堆上分配 User 对象(需要 GC)
public void test() {
User user = new User();
user.setName("Alice");
System.out.println(user.getName());
}

// 有标量替换:不创建 User 对象,直接用局部变量替换(不需要在堆上分配对象!)
// 编译后等价于:
public void test() {
String name = "Alice"; // 直接用局部变量替换对象的字段
System.out.println(name);
}

开启/关闭逃逸分析(JVM 参数):

1
2
3
4
5
# 开启逃逸分析(Java 8+ 默认开启)
-XX:+DoEscapeAnalysis

# 关闭逃逸分析
-XX:-DoEscapeAnalysis

面试加分回答

逃逸分析是 JIT 的高级优化,它能做的优化(栈上分配、锁消除、标量替换)都能显著减少 GC 压力和同步开销。但要注意:逃逸分析本身也有开销(需要分析对象的作用域),对于短生命周期的小对象,逃逸分析带来的收益可能抵不过分析的开销。另外,逃逸分析在 C2 编译器(Server Compiler)中才生效,C1 编译器(Client Compiler)不做逃逸分析。这也是为什么服务器端的 Java 应用性能比客户端好的原因之一。


第 50 题:JVM 的线程模型是怎样的?和用户线程、内核线程的关系?

一句话结论

HotSpot JVM 的线程模型是 1:1 模型:一个 Java 线程直接映射到一个操作系统内核线程(由 OS 调度)。Java 没有自己实现的用户线程(Green Threads),线程的创建、调度、销毁都由操作系统完成。


深度解析

三种线程模型:

模型 说明 优点 缺点 代表
1:1(内核线程模型) 一个用户线程映射到一个内核线程 真正的并行(多核 CPU) 线程创建/切换开销大 Java、Linux pthread
N:1(用户线程模型) 多个用户线程映射到一个内核线程 线程切换快(不需要系统调用) 无法真正并行(一个内核线程) 早期的 Green Threads
M:N(混合模型) M 个用户线程映射到 N 个内核线程 兼顾切换速度和并行能力 实现复杂 Go 的 Goroutine

Java 的线程模型(1:1):

1
2
3
4
5
6
7
8
9
10
// Java 线程 = OS 内核线程
public class ThreadModel {
public static void main(String[] args) {
// 创建一个 Java 线程 = 创建一个 OS 内核线程(开销大,约 1MB 栈空间)
Thread t = new Thread(() -> {
System.out.println("Hello");
});
t.start(); // 启动线程 = 调用 OS 的 pthread_create()
}
}

为什么 Java 不用用户线程(Green Threads)?

1
2
3
4
5
6
7
8
9
Green Threads(用户线程):
+ 线程切换快(不需要系统调用)
- 无法利用多核 CPU(一个内核线程)
- 如果某个线程阻塞(如 IO),整个进程都会阻塞

内核线程(1:1 模型):
+ 真正并行(多核 CPU)
+ 一个线程阻塞,其他线程不受影响
- 线程创建/切换开销大

Java 能实现「轻量级线程」吗?—— Project Loom(虚拟线程)

1
2
3
4
5
6
7
8
9
10
11
// Java 19+ 引入虚拟线程(Project Loom)
public class VirtualThread {
public static void main(String[] args) {
// 创建 100 万个虚拟线程(开销极小,不像平台线程那样占 1MB 栈空间)
for (int i = 0; i < 1_000_000; i++) {
Thread.startVirtualThread(() -> {
System.out.println("Hello from virtual thread");
});
}
}
}

虚拟线程(Virtual Thread)= M:N 模型:多个虚拟线程映射到少量平台线程(内核线程),兼顾了切换速度和并行能力。


面试加分回答

JVM 的线程模型是1:1 模型(一个 Java 线程对应一个 OS 内核线程),这是 Java 能利用多核 CPU 的根本原因。但这也带来了线程创建/切换开销大的问题(每个线程占 1MB 栈空间,线程切换需要系统调用)。Java 19 引入的虚拟线程(Project Loom)就是为了解决这个问题:虚拟线程是轻量级线程(占用极少内存),由 JVM 调度,底层映射到少量平台线程(内核线程),可以创建数百万个虚拟线程而不会耗尽内存。这是 Java 并发编程的重大突破,未来可能取代线程池成为主流的并发模型。



JVM 面试八股文30条(深度详解版)
https://whyalwaysme.lol/2026/06/07/2026-06-07-jvm-interview-deep/
作者
Cassiur
发布于
2026年6月7日
许可协议