🎯 适用:Java 后端开发、架构师、中间件开发岗位面试 📖 内容覆盖:内存模型 · 对象创建 · 类加载 · 垃圾回收算法 · 垃圾回收器 · JIT编译 · JVM调优 · 内存排查 · 字节码 · 线程模型 · G1/ZGC/Shenandoah · 工具监控 · 场景实战
目录
- 一、JVM 内存模型(Q1-Q25)
- 二、对象创建与内存分配(Q26-Q35)
- 三、类加载机制(Q36-Q50)
- 四、垃圾回收算法(Q51-Q65)
- 五、垃圾回收器(Q66-Q85)
- 六、JIT 编译与执行引擎(Q86-Q95)
- 七、JVM 参数与调优(Q96-Q110)
- 八、内存泄漏与溢出排查(Q111-Q125)
- 九、字节码与执行引擎深入(Q126-Q135)
- 十、线程模型与并发深入(Q136-Q145)
- 十一、G1/ZGC/Shenandoah 深入(Q146-Q160)
- 十二、JVM 工具与监控(Q161-Q170)
- 十三、高级主题(Q171-Q185)
- 十四、场景实战题(Q186-Q200)
一、JVM 内存模型
Q1: JVM 的内存模型(运行时数据区)由哪些部分组成?
答案: 根据 JVM 规范,运行时数据区分为以下部分:
| 程序计数器(PC Register) | ❌ 私有 | 当前执行的字节码指令地址 | 无 OOM |
| Java 虚拟机栈 | ❌ 私有 | 栈帧(局部变量表、操作数栈、动态链接、返回地址) | StackOverflowError / OOM |
| 本地方法栈 | ❌ 私有 | Native 方法的栈帧 | StackOverflowError / OOM |
| 堆(Heap) | ✅ 共享 | 对象实例、数组 | OOM |
| 方法区(元空间) | ✅ 共享 | 类信息、常量、静态变量、JIT 代码缓存 | OOM |
| 运行时常量池 | ✅ 共享 | 字面量、符号引用(方法区的一部分) | OOM |
| 直接内存 | ✅ 共享 | NIO 的 ByteBuffer(不属于 JVM 规范) | OOM |
底层实现:HotSpot VM 中,方法区在 JDK 8 后由元空间(Metaspace) 实现,使用本地内存(Native Memory)而非堆内存。元空间由 Metaspace 类管理,底层通过 ChunkManager 和 SpaceManager 分配内存。
Q2: 堆和栈的区别是什么?
答案:
| 用途 | 存储局部变量、方法调用上下文 | 存储对象实例、数组 |
| 线程 | 线程私有 | 线程共享 |
| 生命周期 | 方法结束即销毁 | GC 回收时销毁 |
| 存取速度 | 快(LIFO,指针移动) | 慢(需要 GC 管理) |
| 空间大小 | 小(-Xss,默认 1MB) | 大(-Xms/-Xmx) |
| 异常 | StackOverflowError | OutOfMemoryError |
| 内存分配 | 编译期确定大小(连续分配) | 运行时动态分配(可能不连续) |
底层原理:栈的分配/释放只需要移动栈指针(SP 寄存器),是一个 O(1) 的操作。堆的分配需要查找空闲内存块(通过空闲链表或 TLAB),释放需要 GC 标记-清除/复制,复杂度更高。
Q3: 栈中存的是对象还是引用?
答案: 栈中存储的是基本类型的值和对象的引用(reference),不是对象本身。
MyObject obj = new MyObject();
- obj 是一个存储在栈上的引用(4 或 8 字节的指针)
- new MyObject() 创建的对象存储在堆上
- obj 的值是堆中对象的内存地址
底层实现:
- 在 64 位 JVM 中,引用通常是 8 字节。如果开启指针压缩(-XX:+UseCompressedOops,默认开启),引用压缩为 4 字节
- 指针压缩将 64 位地址压缩为 32 位偏移量,通过堆基地址 + 偏移量 × 8 的方式还原,支持最大 32GB 的堆
Q4: 堆分为哪几部分?
答案:
堆(Heap)
├── 新生代(Young Generation)—— 占堆的 1/3
│ ├── Eden 区 —— 占新生代的 80%
│ ├── Survivor 0(S0)—— 占新生代的 10%
│ └── Survivor 1(S1)—— 占新生代的 10%
└── 老年代(Old Generation)—— 占堆的 2/3
底层细节:
- Eden:S0:S1 的比例由 -XX:SurvivorRatio=8 控制(默认 8:1:1)
- 新生代与老年代的比例由 -XX:NewRatio=2 控制(默认老年代:新生代 = 2:1)
- G1 收集器中,堆被划分为大小相等的 Region(1MB~32MB),不再严格分代
Q5: 程序计数器的作用是什么?为什么是私有的?
答案: 程序计数器记录当前线程正在执行的字节码指令地址(行号)。
为什么私有:
- 多线程通过时间片轮转实现并发
- 线程切换时,需要保存/恢复执行位置
- 每个线程执行的代码位置不同,必须各自独立
- 如果执行 Native 方法,PC 值为 undefined
底层实现:程序计数器是 CPU 寄存器的抽象,HotSpot 中直接使用线程的 PC 寄存器实现。线程切换时,操作系统保存/恢复寄存器状态。
Q6: Java 虚拟机栈的栈帧包含哪些部分?
答案: 每个方法调用时创建一个栈帧,包含:
| 局部变量表 | 存储方法参数和局部变量(基本类型 + 引用) |
| 操作数栈 | 字节码指令的操作数栈(方法执行的工作区) |
| 动态链接 | 将符号引用转换为直接引用 |
| 方法返回地址 | 方法返回后继续执行的位置 |
| 附加信息 | 调试信息等 |
局部变量表的底层结构:
- 以变量槽(Slot) 为单位,每个 Slot 32 位
- long 和 double 占 2 个 Slot
- 非静态方法的第 0 个 Slot 存储 this 引用
- Slot 可复用(超出作用域的变量的 Slot 可被后续变量复用)
操作数栈的底层实现:
- 操作数栈是一个栈结构,字节码指令从操作数栈中弹出操作数、执行运算、将结果压回
- 例如 iadd 指令:弹出两个 int,相加,将结果压回
Q7: 方法区中存储什么内容?
答案: 方法区(JDK 8+ 为元空间)存储:
JDK 7 vs JDK 8 的变化:
- JDK 7:字符串常量池从永久代移到堆中
- JDK 8:永久代被元空间取代,静态变量移到堆中
- 元空间使用本地内存,不再受堆大小限制
Q8: 字符串常量池在哪里?
答案:
- JDK 6 及之前:永久代(方法区) 中
- JDK 7+:堆 中
底层实现:
- 字符串常量池本质是一个 StringTable,底层是哈希表
- 每个字符串常量在表中有一个唯一的 String 对象
- String.intern() 方法:如果常量池中已有相同内容的字符串,返回常量池中的引用;否则将当前字符串的引用放入常量池
String s1 = "abc"; // 在常量池中创建
String s2 = new String("abc"); // 在堆中创建新对象
String s3 = s2.intern(); // 返回常量池中的引用
System.out.println(s1 == s3); // true(同一个引用)
Q9: String s = new String("abc") 创建了几个对象?
答案:
- 如果常量池中没有 "abc":创建 2 个对象(常量池中的 “abc” + 堆中的 new String)
- 如果常量池中已有 "abc":创建 1 个对象(堆中的 new String)
底层执行过程:
Q10: 直接内存(Direct Memory)是什么?
答案: 直接内存不属于 JVM 运行时数据区,是通过 NIO 的 ByteBuffer.allocateDirect() 分配的堆外内存。
底层原理:
- Unsafe.allocateMemory() 直接调用 malloc() 分配本地内存
- 不受 GC 直接管理,通过 Cleaner(虚引用)在 GC 时释放
- 避免了堆内存和本地内存之间的数据复制,I/O 性能更高
- Netty、NIO 大量使用直接内存
OOM 场景:java.lang.OutOfMemoryError: Direct buffer memory——直接内存分配超过 -XX:MaxDirectMemorySize 限制。
Q11: 强引用、软引用、弱引用、虚引用有什么区别?
答案:
| 强引用 | 不回收(只要引用存在) | 普通对象引用 | 赋值操作 A a = new A() |
| 软引用 | 内存不足时回收 | 缓存(如图片缓存) | SoftReference<T> |
| 弱引用 | 下次 GC 时一定回收 | WeakHashMap 的键 | WeakReference<T> |
| 虚引用 | 随时回收,无法通过虚引用获取对象 | 堆外内存管理、对象回收跟踪 | PhantomReference<T> |
底层实现:
- 所有引用类型都继承自 java.lang.ref.Reference
- JVM 维护一个 Reference Handler 线程,负责处理引用队列
- 软引用的回收时机:JVM 在抛出 OOM 之前,会清理所有软引用
- 虚引用必须配合 ReferenceQueue 使用,当对象被 GC 后,虚引用被加入队列
Q12: 内存泄漏和内存溢出有什么区别?
答案:
| 定义 | 对象不再使用但仍被引用,无法被 GC 回收 | 内存不足,无法分配新对象 |
| 结果 | 可用内存逐渐减少 | 直接抛出 OutOfMemoryError |
| 原因 | 代码缺陷(如静态集合未清理) | 内存太小 / 对象太多 / 泄漏导致 |
| 解决 | 找到并修复引用未释放的代码 | 增大内存 / 修复泄漏 / 优化代码 |
常见内存泄漏场景:
Q13: 堆溢出(OOM: Java heap space)的常见原因?
答案:
排查步骤:
Q14: 栈溢出(StackOverflowError)的常见原因?
答案:
解决方案:
Q15: 元空间溢出(OOM: Metaspace)的常见原因?
答案:
底层原因:
- 元空间使用本地内存,由 Metaspace 的 ClassLoaderDataGraph 管理
- 每个类加载器有独立的 Metaspace 分配
- 类加载器未被回收 → 其加载的所有类都无法回收 → 元空间持续增长
Q16: 什么是逃逸分析?
答案: 逃逸分析是 JIT 编译器的一种优化技术,分析对象的作用域是否逃逸出方法或线程。
逃逸类型:
- 方法逃逸:对象被外部方法引用(如作为参数传递或返回值)
- 线程逃逸:对象被其他线程访问
基于逃逸分析的优化:
// 栈上分配示例
public void method() {
Point p = new Point(1, 2); // p 不逃逸出方法
System.out.println(p.x + p.y);
// JIT 优化后,p 直接在栈上分配,不需要堆分配和 GC
}
💡 要点:-XX:+DoEscapeAnalysis(默认开启)启用逃逸分析。栈上分配是最重要的优化之一,能显著减少 GC 压力。
Q17: 什么是 TLAB(Thread Local Allocation Buffer)?
答案: TLAB 是 JVM 为每个线程在 Eden 区中预先分配的一小块私有内存,用于加速对象分配。
为什么需要 TLAB:
- 堆是线程共享的,多线程同时分配对象需要加锁(CAS)
- TLAB 是线程私有的,分配时不需要加锁,提高了分配效率
底层实现:
- 每个线程有一个 TLAB,大小由 -XX:TLABSize 控制
- 对象优先在 TLAB 中分配
- TLAB 用完后,从 Eden 区申请新的 TLAB
- TLAB 中剩余空间不足时,使用指针碰撞(Bump the Pointer) 分配
// 对象分配流程
if (TLAB 有足够空间) {
在 TLAB 中分配(无需加锁)
} else if (对象是大对象) {
直接在老年代分配
} else {
在 Eden 区分配(需要 CAS 加锁)
}
💡 要点:TLAB 默认开启(-XX:+UseTLAB),是 JVM 对象分配的第一选择。
Q18: JVM 中对象的内存布局是怎样的?
答案: 在 HotSpot 中,一个对象在内存中由三部分组成:
┌────────────────────────────────────┐
│ 对象头(Header) │
│ ├── Mark Word(8 字节) │
│ │ ├── 哈希码(HashCode) │
│ │ ├── GC 分代年龄(4 bit) │
│ │ ├── 锁状态标志(2 bit) │
│ │ ├── 线程持有的锁 │
│ │ └── 偏向线程 ID │
│ └── 类型指针(Klass Pointer,4/8B)│
│ 指向方法区中的类元数据 │
├────────────────────────────────────┤
│ 实例数据(Instance Data) │
│ 存储对象的字段值 │
├────────────────────────────────────┤
│ 对齐填充(Padding) │
│ 凑齐 8 字节的整数倍 │
└────────────────────────────────────┘
Mark Word 的结构(64 位 JVM):
| 无锁 | hashCode(31) + age(4) + biased(1) | 01 |
| 偏向锁 | threadId(54) + epoch(2) + age(4) | 01 |
| 轻量级锁 | 指向栈中锁记录的指针(62) | 00 |
| 重量级锁 | 指向 Monitor 对象的指针(62) | 10 |
| GC 标记 | 空(62) | 11 |
Q19: 对象的创建过程是怎样的?
答案:
Object obj = new Object();
完整创建过程(底层 6 步):
- 指针碰撞(Bump the Pointer):堆内存规整时使用(Serial、ParNew 收集器)
- 空闲列表(Free List):堆内存不规整时使用(CMS 收集器)
- 优先在 TLAB 中分配
Q20: 对象的内存分配策略是怎样的?
答案:
| 大多数对象 | Eden 区 | TLAB 优先 |
| 大对象 | 老年代 | -XX:PretenureSizeThreshold 控制 |
| 长期存活对象 | 老年代 | 年龄 ≥ -XX:MaxTenuringThreshold(默认 15) |
| 动态年龄判断 | 老年代 | Survivor 中相同年龄对象总和 ≥ Survivor 空间 50% |
| 空间分配担保 | 老年代 | Minor GC 后 Survivor 放不下 |
大对象直接进入老年代的原因:
- 避免在 Eden 和 Survivor 之间来回复制(开销大)
- 大对象需要连续内存,新生代碎片化后可能分配失败
动态年龄判断的底层实现:
- GC 时,从年龄 1 开始累加 Survivor 中各年龄对象的大小
- 当累加到某个年龄 N 时,总和 ≥ Survivor 空间的 50%
- 则年龄 ≥ N 的对象直接晋升老年代
Q21: 什么是对象的可达性分析?GC Roots 有哪些?
答案: 可达性分析是 JVM 判断对象是否存活的算法。从 GC Roots 出发,沿引用链遍历,不可达的对象判定为可回收。
GC Roots 包括:
底层实现:
- HotSpot 使用 OopMap(Ordinary Object Pointer Map) 记录栈帧中哪些位置存储了引用
- GC 不需要遍历整个栈帧,直接查 OopMap 即可
- 安全点(Safepoint):只有在安全点才会生成/更新 OopMap
Q22: 什么是安全点(Safepoint)和安全区域(Safe Region)?
答案:
安全点:
- 程序执行过程中,只有在特定位置才能暂停进行 GC
- 安全点位置:方法调用、循环跳转、异常跳转等
- GC 时,所有线程必须跑到最近的安全点才能暂停(主动式中断)
安全区域:
- 线程处于 Sleep 或 Blocked 状态时,无法主动跑到安全点
- 安全区域是代码片段中引用关系不会发生变化的区域
- 线程进入安全区域时设置标志,GC 时不再等待这些线程
底层实现:
- JVM 维护一个安全点计数器,线程到达安全点时递增
- GC 线程设置安全点标志,等待所有线程到达安全点后开始 GC
Q23: 什么是记忆集(Remembered Set)和卡表(Card Table)?
答案: 记忆集和卡表用于解决跨代引用问题(老年代引用新生代对象)。
问题:Minor GC 只回收新生代,但老年代可能持有新生代对象的引用。如果不处理跨代引用,会误回收存活对象。
卡表(Card Table):
- 一个字节数组,每个元素对应老年代的一块内存区域(512 字节,称为卡页)
- 当老年代对象的引用字段被修改时,对应的卡页标记为脏页(dirty)
- Minor GC 时,只扫描脏页对应的卡页,找到跨代引用
记忆集(Remembered Set):
- G1 中使用,每个 Region 维护一个记忆集
- 记录其他 Region 中有哪些引用指向本 Region
- 使用 Point-Out 方式(谁引用了我)
写屏障(Write Barrier):
- 在引用赋值操作前后插入屏障代码
- 赋值时将对应的卡页标记为 dirty
- 类似于 AOP 的切面编程
Q24: 对象什么时候进入老年代?
答案:
Q25: 什么是空间分配担保?
答案: Minor GC 前,JVM 检查老年代的可用空间是否大于新生代所有对象的总空间。
担保流程:
JDK 6 Update 24 之后:规则简化为只要老年代可用空间 > 新生代所有对象总空间,就执行 Minor GC;否则 Full GC。
二、对象创建与内存分配
Q26: 对象在堆中的分配方式有哪些?
答案:
指针碰撞(Bump the Pointer):
- 堆内存是规整的(已用和空闲分开)
- 分配时移动指针即可
- 使用 Serial、ParNew 等带有压缩整理功能的收集器时使用
空闲列表(Free List):
- 堆内存不规整(已用和空闲交错)
- 维护一个空闲列表,分配时找到足够大的空闲块
- 使用 CMS 等不带压缩功能的收集器时使用
并发安全:
- 多线程同时分配需要同步,使用 CAS + 失败重试
- 优先在 TLAB 中分配(无需加锁)
Q27: 对象的生命周期是怎样的?
答案:
创建(new)→ 使用 → 不可达 → 被标记 → 被回收 → 内存释放
详细流程:
💡 要点:finalize() 只会被调用一次,且执行时间不确定,不推荐使用。Java 9+ 已标记为 @Deprecated。
Q28: 对象头中的 Mark Word 有什么作用?
答案: Mark Word 是对象头的核心部分(64 位 JVM 中占 8 字节),存储对象的运行时元数据。
存储的信息:
- 哈希码(HashCode):System.identityHashCode() 计算的值
- GC 分代年龄:4 bit,最大值 15(所以 MaxTenuringThreshold 最大为 15)
- 锁状态标志:标识当前锁的状态(无锁/偏向锁/轻量级锁/重量级锁)
- 偏向线程 ID:偏向锁模式下记录持有锁的线程
- Epoch:偏向锁的时间戳,用于批量撤销
Mark Word 的复用:
- 无锁状态:存储 hashCode + age
- 偏向锁状态:存储 threadId + epoch + age(hashCode 被覆盖,需要时重新计算)
- 轻量级锁:存储指向栈中 Lock Record 的指针
- 重量级锁:存储指向 Monitor 对象的指针
Q29: 什么是指针压缩(Compressed Oops)?
答案: 指针压缩是 64 位 JVM 的优化技术,将 64 位的对象引用压缩为 32 位。
原理:
- 64 位引用占 8 字节,32 位引用占 4 字节
- 压缩后的引用 = (实际地址 – 堆基地址) / 8
- 解压 = 压缩值 × 8 + 堆基地址
- 支持最大 32GB 的堆(2^32 × 8 = 32GB)
开启条件:
- -XX:+UseCompressedOops(默认开启,堆 < 32GB 时)
- 堆 ≥ 32GB 时自动关闭
💡 要点:堆大小在 4GB~32GB 之间时,指针压缩效果最好。超过 32GB 后引用膨胀为 8 字节,实际可用内存反而可能不如 32GB。
Q30: 对象的访问定位方式有哪些?
答案:
句柄访问:
- 堆中划分一块"句柄池"
- 引用存储的是句柄地址,句柄中包含对象实例数据指针和类型数据指针
- 优点:引用稳定(对象移动时只需改句柄,不改引用)
- 缺点:两次指针定位,性能较低
直接指针访问(HotSpot 使用):
- 引用直接存储对象的堆地址
- 对象头中包含类型指针,指向方法区中的类元数据
- 优点:一次定位,性能更高
- 缺点:对象移动时需要更新所有引用
句柄访问: 引用 → 句柄池 → 对象实例数据
→ 类型数据(方法区)
直接指针: 引用 → 对象实例数据 → 类型数据(方法区)
Q31: 什么是对象的标量替换?
答案: 标量替换是逃逸分析的优化之一。如果 JIT 发现对象不逃逸,会将对象拆解为基本类型变量,直接在栈上存储。
// 原始代码
public int method() {
Point p = new Point(1, 2);
return p.x + p.y;
}
// JIT 标量替换后(等价于)
public int method() {
int x = 1;
int y = 2;
return x + y;
}
💡 要点:标量替换消除了堆分配和 GC 开销,是最激进的栈上分配优化。
Q32: 什么是偏向锁、轻量级锁、重量级锁?
答案: 这是 JVM 对 synchronized 的锁升级优化:
| 无锁 | hashCode + age | 无竞争 | 最高 |
| 偏向锁 | threadId + epoch | 只有一个线程访问 | 极高(无 CAS) |
| 轻量级锁 | 指向 Lock Record | 少量竞争(自旋) | 高 |
| 重量级锁 | 指向 Monitor | 激烈竞争(阻塞) | 低 |
锁升级过程:
无锁 → 偏向锁(第一个线程访问)
→ 轻量级锁(第二个线程竞争,撤销偏向)
→ 重量级锁(自旋失败,膨胀为 Monitor)
底层实现:
- 偏向锁:在 Mark Word 中记录 threadId,后续该线程进入时无需 CAS
- 轻量级锁:在栈帧中创建 Lock Record,将 Mark Word 复制到 Lock Record,CAS 将 Mark Word 指向 Lock Record
- 重量级锁:膨胀为 Monitor 对象(ObjectMonitor),使用互斥量(Mutex)实现阻塞
Q33: 什么是自旋锁?什么是自适应自旋?
答案:
自旋锁:
- 轻量级锁竞争时,线程不立即阻塞,而是循环等待(自旋)
- 避免了线程阻塞和唤醒的开销(用户态/内核态切换)
- 适合锁持有时间短的场景
自适应自旋:
- JVM 根据历史数据自动调整自旋次数
- 如果上次自旋成功获得锁 → 下次多自旋几次
- 如果某个锁很少自旋成功 → 少自旋或直接阻塞
参数:
- -XX:+UseSpinning(JDK 6+ 默认开启)
- -XX:PreBlockSpin=10(默认自旋 10 次)
Q34: 对象的 hashCode 存在哪里?
答案:
- 未加锁时:hashCode 存储在 Mark Word 中(31 bit)
- 加锁后:Mark Word 被锁信息覆盖,hashCode 被移出
- 轻量级锁:hashCode 复制到 Lock Record 中
- 重量级锁:hashCode 存储在 Monitor 对象中
hashCode 的计算:
- System.identityHashCode() 基于对象的内存地址计算(不重写 hashCode 时)
- 重写 hashCode() 后,使用自定义逻辑
Q35: 什么是内存对齐(Padding)?
答案: HotSpot 要求对象的起始地址必须是 8 字节的整数倍。
原因:
对象大小计算:
对象大小 = 对象头(12 字节)+ 实例数据 + 对齐填充(补齐到 8 的倍数
三、类加载机制
Q36: 类的加载过程是怎样的?
答案: 类的生命周期包含 7 个阶段:
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution)
→ 初始化(Initialization) → 使用(Using) → 卸载(Unloading)
其中,验证、准备、解析合称为连接(Linking)。
各阶段详解:
| 加载 | 通过类的全限定名获取字节码 → 将字节码转化为方法区的运行时数据结构 → 在堆中生成 Class 对象 |
| 验证 | 文件格式验证、元数据验证、字节码验证、符号引用验证 |
| 准备 | 为类变量(static)分配内存并设置零值(不是初始值) |
| 解析 | 将符号引用替换为直接引用(指针/偏移量) |
| 初始化 | 执行 <clinit>() 方法(类构造器),为类变量赋初始值 |
准备阶段的细节:
static int value = 123; // 准备阶段 value = 0,初始化阶段 value = 123
static final int VALUE = 123; // 准备阶段 VALUE = 123(编译期常量,ConstantValue 属性)
Q37: 类加载器有哪些?
答案:
| Bootstrap ClassLoader | JAVA_HOME/lib(rt.jar 等) | C++ 实现(非 Java 类) |
| Extension ClassLoader | JAVA_HOME/lib/ext | Java 实现(sun.misc.Launcher$ExtClassLoader) |
| Application ClassLoader | CLASSPATH 下的类 | Java 实现(sun.misc.Launcher$AppClassLoader) |
| 自定义 ClassLoader | 用户自定义路径 | 继承 java.lang.ClassLoader |
底层实现:
- 每个类加载器都有一个 parent 字段,指向父加载器(不是继承关系,是组合关系)
- Bootstrap ClassLoader 由 C++ 实现,Java 中无法直接引用,getParent() 返回 null
Q38: 什么是双亲委派模型?
答案: 当一个类加载器收到类加载请求时,首先委派给父加载器加载,只有父加载器无法加载时才自己加载。
自定义 ClassLoader → Application ClassLoader → Extension ClassLoader → Bootstrap ClassLoader
← 找不到才向下传递 ←
底层实现(ClassLoader.loadClass() 源码):
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 2. 委派给父加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 3. 父加载器为 null,使用 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器无法加载
}
if (c == null) {
// 4. 父加载器无法加载,自己加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
Q39: 双亲委派模型的作用是什么?
答案:
为什么核心类不能被篡改:
- 用户定义的 java.lang.String 会委派给 Bootstrap ClassLoader
- Bootstrap ClassLoader 会加载 rt.jar 中的官方 String
- 用户的自定义 String 永远不会被加载
Q40: 如何打破双亲委派模型?
答案:
SPI 机制打破双亲委派的原因:
- JDBC 的 Driver 接口由 Bootstrap ClassLoader 加载(在 rt.jar 中)
- 但 Driver 的实现类在 CLASSPATH 中,需要 Application ClassLoader 加载
- 使用 Thread.currentThread().getContextClassLoader() 获取 Application ClassLoader 来加载
Tomcat 打破双亲委派的原因:
- 不同 Web 应用可能依赖不同版本的同一个库
- 需要每个 Web 应用有独立的类加载器,互不影响
- WebAppClassLoader 优先加载自己目录下的类,不委派父加载器
Q41: 什么是类的初始化时机?
答案: 类的初始化(执行 <clinit>())在以下情况下触发:
主动引用(会触发初始化):
被动引用(不会触发初始化):
Q42: <clinit>() 方法有什么特点?
答案: <clinit>() 是编译器自动生成的类构造器方法,用于初始化类变量和静态代码块。
特点:
public class Test {
static int a = 1; // 收集到 <clinit>()
static {
a = 2; // 收集到 <clinit>()
System.out.println("static block");
}
// 编译器生成:
// static {
// a = 1;
// a = 2;
// System.out.println("static block");
// }
}
Q43: 什么是类的卸载?什么条件下会卸载?
答案: 类的卸载是指从方法区(元空间)中移除类的元数据。
卸载条件(三个条件同时满足):
实际场景:
- 由 Bootstrap、Extension、Application ClassLoader 加载的类基本不会被卸载
- 自定义类加载器加载的类可能被卸载(如 OSGi、热部署场景)
Q44: 什么是字节码?Java 如何实现跨平台?
答案: 字节码(Bytecode)是 Java 源代码编译后的中间代码,存储在 .class 文件中。
跨平台原理:
Java 源码 → javac 编译 → .class 字节码 → JVM(各平台不同)→ 机器码
- 字节码是平台无关的,可以在任何 JVM 上运行
- 每个平台有对应的 JVM 实现,负责将字节码转换为该平台的机器码
字节码指令:
- 由一个字节的操作码(opcode)+ 操作数组成
- 共约 200 条指令,如 aload_0、invokevirtual、ireturn 等
Q45: 类加载器的命名空间是什么?
答案: 每个类加载器有自己的命名空间,由该加载器及其所有父加载器加载的类组成。
规则:
- 同一个命名空间中,类名必须唯一
- 不同命名空间中,可以存在同名的类(不同实例)
- 判断两个类是否相同:类名相同 + 同一个类加载器加载
// 两个不同类加载器加载的同名类是不同的类
ClassLoader cl1 = new MyClassLoader();
ClassLoader cl2 = new MyClassLoader();
Class<?> c1 = cl1.loadClass("com.example.MyClass");
Class<?> c2 = cl2.loadClass("com.example.MyClass");
System.out.println(c1 == c2); // false
Q46: 什么是类的连接阶段中的验证?
答案: 验证是连接的第一步,确保字节码的安全性和正确性。
四种验证:
Q47: 如何自定义类加载器?
答案: 继承 java.lang.ClassLoader,重写 findClass() 方法:
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 读取字节码文件
byte[] data = loadClassData(name);
// 2. 将字节码转换为 Class 对象
return defineClass(name, data, 0, data.length);
}
private byte[] loadClassData(String name) {
String path = classPath + "/" + name.replace('.', '/') + ".class";
// 读取文件内容到 byte[]
// …
}
}
💡 要点:重写 findClass() 而非 loadClass(),这样仍然遵循双亲委派模型。如果要打破双亲委派,需要重写 loadClass()。
Q48: 什么是运行时常量池?
答案: 运行时常量池是方法区的一部分,在类加载的加载阶段,将 .class 文件中的常量池(Constant Pool)加载到内存中形成。
常量池内容:
- 字面量:字符串、整数、浮点数等常量
- 符号引用:类名、方法名、字段名、描述符
动态性:
- 运行时常量池不要求编译期产生,运行时也可以将新的常量放入
- 例如 String.intern() 方法
Q49: 什么是方法句柄(Method Handle)?
答案: 方法句柄(java.lang.invoke.MethodHandle)是 JDK 7 引入的轻量级方法调用机制,类似于 C 语言的函数指针。
与反射的区别:
- 反射基于 Java 层面的 java.lang.reflect.Method
- 方法句柄直接指向底层方法,经过 JIT 优化后性能接近直接调用
- 方法句柄支持参数适配和返回值适配
Q50: Java 中的动态代理有哪几种实现方式?
答案:
JDK 动态代理:
- 基于接口,通过 java.lang.reflect.Proxy 实现
- 运行时生成代理类($Proxy0 等),实现目标接口
- 底层使用 ProxyGenerator 生成字节码
CGLIB 动态代理:
- 基于继承,通过生成目标类的子类实现代理
- 使用 ASM 字节码框架生成子类的字节码
- 不能代理 final 类和 final 方法
底层差异:
- JDK 代理:代理类继承 Proxy,实现目标接口,通过 InvocationHandler 调用
- CGLIB:代理类继承目标类,重写目标方法,通过 MethodInterceptor 调用
四、垃圾回收算法
Q51: 如何判断对象是否可以被回收?
答案:
引用计数法(JVM 不使用):
- 每个对象维护一个引用计数器
- 引用 +1,断开 -1,计数为 0 时回收
- 问题:无法解决循环引用
可达性分析算法(JVM 使用):
- 从 GC Roots 出发,沿引用链遍历
- 不可达的对象判定为可回收
- 需要两次标记确认(finalize 可能复活对象)
Q52: 垃圾回收算法有哪些?
答案:
| 标记-清除 | 标记存活→清除未标记 | 简单 | 碎片、效率低 | CMS 老年代 |
| 标记-复制 | 存活对象复制到新区域 | 无碎片、高效 | 空间浪费 50% | 新生代 |
| 标记-整理 | 标记→存活对象向一端压缩 | 无碎片、空间高效 | 移动成本高 | 老年代 |
| 分代收集 | 按生命周期分区,不同区用不同算法 | 综合最优 | 实现复杂 | 所有主流收集器 |
标记-复制的优化:
- 新生代 Eden:S0:S1 = 8:1:1
- 每次只使用 Eden + 一个 Survivor(90% 空间利用率)
- 只浪费 10% 的空间
Q53: 什么是分代收集理论?
答案: 分代收集基于两个分代假说:
实践:
- 新生代:对象生命周期短,使用标记-复制算法,频繁回收
- 老年代:对象生命周期长,使用标记-清除/整理算法,低频回收
- 新生代中存活的对象经过多次 GC 后晋升到老年代
跨代引用假说:
- 跨代引用相对于同代引用是极少数的
- 使用记忆集(Remembered Set) 解决跨代引用问题
Q54: Minor GC、Major GC、Full GC 的区别?
答案:
| Minor GC / Young GC | 新生代 | Eden 区满 | 频繁 | 短(毫秒级) |
| Major GC / Old GC | 老年代 | 老年代空间不足 | 较少 | 较长 |
| Full GC | 整个堆 + 方法区 | 多种(见下方) | 最少 | 最长(秒级) |
Full GC 的触发条件:
Q55: 什么是可达性分析的两次标记?
答案: 对象被判定为不可达后,需要经过两次标记才能确定回收:
第一次标记:可达性分析发现对象不可达
第二次标记:检查对象是否需要执行 finalize()
- 如果对象没有重写 finalize() 或 finalize() 已被调用过 → 标记为可回收
- 如果对象重写了 finalize() 且未被调用过 → 放入 F-Queue
- Finalizer 线程异步执行 F-Queue 中对象的 finalize()
- 如果在 finalize() 中重新建立引用(如 this 赋值给类变量)→ 对象复活
- 复活的对象在第二次标记时被移出回收集合
public class FinalizeExample {
static FinalizeExample instance;
@Override
protected void finalize() throws Throwable {
super.finalize();
instance = this; // 复活!
}
public static void main(String[] args) throws Exception {
instance = new FinalizeExample();
instance = null;
System.gc();
Thread.sleep(500);
System.out.println(instance); // 非 null(复活了)
}
}
💡 要点:finalize() 只会被调用一次。如果复活后再次不可达,finalize 不会再被调用,直接回收。
Q56: 什么是三色标记法?
答案: 三色标记法是并发标记阶段使用的对象标记算法:
| 白色 | 尚未被扫描的对象(GC 结束后仍为白色的对象被回收) |
| 灰色 | 已被扫描,但其引用的对象尚未全部扫描 |
| 黑色 | 已被扫描,且其引用的对象全部扫描完成
标记过程:
并发标记的问题:
- 漏标(对象消失):黑色对象新增了对白色对象的引用,但灰色对象删除了对该白色对象的引用
- 解决方案:写屏障 + 增量更新(CMS) 或 写屏障 + 原始快照(G1)
Q57: 什么是写屏障(Write Barrier)?
答案: 写屏障是在引用赋值操作前后插入的一段代码,类似于 AOP 的 Around 通知。
作用:
- 维护记忆集(Remembered Set / Card Table)
- 实现增量更新或原始快照
写屏障的类型:
- 写前屏障(Pre-Write Barrier):赋值前执行(G1 的 SATB 使用)
- 写后屏障(Post-Write Barrier):赋值后执行(CMS 的增量更新使用)
底层实现:
// 写后屏障的伪代码
void post_write_barrier(Object* obj, Object* new_ref) {
// 将卡页标记为 dirty
card_table[card_index(obj)] = DIRTY;
}
Q58: 什么是安全点与安全区域的配合?
答案: GC 需要暂停所有用户线程(STW),但线程不能在任意位置暂停(可能导致数据不一致)。
安全点:
- 代码中的特定位置(方法调用、循环跳转、异常跳转)
- GC 时,线程运行到最近的安全点才能暂停
- 使用主动式中断:设置安全点标志,线程轮询检查
安全区域:
- 线程处于 Sleep/Blocked 等状态时,无法主动跑到安全点
- 安全区域是引用关系不会变化的代码段
- 线程进入安全区域时设置标志,GC 不再等待
Q59: 什么是 STW(Stop The World)?
答案: STW 是 GC 暂停所有用户线程的行为。
为什么需要 STW:
- 可达性分析需要在一个一致性快照上进行
- 如果不暂停,对象引用关系不断变化,分析结果不准确
哪些阶段会 STW:
- 初始标记(Initial Mark):标记 GC Roots 直接关联的对象(很短)
- 重新标记(Remark):修正并发标记期间的变动
- 复制/整理阶段:部分收集器需要 STW
减少 STW 的方法:
- 使用低延迟收集器(CMS、G1、ZGC)
- 增量标记(将标记过程分多次执行)
- 并发标记(与用户线程同时执行)
Q60: 什么是记忆集(Remembered Set)?
答案: 记忆集用于解决跨代引用问题。
问题:Minor GC 只回收新生代,但老年代可能引用新生代对象。如果不知道哪些老年代对象引用了新生代对象,就需要扫描整个老年代。
记忆集:
- 记录"谁引用了我"(Point-Out 方式)
- Minor GC 时,只扫描记忆集中的引用,不需要扫描整个老年代
粒度:
- 字长精度:记录精确到字长
- 对象精度:记录精确到对象
- 卡精度:记录精确到卡页(Card Page,512 字节)—— JVM 实际使用
Q61: 什么是卡表(Card Table)?
答案: 卡表是记忆集的一种实现方式,使用字节数数组实现。
结构:
Card Table: [0][1][0][1][0][0][1][0]…
↑ ↑ ↑
dirty dirty
- 每个元素(卡页)对应老年代的一块 512 字节的内存区域
- 值为 1(dirty)表示该区域中有引用指向新生代
- 值为 0(clean)表示没有跨代引用
写屏障维护卡表:
// 引用赋值时
obj.field = new_ref;
// 写后屏障自动执行
card_table[card_index(obj)] = DIRTY;
Q62: 什么是对象的晋升(Promotion)?
答案: 对象从新生代晋升到老年代的过程。
晋升条件:
动态年龄判断的底层实现:
GC 后,计算 Survivor 中各年龄对象的总大小:
age=1: 10MB
age=2: 15MB
age=3: 20MB
Total = 10 + 15 = 25MB ≥ Survivor空间(32MB) × 50% = 16MB
→ age ≥ 2 的对象(15+20=35MB)全部晋升到老年代
Q63: 什么是空间分配担保?
答案: Minor GC 前,JVM 检查老年代是否有足够空间容纳新生代可能晋升的对象。
流程:
Q64: 什么是引用计数法?为什么 JVM 不使用?
答案: 引用计数法为每个对象维护一个引用计数器:
- 引用 +1,断开引用 -1
- 计数为 0 时回收
为什么 JVM 不使用:
- 无法解决循环引用:两个对象互相引用,计数永远不为 0
- 需要额外的计数器空间
- 每次引用变更都需要更新计数器
// 循环引用示例
Object a = new Object();
Object b = new Object();
a.ref = b;
b.ref = a;
a = null;
b = null;
// 引用计数法无法回收 a 和 b(互相引用)
// 可达性分析可以回收(从 GC Roots 不可达)
Q65: 什么是根节点枚举(Root Scanning)?
答案: 根节点枚举是 GC 的第一步,找出所有 GC Roots。
HotSpot 的优化:
- 使用 OopMap 记录栈帧中哪些位置存储了引用
- GC 时不需要遍历整个栈帧,直接查 OopMap
- OopMap 在安全点生成/更新
- 类加载时,也会记录静态字段的 OopMap
OopMap 的作用:
- 加速根节点枚举
- 精确确定哪些位置是引用,哪些是基本类型
- 避免误将基本类型当作引用
五、垃圾回收器
Q66: 有哪些垃圾回收器?
答案:
| Serial | 新生代 | 复制 | 单线程、STW | -XX:+UseSerialGC |
| ParNew | 新生代 | 复制 | Serial 的多线程版 | -XX:+UseParNewGC |
| Parallel Scavenge | 新生代 | 复制 | 吞吐量优先 | -XX:+UseParallelGC |
| Serial Old | 老年代 | 标记-整理 | 单线程、STW | — |
| Parallel Old | 老年代 | 标记-整理 | 多线程 | -XX:+UseParallelOldGC |
| CMS | 老年代 | 标记-清除 | 低延迟 | -XX:+UseConcMarkSweepGC |
| G1 | 整堆 | 复制+标记-整理 | Region 化、可控停顿 | -XX:+UseG1GC |
| ZGC | 整堆 | 染色指针+读屏障 | 停顿 < 10ms | -XX:+UseZGC |
| Shenandoah | 整堆 | 转发指针 | 停顿 < 10ms | -XX:+UseShenandoahGC |
Q67: Serial 收集器的特点?
答案:
- 单线程执行 GC,需要完全 STW
- 使用复制算法回收新生代
- 简单高效,适合客户端模式或小内存场景
- 是所有收集器中内存消耗最小的
用户线程 → STW → 单线程 GC → 恢复用户线程
Q68: ParNew 收集器的特点?
答案:
- Serial 的多线程版本
- 使用复制算法回收新生代
- 是唯一能与 CMS 配合的新生代收集器
- 并行 GC,但仍然需要 STW
用户线程 → STW → 多线程 GC → 恢复用户线程
Q69: Parallel Scavenge 收集器的特点?
答案:
- 目标是达到可控的吞吐量(Throughput)
- 吞吐量 = 用户代码运行时间 / (用户代码运行时间 + GC 时间)
- 使用复制算法回收新生代
- 支持自适应调节策略(-XX:+UseAdaptiveSizePolicy)
参数:
- -XX:MaxGCPauseMillis:最大 GC 停顿时间(毫秒)
- -XX:GCTimeRatio:吞吐量大小(默认 99,即 GC 时间 ≤ 1%)
Q70: CMS 收集器的特点和工作流程?
答案: CMS(Concurrent Mark Sweep)以最短停顿时间为目标。
四个阶段:
1. 初始标记(STW)—— 标记 GC Roots 直接关联的对象(很快)
2. 并发标记 —— 与用户线程并发执行,遍历对象图(最耗时)
3. 重新标记(STW)—— 修正并发标记期间的变动
4. 并发清除 —— 与用户线程并发执行,清除未标记对象
缺点:
参数:
- -XX:CMSInitiatingOccupancyFraction:触发 CMS 的老年代占用比例(默认 92%)
- -XX:+UseCMSCompactAtFullCollection:Full GC 后压缩(默认开启)
- -XX:CMSFullGCsBeforeCompaction:多少次 Full GC 后压缩一次
Q71: G1 收集器的特点和工作流程?
答案: G1(Garbage-First)是 JDK 9 的默认收集器,面向大内存和可控停顿场景。
核心设计:
- 将堆划分为大小相等的 Region(1MB~32MB)
- 每个 Region 可以是 Eden、Survivor、Old 或 Humongous
- 通过优先回收垃圾最多的 Region(Garbage-First)实现可控停顿
工作流程:
1. 初始标记(STW)—— 标记 GC Roots 直接关联的对象(借用 Minor GC 的 STW)
2. 并发标记 —— 与用户线程并发执行
3. 最终标记(STW)—— 处理并发标记遗留的 SATB 记录
4. 筛选回收(STW)—— 根据停顿时间目标,选择回收价值最高的 Region
Mixed GC:
- 回收所有新生代 Region + 部分老年代 Region
- 通过 -XX:MaxGCPauseMillis(默认 200ms)控制停顿时间
Humongous 对象:
- 超过 Region 50% 大小的对象
- 直接分配在连续的 Humongous Region 中
- 属于老年代的一部分
Q72: CMS 和 G1 的区别?
答案:
| 算法 | 标记-清除 | 复制+标记-整理 |
| 碎片 | 有碎片 | 无碎片(Region 内压缩) |
| 停顿 | 不可预测 | 可设定目标(MaxGCPauseMillis) |
| 内存 | 连续分代 | Region 化 |
| 适用 | 中小堆(< 8GB) | 大堆(> 4GB) |
| 回收范围 | 老年代 | 整堆(Mixed GC) |
| 并发阶段 | 并发标记+并发清除 | 仅并发标记 |
| JDK | JDK 9 标记废弃 | JDK 9 默认 |
Q73: ZGC 收集器的特点?
答案: ZGC 是 JDK 11 引入的超低延迟收集器,目标停顿时间 < 10ms,且不随堆大小增长。
核心技术:
染色指针(Colored Pointer):
- 64 位指针中,高 4 位用于存储标记信息(Marked0/Marked1/Remapped/Finalizable)
- 对象的引用信息直接存储在指针中,不需要读取对象头
读屏障(Load Barrier):
- 访问对象时检查指针的标记位
- 如果指针被标记,先执行修正操作再访问
并发整理:
- 标记、转移、重定位几乎完全并发执行
- 几乎无 STW
ZGC 的工作流程:
1. 并发标记(几乎无 STW)
2. 并发预备重分配
3. 并发重分配(转移存活对象)
4. 并发重映射(修正引用)
参数:
- -XX:+UseZGC(JDK 11+)
- -XX:ZCollectionInterval:GC 间隔时间
- -XX:SoftMaxHeapSize:软上限堆大小
Q74: Shenandoah 收集器的特点?
答案: Shenandoah 是 Red Hat 开发的低延迟收集器(JDK 12+),与 ZGC 类似。
核心技术:
- 转发指针(Forwarding Pointer):每个对象头部有一个转发指针,指向对象的新位置
- Brooks Pointer:对象移动时,更新转发指针,后续访问自动转发到新位置
- 并发整理:几乎完全并发
与 ZGC 的区别:
- ZGC 使用染色指针,Shenandoah 使用转发指针
- Shenandoah 有初始标记和最终标记两个短暂 STW
- ZGC 不受堆大小限制,Shenandoah 在超大堆下有一定压力
Q75: Epsilon 收集器是什么?
答案: Epsilon 是 JDK 11 引入的**"无操作"收集器**,只负责内存分配,不负责回收。
使用场景:
- 性能测试(排除 GC 的影响)
- 生命周期很短的应用
- 一次性任务
java -XX:+UseEpsilonGC -Xmx1g MyApp
Q76: 什么是吞吐量优先和低延迟优先的收集器选择?
答案:
| 吞吐量优先(后台计算) | Parallel Scavenge + Parallel Old | 最大化代码执行时间占比 |
| 低延迟优先(Web 服务) | G1 或 ZGC | 最小化 STW 时间 |
| 超低延迟(金融交易) | ZGC 或 Shenandoah | STW < 10ms |
| 小内存(客户端) | Serial | 简单高效 |
| JDK 8 默认 | Parallel Scavenge + Parallel Old | — |
| JDK 9+ 默认 | G1 | — |
Q77: G1 的 Region 大小如何选择?
答案:
- 通过 -XX:G1HeapRegionSize=n 设置(1MB~32MB,2 的幂)
- 默认:堆大小 / 2048
选择原则:
- Region 太小:记忆集(RSet)占用太多内存
- Region 太大:回收效率降低(每个 Region 的垃圾比例不精确)
- 一般使用默认值即可,大堆可以适当增大
Q78: 什么是 SATB(Snapshot At The Beginning)?
答案: SATB 是 G1 在并发标记阶段使用的算法,保证并发标记的正确性。
原理:
- 并发标记开始时,创建一个逻辑快照
- 并发标记期间,如果引用被删除,通过写前屏障将旧引用记录到 SATB 缓冲区
- 最终标记阶段处理 SATB 缓冲区中的记录
- 保证了并发标记期间不会漏标对象
与 CMS 的增量更新对比:
- SATB:记录被删除的引用(原始快照)
- 增量更新:记录新增的引用
Q79: 什么是 G1 的 Mixed GC?
答案: Mixed GC 是 G1 特有的回收方式,同时回收:
- 所有新生代 Region(Eden + Survivor)
- 部分老年代 Region(选择回收价值最高的)
触发条件:
- 老年代占用率达到 -XX:InitiatingHeapOccupancyPercent(默认 45%)
- 触发并发标记
- 并发标记完成后,触发 Mixed GC
回收过程:
1. 初始标记(STW,借用 Minor GC)
2. 并发标记
3. 最终标记(STW)
4. 筛选回收(STW)—— 选择回收价值最高的 Region
Q80: G1 的停顿时间模型是什么?
答案: G1 通过停顿预测模型(Pause Prediction Model)实现可控停顿。
原理:
为什么叫 Garbage-First:
- 优先回收垃圾最多的 Region
- 最大化每次 GC 的回收效率
Q81: 什么是记忆集(RSet)在 G1 中的实现?
答案: G1 的每个 Region 维护一个 Remembered Set(RSet),记录其他 Region 中有哪些引用指向本 Region。
RSet 的结构:
- 使用 Per-Region Table 实现
- 每个 Card 对应一个 bit,标记其他 Region 的卡页是否 dirty
- 使用哈希表存储,key 是引用来源的 Region,value 是 dirty card 列表
RSet 的维护:
- 引用赋值时,写后屏障更新 RSet
- RSet 占用额外内存(约 5%~10% 的堆)
Q82: 什么是写屏障的 SATB 队列?
答案: G1 的写前屏障将删除的引用记录到 SATB 队列中。
结构:
- 每个线程有一个 SATB 队列(Thread Local Buffer)
- 并发标记期间,引用被删除时,旧引用被放入队列
- 队列满后,放入全局 SATB 队列
- 最终标记阶段处理所有 SATB 队列
Q83: ZGC 的染色指针如何工作?
答案: ZGC 将对象引用的 64 位指针中的高 4 位用于存储元数据:
64位指针:[4位标记信息][42位对象地址][未使用]
Marked0/1, Remapped, Finalizable
读屏障:
- 访问对象时,先检查指针的标记位
- 如果标记位不为期望值,先执行自愈(self-healing) 操作
- 自愈:将指针的标记位修正为当前正确的值
并发转移的原理:
Q84: 什么是 GC 的并发标记与用户线程的竞争?
答案: 并发标记阶段,GC 线程和用户线程同时执行,可能导致:
解决方案:
- 增量更新(Incremental Update):记录新增的引用(CMS 使用)
- 原始快照(SATB):记录删除的引用(G1 使用)
Q85: 如何选择垃圾收集器?
答案:
堆 < 100MB → Serial
堆 < 4GB,吞吐量优先 → Parallel
堆 < 8GB,低延迟 → CMS(JDK 8)/ G1(JDK 9+)
堆 > 4GB,可控停顿 → G1
堆 > 8GB,超低延迟 → ZGC(JDK 11+)
JDK 版本考虑:
- JDK 8:Parallel Scavenge + Parallel Old(默认)或 G1
- JDK 9~16:G1(默认)
- JDK 17+:G1(默认),可选 ZGC
六、JIT 编译与执行引擎
Q86: Java 是解释执行还是编译执行?
答案: Java 是解释执行 + 编译执行的混合模式。
执行流程:
Java 源码 → javac 编译 → 字节码(.class)
→ JVM 启动时:解释器逐条解释执行
→ 热点代码:JIT 编译器编译为本地机器码
→ 后续执行:直接运行机器码
为什么需要混合模式:
- 解释器:启动快,但执行慢
- JIT 编译器:启动慢(需要编译),但执行快
- 混合模式:启动时用解释器,热点代码用 JIT
Q87: 什么是 JIT 编译器?
答案: JIT(Just-In-Time)编译器在运行时将热点代码编译为本地机器码。
HotSpot 中有两个 JIT 编译器:
C1(Client Compiler):
- 编译速度快,优化少
- 适合客户端应用
- 参数:-client
C2(Server Compiler):
- 编译速度慢,优化多
- 适合服务器应用
- 参数:-server
分层编译(Tiered Compilation):
- JDK 8 默认开启(-XX:+TieredCompilation)
- Level 0:解释执行
- Level 1:C1 编译,无性能分析
- Level 2:C1 编译,有限性能分析
- Level 3:C1 编译,完整性能分析
- Level 4:C2 编译,完全优化
Q88: 什么是热点代码?如何检测?
答案: 热点代码是被频繁执行的代码段。
检测方法:
HotSpot 的计数器:
- 方法调用计数器:方法被调用的次数
- 阈值:-XX:CompileThreshold(Client 1500,Server 10000)
- 回边计数器:循环体执行的次数
- 阈值:-XX:OnStackReplacePercentage
热度衰减:
- 方法调用计数器在半衰期内未达到阈值,计数减半
- 由 -XX:-UseCounterDecay 控制是否开启
Q89: JIT 编译器有哪些优化技术?
答案:
| 方法内联 | 将被调用方法的代码直接嵌入调用处 |
| 逃逸分析 | 分析对象是否逃逸出方法/线程 |
| 标量替换 | 将对象拆分为基本类型变量 |
| 栈上分配 | 不逃逸的对象在栈上分配 |
| 同步消除 | 不逃逸线程的对象去除同步锁 |
| 循环展开 | 减少循环跳转次数 |
| 空值检查消除 | 去除不必要的 null 检查 |
| 公共子表达式消除 | 复用已计算的表达式 |
| 内联缓存 | 缓存虚方法的调用目标 |
方法内联的条件:
- 方法体 ≤ 35 字节(C2 默认)自动内联
- 方法体 ≤ 325 字节(C2)且调用频繁时内联
- -XX:MaxInlineSize 控制内联阈值
Q90: 什么是方法内联(Method Inlining)?
答案: 方法内联是 JIT 最重要的优化,将被调用方法的代码直接嵌入调用处,消除方法调用开销。
// 原始代码
public int add(int a, int b) { return a + b; }
public int compute() { return add(1, 2) * 3; }
// 内联后
public int compute() { return (1 + 2) * 3; }
为什么内联重要:
- 消除方法调用的开销(栈帧创建/销毁、参数传递)
- 为后续优化创造条件(如常量折叠、逃逸分析)
内联的限制:
- 虚方法需要类型推断确认具体调用目标
- 如果无法确定目标,使用内联缓存(Inline Cache)
Q91: 什么是 AOT 编译?
答案: AOT(Ahead-Of-Time)编译是在程序运行前将字节码编译为本地机器码。
GraalVM Native Image:
- 使用 Graal 编译器在构建时编译
- 生成独立的可执行文件,不需要 JVM
- 启动速度极快,内存占用小
AOT vs JIT:
| 编译时机 | 运行前 | 运行时 |
| 启动速度 | 极快 | 较慢 |
| 峰值性能 | 较低(无法做运行时优化) | 更高(可做运行时优化) |
| 编译时间 | 极长(分钟级) | 短(毫秒级) |
Q92: 什么是逃逸分析的标量替换?
答案: 标量替换是逃逸分析的优化之一。如果 JIT 发现对象不逃逸,将对象拆解为基本类型变量。
// 原始代码
public int method() {
Point p = new Point(1, 2); // p 不逃逸
return p.x + p.y;
}
// JIT 标量替换后
public int method() {
int x = 1;
int y = 2;
return x + y;
}
💡 要点:标量替换消除了堆分配和 GC 开销。-XX:+EliminateAllocations(默认开启)。
Q93: 什么是即时编译的逆优化(Deoptimization)?
答案: 逆优化是 JIT 编译代码被废弃,回退到解释执行的过程。
触发条件:
Q94: 什么是 CodeCache?
答案: CodeCache 是 JVM 存储JIT 编译后的本地机器码的区域。
底层实现:
- 使用本地内存(Native Memory)
- 默认大小:240MB(JDK 8)或 240MB(JDK 11+)
- 由 -XX:ReservedCodeCacheSize 控制
CodeCache 满了会怎样:
- JIT 编译停止,所有代码使用解释执行
- 性能显著下降
- JVM 日志中会出现 CodeCache is full 警告
# 监控 CodeCache
jcmd <pid> Compiler.codecache
Q95: 什么是 GraalVM?
答案: GraalVM 是 Oracle 开发的高性能多语言虚拟机。
核心特性:
Graal 编译器的优势:
- 更激进的内联优化
- 更好的逃逸分析
- 支持部分求值(Partial Evaluation)
七、JVM 参数与调优
Q96: 常用的 JVM 内存参数有哪些?
答案:
| -Xms | 初始堆大小 | 物理内存的 1/64 | 设为与 -Xmx 相同 |
| -Xmx | 最大堆大小 | 物理内存的 1/4 | 根据应用需要设置 |
| -Xmn | 新生代大小 | 堆的 1/3 | 堆的 1/3~1/2 |
| -Xss | 线程栈大小 | 1MB | 一般不改 |
| -XX:MetaspaceSize | 元空间初始大小 | 21MB | 设为 256MB |
| -XX:MaxMetaspaceSize | 元空间最大大小 | 无限制 | 设为 512MB |
| -XX:MaxDirectMemorySize | 直接内存最大大小 | -Xmx | 根据 NIO 使用量设置 |
Q97: 如何调优新生代?
答案: 原则:
- 新生代太小 → 频繁 Minor GC
- 新生代太大 → Minor GC 耗时长,老年代空间不足
参数:
-Xmn2g # 新生代 2GB
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
-XX:MaxTenuringThreshold=15 # 最大晋升年龄
-XX:TargetSurvivorRatio=90 # Survivor 目标使用率
-XX:+UseAdaptiveSizePolicy # 自适应调节(Parallel Scavenge)
调优步骤:
Q98: 如何调优老年代?
答案: 原则:
- 老年代太小 → 频繁 Full GC
- 老年代太大 → Full GC 耗时长
参数:
-XX:NewRatio=2 # 老年代:新生代 = 2:1
-XX:CMSInitiatingOccupancyFraction=75 # CMS 触发阈值
-XX:G1MixedGCCountTarget=8 # G1 Mixed GC 次数目标
Q99: 如何开启和分析 GC 日志?
答案: JDK 8 GC 日志参数:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/to/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
JDK 9+ 统一日志格式:
-Xlog:gc*:file=/path/to/gc.log:time,tags,level:filecount=5,filesize=20M
GC 日志示例解读:
[GC (Allocation Failure)
[PSYoungGen: 204800K->25600K(230400K)]
204800K->128000K(757760K),
0.0123456 secs]
- Allocation Failure:触发原因
- PSYoungGen:Parallel Scavenge 新生代
- 204800K->25600K:GC 前后新生代占用
- 230400K:新生代总大小
- 204800K->128000K:GC 前后堆占用
- 757760K:堆总大小
- 0.0123456 secs:GC 耗时
Q100: 什么是 GC 停顿时间?如何优化?
答案: GC 停顿时间(STW)是 GC 暂停所有用户线程的时间。
优化方法:
Q101: 如何设置 JVM 参数?
答案:
# 基本参数
java -Xms4g -Xmx4g -Xmn2g -Xss512k -jar app.jar
# GC 参数
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jar
# GC 日志
java -XX:+PrintGCDetails -Xloggc:gc.log -jar app.jar
# OOM 自动 Dump
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./dump.hprof -jar app.jar
# 查看当前 JVM 参数
jinfo -flags <pid>
Q102: 什么是内存溢出自动 Dump?
答案:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump.hprof
-XX:OnOutOfMemoryError="kill -9 %p" # OOM 后执行的命令
JVM 在抛出 OOM 时自动生成堆转储文件,可以用 MAT 或 JVisualVM 分析。
Q103: 什么是字符串去重(String Deduplication)?
答案: G1 收集器的特性(JDK 8u20+),自动对堆中内容相同的 String 对象进行去重。
原理:
- G1 在并发标记阶段识别重复的 String
- 将重复 String 的 char[] 数组指向同一个
- 节省内存
参数:
-XX:+UseStringDeduplication # 开启字符串去重
-XX:StringDeduplicationAgeThreshold=3 # 只去重存活 3 次 GC 的 String
Q104: 什么是大页(Huge Pages)?
答案: 操作系统默认内存页大小 4KB,大页使用 2MB 或 1GB 的页。
优势:
- 减少 TLB(Translation Lookaside Buffer)缺失
- 减少页表项数量
- 提高内存访问性能
JVM 参数:
-XX:+UseLargePages # 启用大页
-XX:LargePageSizeInBytes=2m # 大页大小
前提:操作系统需要配置大页支持(Linux: vm.nr_hugepages)。
Q105: 如何判断堆内存是否设置合理?
答案:
GC 日志分析:
- Full GC 频率 < 1 天一次 → 合理
- Full GC 后老年代使用率 < 70% → 合理
- Minor GC 频率合理(几秒到几十秒一次)
指标:
- GC 时间占比 < 5% → 合理
- Full GC 停顿时间可接受
- 堆使用率峰值 < 80%
不合理的信号:
- Full GC 频繁(每分钟多次)
- Full GC 后堆使用率仍然很高
- GC 停顿时间过长(> 1 秒)
Q106: 什么是自适应调节策略(AdaptiveSizePolicy)?
答案: Parallel Scavenge 收集器的特性,JVM 自动调整堆大小、新生代比例、晋升年龄等参数。
原理:
- 根据 GC 的实际表现(停顿时间、吞吐量)
- 自动调整 -XX:SurvivorRatio、-XX:MaxTenuringThreshold 等
- 以达到 -XX:MaxGCPauseMillis 和 -XX:GCTimeRatio 的目标
参数:-XX:+UseAdaptiveSizePolicy(Parallel Scavenge 默认开启)
Q107: 如何排查 CPU 飙高问题?
答案:
找到 CPU 高的线程:
top -Hp <pid> # 找到 CPU 高的线程 ID
printf '%x' <tid> # 转换为 16 进制
jstack <pid> | grep <tid_hex> # 查看线程栈
常见原因:
- Full GC 频繁(GC 线程占 CPU)
- 死循环
- 死锁
- 正则表达式回溯
Q108: 如何排查内存飙高问题?
答案:
查看堆内存使用:
jmap -heap <pid>
jstat -gcutil <pid> 1000
生成堆转储:
jmap -dump:format=b,file=dump.hprof <pid>
分析堆转储:
- MAT:Dominator Tree、Leak Suspects
- JVisualVM:类实例数、内存占用
Q109: 如何设置一个合理的 JVM 参数模板?
答案:
# 4GB 堆,G1 收集器
java \\
-Xms4g \\
-Xmx4g \\
-XX:+UseG1GC \\
-XX:MaxGCPauseMillis=200 \\
-XX:MetaspaceSize=256m \\
-XX:MaxMetaspaceSize=512m \\
-XX:MaxDirectMemorySize=1g \\
-XX:+HeapDumpOnOutOfMemoryError \\
-XX:HeapDumpPath=/opt/logs/heapdump.hprof \\
-XX:+PrintGCDetails \\
-XX:+PrintGCDateStamps \\
-Xloggc:/opt/logs/gc.log \\
-XX:+UseGCLogFileRotation \\
-XX:NumberOfGCLogFiles=10 \\
-XX:GCLogFileSize=50M \\
-jar app.jar
Q110: 什么是堆外内存?如何监控?
答案: 堆外内存(Off-Heap Memory)包括:
- 直接内存:NIO ByteBuffer
- JNI 代码:Native 代码分配的内存
- JVM 内部:GC 数据结构、JIT 代码缓存
监控方法:
# 查看 NIO 直接内存
jcmd <pid> VM.native_memory summary
# 开启 NMT(Native Memory Tracking)
-XX:NativeMemoryTracking=summary
八、内存泄漏与溢出排查
Q111: 常见的内存泄漏场景有哪些?
答案:
Q112: ThreadLocal 的内存泄漏问题?
答案: ThreadLocal 使用 ThreadLocalMap 存储数据,key 是弱引用,value 是强引用。
泄漏原因:
解决方案:
try {
threadLocal.set(value);
// 使用 value
} finally {
threadLocal.remove(); // 必须清理!
}
Q113: 如何使用 MAT 分析堆转储?
答案:
Q114: 如何使用 jstat 监控 GC?
答案:
# 每 1 秒输出一次 GC 统计
jstat -gcutil <pid> 1000
# 输出示例
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 50.00 30.00 45.00 95.00 92.00 100 0.500 5 0.200 0.700
字段含义:
- S0/S1:Survivor 使用率
- E:Eden 使用率
- O:老年代使用率
- M:元空间使用率
- YGC/YGCT:Young GC 次数/耗时
- FGC/FGCT:Full GC 次数/耗时
- GCT:总 GC 耗时
Q115: 如何使用 jmap 生成堆转储?
答案:
# 生成堆转储
jmap -dump:format=b,file=dump.hprof <pid>
# 只 dump 存活对象(触发 Full GC)
jmap -dump:live,format=b,file=dump.hprof <pid>
# 查看堆使用情况
jmap -heap <pid>
# 查看对象实例统计
jmap -histo <pid> | head -20
Q116: 如何使用 jstack 分析线程状态?
答案:
# 输出线程栈
jstack <pid>
# 检测死锁
jstack -l <pid>
# 输出到文件
jstack <pid> > thread_dump.txt
线程状态:
- RUNNABLE:正在运行或等待 CPU
- BLOCKED:等待获取锁
- WAITING:无限等待(Object.wait())
- TIMED_WAITING:有限等待(Thread.sleep())
- TERMINATED:已结束
Q117: 什么是 Java Flight Recorder(JFR)?
答案: JFR 是 JDK 内置的低开销性能监控工具,记录 JVM 运行时事件。
事件类型:
- GC 事件
- 线程事件
- I/O 事件
- 方法采样
- 内存分配
使用方法:
# 开始录制
jcmd <pid> JFR.start duration=60s filename=recording.jfr
# 或使用参数启动
-XX:StartFlightRecording=duration=60s,filename=recording.jfr
分析工具:JDK Mission Control(JMC)
Q118: 如何排查 Full GC 频繁的问题?
答案:
- 老年代空间不足 → 增大堆或优化代码
- 内存泄漏 → 修复泄漏
- 元空间不足 → 增大 MetaspaceSize
- System.gc() 调用 → 禁用 -XX:+DisableExplicitGC
- 增大堆内存
- 优化代码减少对象分配
- 选择合适的收集器
- 调整新生代/老年代比例
Q119: 如何排查 OOM 问题?
答案: 步骤:
- MAT → Leak Suspects → Dominator Tree
- 找到占用内存最多的对象
- 追踪 GC Roots 引用链
- 内存泄漏:修复引用未释放的代码
- 内存不足:增大堆 / 优化代码 / 分批处理
Q120: 什么是 Metaspace 的类加载器泄漏?
答案: 类加载器泄漏是指类加载器无法被 GC 回收,导致其加载的所有类都无法卸载。
常见场景:
- Web 容器(Tomcat)反复部署/卸载应用
- 每次部署创建新的类加载器,旧的类加载器未被回收
- 某些静态引用持有旧类加载器的引用
排查:
jmap -dump:live,format=b,file=dump.hprof <pid>
# MAT 中查看 ClassLoader 的实例数和 GC Roots
Q121: 什么是直接内存泄漏?
答案: 直接内存(NIO ByteBuffer)不受 GC 直接管理,如果未正确释放会导致泄漏。
泄漏原因:
- ByteBuffer.allocateDirect() 分配的内存未释放
- Cleaner 未被触发(GC 不及时)
- Netty 的 ByteBuf 未 release
排查:
# 监控直接内存
-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary
# 查看 NIO 直接内存使用
jcmd <pid> VM.info
Q122: 什么是堆的碎片化?
答案: 堆碎片化是指堆内存中有大量不连续的小空闲块,无法分配大对象。
产生原因:
- 标记-清除算法不会压缩内存
- CMS 收集器使用标记-清除,容易产生碎片
解决:
- 使用 G1(Region 化,无碎片)
- CMS 的压缩参数:-XX:+UseCMSCompactAtFullCollection
- 升级到 JDK 9+,默认使用 G1
Q123: 如何分析 GC 日志中的 Full GC 原因?
答案: GC 日志中 Full GC 前面的关键词表示触发原因:
| Metadata GC Threshold | 元空间达到阈值 |
| Ergonomics | JVM 自适应调节 |
| Allocation Failure | 分配失败 |
| System.gc() | 代码调用 System.gc() |
| Heap Inspection Initiated GC | jmap -histo 触发 |
| Concurrent Mode Failure | CMS 并发模式失败 |
| Promotion Failed | 晋升失败 |
| G1 Humongous Allocation | G1 大对象分配 |
Q124: 如何监控 JVM 的线程数?
答案:
# 查看线程数
jstack <pid> | grep 'java.lang.Thread.State' | wc -l
# 查看线程状态分布
jstack <pid> | grep 'java.lang.Thread.State' | sort | uniq -c
# JMX 监控
jconsole <pid>
# Thread 标签页查看线程数和状态
Q125: 什么是堆的监控指标?
答案:
| 堆使用率 | 已用/总大小 | jstat, JMX |
| GC 频率 | 每分钟 GC 次数 | GC 日志 |
| GC 停顿时间 | 每次 GC 的 STW 时间 | GC 日志 |
| Full GC 频率 | 每天 Full GC 次数 | GC 日志 |
| 对象分配速率 | 每秒分配的字节数 | JFR |
| 晋升速率 | 每秒晋升到老年代的字节数 | GC 日志 |
| 元空间使用率 | 已用/总大小 | jstat |
九、字节码与执行引擎深入
Q126: 字节码指令有哪些类别?
答案:
| 加载/存储 | iload, istore, aload, astore | 局部变量表 ↔ 操作数栈 |
| 运算 | iadd, isub, imul, idiv | 算术运算 |
| 类型转换 | i2l, i2f, l2d | 基本类型转换 |
| 对象创建 | new, newarray, anewarray | 创建对象/数组 |
| 方法调用 | invokevirtual, invokeinterface, invokespecial, invokestatic, invokedynamic | 调用方法 |
| 控制转移 | ifeq, ifne, goto | 条件/无条件跳转 |
| 栈操作 | pop, dup, swap | 操作数栈操作 |
| 同步 | monitorenter, monitorexit | 获取/释放监视器锁 |
Q127: 方法调用的 5 条字节码指令有什么区别?
答案:
| invokevirtual | 调用虚方法(普通实例方法) | 运行时多态 |
| invokeinterface | 调用接口方法 | 运行时多态 |
| invokespecial | 调用构造方法、私有方法、父类方法 | 编译期确定 |
| invokestatic | 调用静态方法 | 编译期确定 |
| invokedynamic | 动态调用(Lambda、方法句柄) | 运行时动态绑定 |
invokedynamic:
- JDK 7 引入,支持动态语言
- JDK 8 Lambda 表达式使用此指令
- 运行时通过 BootstrapMethod 确定调用目标
Q128: 什么是栈帧中的动态链接?
答案: 动态链接将符号引用转换为直接引用。
过程:
- 编译时:方法调用使用符号引用(如 com/example/MyClass.myMethod:(I)V)
- 运行时:将符号引用解析为方法的直接地址
静态解析 vs 动态解析:
- invokespecial、invokestatic:在类加载的解析阶段静态解析
- invokevirtual、invokeinterface:在运行时动态解析(多态)
Q129: 什么是操作数栈?
答案: 操作数栈是栈帧中的工作区,字节码指令在此进行运算。
// 计算 1 + 2
// 字节码:
iconst_1 // 将 1 压入操作数栈
iconst_2 // 将 2 压入操作数栈
iadd // 弹出两个 int,相加,将结果 3 压回
istore_1 // 弹出结果,存入局部变量表第 1 个 Slot
Q130: 什么是方法的分派(Dispatch)?
答案:
静态分派:
- 编译期确定方法目标
- 对应 invokespecial 和 invokestatic
- 也叫解析(Resolution)
动态分派:
- 运行时确定方法目标
- 对应 invokevirtual 和 invokeinterface
- 实现多态
单分派 vs 多分派:
- Java 是静态多分派 + 动态单分派
- 静态分派考虑接收者类型和参数类型(多分派)
- 动态分派只考虑接收者类型(单分派)
虚方法表(vtable):
- 每个类有一个虚方法表
- 存储虚方法的实际入口地址
- 子类重写的方法替换为子类的入口
- 动态分派通过查 vtable 实现
Q131: 什么是虚方法表(vtable)?
答案: 虚方法表是 HotSpot 实现动态分派的核心数据结构。
结构:
Object 的 vtable:
[0] hashCode() → Object.hashCode
[1] equals() → Object.equals
[2] toString() → Object.toString
…
String 的 vtable:
[0] hashCode() → String.hashCode (重写)
[1] equals() → String.equals (重写)
[2] toString() → String.toString (重写)
…
创建时机:类加载的连接阶段,为类创建 vtable。
Q132: 什么是接口方法表(itable)?
答案: itable 是接口方法的分派表。
与 vtable 的区别:
- vtable:只存储类的方法
- itable:存储接口方法,通过接口索引 + 方法索引定位
- 一个类可以实现多个接口,itable 更复杂
Q133: synchronized 在字节码层面是如何实现的?
答案:
- 同步代码块:使用 monitorenter 和 monitorexit 指令
- 同步方法:方法的 access_flags 中设置 ACC_SYNCHRONIZED 标志
// 同步代码块
synchronized (obj) {
// 代码
}
// 字节码:
monitorenter // 获取 obj 的监视器锁
// 代码
monitorexit // 释放锁
// 异常处理中的 monitorexit(保证异常时也能释放锁)
Q134: 什么是局部变量表的 Slot 复用?
答案: 局部变量表中的 Slot 可以被后续变量复用(超出作用域后)。
public void method() {
{
int a = 1; // 占用 Slot 1
} // a 超出作用域
int b = 2; // 复用 Slot 1
}
影响:
- 节省内存
- 可能影响 GC:Slot 被复用后,原变量的引用被覆盖,对象可被 GC
Q135: 什么是字节码的异常处理表?
答案: 异常处理表记录了代码中哪些范围的代码需要处理什么类型的异常,跳转到哪个处理器。
Exception table:
from to target type
0 4 8 Class java/lang/Exception
含义:0~4 行的字节码如果抛出 Exception,跳转到第 8 行执行。
十、线程模型与并发深入
Q136: JVM 的线程模型是怎样的?
答案: HotSpot 使用内核级线程模型(1:1 模型):
- 每个 Java 线程对应一个操作系统内核线程
- 线程的创建、调度、同步由操作系统内核完成
- 优点:可以利用多核 CPU
- 缺点:线程创建/切换开销大
JVM 线程:
- Java 线程(Thread)→ JVM 内部线程 → 操作系统内核线程
Q137: JVM 有哪些后台线程?
答案:
| VM Thread | 处理 GC、反优化等 VM 操作 |
| GC Threads | 并行 GC 线程 |
| Compiler Threads | JIT 编译线程 |
| Signal Dispatcher | 处理外部信号 |
| Reference Handler | 处理引用队列(软/弱/虚引用) |
| Finalizer | 执行 finalize() 方法 |
| Service Thread | 周期性任务 |
Q138: 什么是线程栈的大小?如何影响线程数?
答案: 每个线程的栈大小由 -Xss 控制(默认 1MB)。
线程数限制:
最大线程数 ≈ (堆内存 – 其他内存) / 线程栈大小
- 堆 4GB,栈 1MB → 约 3000 个线程(需预留其他内存)
- 如果栈设置过大(如 2MB),线程数减半
- 如果线程数过多 → OutOfMemoryError: unable to create new native thread
Q139: 什么是 Java 的内存模型(JMM)?
答案: JMM(Java Memory Model)定义了多线程环境下变量的可见性、有序性和原子性规则。
核心概念:
- 主内存(Main Memory):所有线程共享的内存(对应堆)
- 工作内存(Working Memory):每个线程私有的本地缓存(对应 CPU 缓存/寄存器)
- 8 种原子操作:lock, unlock, read, load, use, assign, store, write
三大特性:
Q140: volatile 的底层实现是什么?
答案: volatile 变量的读写使用内存屏障(Memory Barrier) 保证可见性和有序性。
底层实现:
- 写操作:在写操作后插入 StoreLoad 屏障(写回主内存)
- 读操作:在读操作前插入 LoadLoad 屏障(从主内存读取)
x86 架构:
- volatile 写 → lock addl $0, (%rsp)(lock 前缀指令,刷新写缓冲)
- volatile 写后读 → 插入 mfence 指令
Q141: synchronized 的锁升级过程?
答案:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
| 偏向锁 | Mark Word 存储 threadId | 最高(无 CAS) |
| 轻量级锁 | CAS 将 Mark Word 指向 Lock Record | 高(自旋) |
| 重量级锁 | Monitor 对象(互斥量) | 低(阻塞) |
锁消除:JIT 发现锁对象不逃逸,直接去除同步。 锁粗化:JIT 将多次连续的锁操作合并为一次。
Q142: 什么是 Monitor 对象?
答案: Monitor 是 JVM 实现重量级锁的底层数据结构(C++ 的 ObjectMonitor)。
结构:
ObjectMonitor:
_owner: 持有锁的线程
_EntryList: 等待获取锁的线程队列
_WaitSet: 调用 wait() 的线程队列
_recursions: 重入计数
工作流程:
Q143: 什么是 AQS(AbstractQueuedSynchronizer)?
答案: AQS 是 java.util.concurrent 包中锁和同步器的基础框架。
核心思想:
- 维护一个 volatile int state 表示同步状态
- 使用 CLH 队列管理等待线程
- 子类通过重写 tryAcquire()/tryRelease() 实现不同的同步器
实现:
- ReentrantLock:state = 重入次数
- Semaphore:state = 可用许可数
- CountDownLatch:state = 计数
Q144: 什么是 CAS?底层如何实现?
答案: CAS(Compare And Swap)是一种无锁的原子操作。
原理:
CAS(内存地址, 期望值, 新值)
if (*内存地址 == 期望值) {
*内存地址 = 新值;
return true;
} else {
return false;
}
底层实现:
- 使用 CPU 的原子指令 cmpxchg
- x86:lock cmpxchg 指令
- 保证在多核环境下的原子性
问题:
- ABA 问题:值从 A 变为 B 再变回 A,CAS 误认为没变化(使用版本号解决)
- 自旋开销:CAS 失败后重试,消耗 CPU
- 只能保证一个变量的原子操作
Q145: 什么是伪共享(False Sharing)?
答案: 伪共享是多线程环境下,不同变量存储在同一个缓存行中,导致不必要的缓存失效。
问题:
- CPU 缓存以缓存行(64 字节)为单位
- 如果线程 A 修改变量 X,线程 B 读取变量 Y,X 和 Y 在同一缓存行
- 线程 A 的修改导致整个缓存行失效,线程 B 需要重新读取
解决:
// 使用 @Contended 注解(JDK 8+)
@sun.misc.Contended
private volatile long counter;
-XX:-RestrictContended 允许自定义类使用此注解。
十一、G1/ZGC/Shenandoah 深入
Q146: G1 的 Region 有哪些类型?
答案:
| Eden Region | 新对象分配的区域 |
| Survivor Region | 存活对象的中转区域 |
| Old Region | 长期存活对象的区域 |
| Humongous Region | 大对象(> Region 50%)的区域 |
| Free Region | 空闲区域 |
Q147: G1 的并发标记如何工作?
答案:
1. 初始标记(STW)—— 标记 GC Roots 直接关联的对象(借用 Minor GC 的 STW)
2. Root Region 扫描 —— 扫描 Survivor Region 引用的 Old Region
3. 并发标记 —— 与用户线程并发执行,遍历对象图
4. 最终标记(STW)—— 处理 SATB 缓冲区中的记录
5. 清理(STW)—— 统计每个 Region 的存活对象,选择回收价值高的 Region
Q148: G1 的 Mixed GC 触发条件?
答案:
Mixed GC 的选择:
- 根据 -XX:MaxGCPauseMillis 的目标停顿时间
- 选择回收价值最高的老年代 Region
- 同时回收所有新生代 Region
Q149: ZGC 的染色指针具体如何工作?
答案: ZGC 使用 64 位指针的高 4 位存储元数据:
| 42-43 | Marked0/Marked1 | 标记位(用于并发标记) |
| 44 | Remapped | 重映射位(用于并发转移) |
| 45 | Finalizable | 只能通过 finalize() 访问 |
| 0-41 | Address | 对象地址(支持 4TB 堆) |
读屏障:
// 访问对象时
Object o = ref.obj;
// 读屏障:检查指针标记位
if (指针标记位 != 期望值) {
// 自愈:修正指针
ref.obj = self_heal(ref.obj);
o = ref.obj;
}
Q150: ZGC 的并发转移如何实现?
答案: ZGC 的对象转移(Relocation)几乎完全并发:
关键:
- 使用转发表(Forwarding Table) 记录旧地址到新地址的映射
- 读屏障在访问旧地址时,自动修正为新地址
- 不需要 STW
Q151: Shenandoah 的转发指针如何工作?
答案: Shenandoah 在每个对象头部添加一个转发指针(Brooks Pointer)。
原理:
- 对象移动时,更新转发指针指向新地址
- 后续访问旧地址时,通过转发指针跳转到新地址
- 并发转移时,使用 CAS 更新转发指针
读屏障:
- 每次读取对象时检查转发指针
- 如果转发指针不指向自身,说明对象已移动,自动跳转
Q152: ZGC 和 Shenandoah 的区别?
答案:
| 核心技术 | 染色指针 + 读屏障 | 转发指针 + 读屏障 |
| STW 时间 | < 1ms | < 10ms |
| 堆大小限制 | 支持 4TB+ | 支持 TB 级 |
| 指针大小 | 64 位(压缩后 32 位) | 64 位 |
| 维护者 | Oracle | Red Hat |
| JDK 版本 | JDK 11+ | JDK 12+(实验性) |
Q153: 什么是 ZGC 的多重映射(Multi-Mapping)?
答案: ZGC 使用多重映射实现染色指针:
- 同一个物理内存被映射到多个虚拟地址
- 不同的标记位对应不同的虚拟地址
- 但指向同一个物理内存
虚拟地址 A (Marked0=0, Remapped=0) ──→ 物理地址 X
虚拟地址 B (Marked0=1, Remapped=0) ──→ 物理地址 X
虚拟地址 C (Marked0=0, Remapped=1) ──→ 物理地址 X
Q154: G1 的 RSet 维护开销有多大?
答案: RSet 的维护开销:
- 写屏障:每次引用赋值都需要更新 RSet
- 内存开销:RSet 占用约 5%~10% 的堆内存
- CPU 开销:写屏障和并发细化线程消耗 CPU
优化:
- -XX:G1RSetUpdatingPauseTimePercent:控制 RSet 更新的 STW 时间占比
- -XX:G1ConcRefinementThreads:并发细化线程数
Q155: 什么是 G1 的 Humongous 对象分配?
答案: 超过 Region 50% 大小的对象被认定为 Humongous 对象。
特点:
- 直接分配在连续的 Humongous Region 中
- 逻辑上属于老年代
- 不参与 Young GC 的复制
- Mixed GC 时回收
- 可能导致提前触发并发标记
问题:
- Humongous 对象分配可能引起连续的 Region 分配,导致碎片
- 频繁创建/销毁 Humongous 对象会影响 G1 性能
Q156: 如何选择 G1、ZGC、Shenandoah?
答案:
| 堆 4GB~64GB,通用场景 | G1 |
| 堆 > 64GB,低延迟 | ZGC |
| 需要极致低延迟(< 1ms STW) | ZGC |
| Red Hat 技术栈 | Shenandoah |
| JDK 8 | G1 |
| JDK 11+ | G1 或 ZGC |
Q157: ZGC 的触发条件是什么?
答案: ZGC 的触发条件相对简单:
- 堆使用率达到某个阈值时触发
- 由 -XX:ZAllocationSpikeTolerance 控制(默认 2.0)
- ZGC 会根据分配速率自适应调整触发时机
Q158: G1 的并发标记失败会怎样?
答案: 并发标记失败会导致:
避免:
- 不要让堆使用率过高
- 增大堆或调整 -XX:InitiatingHeapOccupancyPercent
Q159: ZGC 的指针压缩如何工作?
答案: ZGC 在 JDK 16+ 支持指针压缩(-XX:+UseCompressedOops):
- 堆 < 4GB 时,指针压缩为 32 位
- 堆 4GB~4TB 时,使用 64 位指针(带染色位)
- 染色位存储在指针的高 4 位
Q160: 什么是 G1 的疏散(Evacuation)?
答案: 疏散是 G1 将存活对象从一个 Region 复制到另一个 Region 的过程。
过程:
疏散失败:
- 目标 Region 没有足够空间
- 退化为 Full GC
十二、JVM 工具与监控
Q161: 常用的 JVM 监控工具有哪些?
答案:
| jps | 列出 Java 进程 | jps -l |
| jstat | 监控 GC 统计 | jstat -gcutil <pid> 1000 |
| jmap | 生成堆转储 | jmap -dump:format=b,file=dump.hprof <pid> |
| jstack | 输出线程栈 | jstack <pid> |
| jinfo | 查看/修改 JVM 参数 | jinfo -flags <pid> |
| jcmd | 综合命令 | jcmd <pid> help |
| JConsole | 图形化监控 | jconsole <pid> |
| JVisualVM | 图形化分析 | jvisualvm |
| MAT | 堆转储分析 | Eclipse 插件 |
| JFR | 低开销记录 | jcmd <pid> JFR.start |
| Arthas | 在线诊断 | java -jar arthas-boot.jar |
Q162: 如何使用 jcmd?
答案:
# 列出 Java 进程
jcmd -l
# 查看 JVM 参数
jcmd <pid> VM.flags
# 查看系统属性
jcmd <pid> VM.system_properties
# 生成堆转储
jcmd <pid> GC.heap_dump dump.hprof
# 查看线程栈
jcmd <pid> Thread.print
# 查看 GC 信息
jcmd <pid> GC.heap_info
# 查看类加载统计
jcmd <pid> VM.classloader_stats
# 查看 CodeCache
jcmd <pid> Compiler.codecache
# 触发 GC
jcmd <pid> GC.run
Q163: 如何使用 Arthas 进行在线诊断?
答案: Arthas 是阿里巴巴开源的 Java 诊断工具。
# 启动
java -jar arthas-boot.jar
# 查看 Dashboard
dashboard
# 查看线程
thread
# 查看方法调用耗时
trace com.example.MyClass myMethod
# 反编译类
jad com.example.MyClass
# 监控方法返回值
watch com.example.MyClass myMethod "{returnObj}"
# 查看方法调用栈
stack com.example.MyClass myMethod
# 热替换代码
mc /tmp/MyClass.java
redefine /tmp/MyClass.class
Q164: 如何使用 JFR(Java Flight Recorder)?
答案:
# 启动录制(60 秒)
jcmd <pid> JFR.start duration=60s filename=recording.jfr
# 持续录制
jcmd <pid> JFR.start settings=profile filename=continuous.jfr
jcmd <pid> JFR.dump filename=dump.jfr
# 停止录制
jcmd <pid> JFR.stop
# JVM 启动参数
-XX:StartFlightRecording=duration=60s,filename=recording.jfr
-XX:FlightRecorder=sampledThreads
分析:使用 JDK Mission Control(JMC)打开 .jfr 文件。
Q165: 如何使用 MAT 分析内存泄漏?
答案:
打开 .hprof 文件
Leak Suspects Report:自动分析泄漏可疑点
Dominator Tree:查看对象的内存占用排名
Path to GC Roots(排除弱引用/软引用):找到引用链
OQL 查询:
SELECT * FROM java.util.HashMap WHERE size > 10000
SELECT * FROM java.lang.String WHERE value.length > 1000
Q166: 如何监控元空间?
答案:
# jstat 查看元空间
jstat -gcmetacapacity <pid>
# JMX 监控
# java.lang:type=MemoryPool,name=Metaspace
# 参数
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
Q167: 如何监控直接内存?
答案:
# 开启 NMT
-XX:NativeMemoryTracking=summary
# 查看内存分布
jcmd <pid> VM.native_memory summary
# 查看内存基线
jcmd <pid> VM.native_memory baseline
# 对比变化
jcmd <pid> VM.native_memory summary.diff
Q168: 如何监控 JIT 编译?
答案:
# 查看 JIT 编译日志
-XX:+PrintCompilation
# 输出示例
# 68 1 3 java.lang.String::hashCode (55 bytes)
# 表示第 68 个编译任务,编译 ID=1,编译级别 3(C1),类名和方法名
# 查看方法内联
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
# 查看 CodeCache
jcmd <pid> Compiler.codecache
Q169: 如何查看 JVM 的所有参数?
答案:
# 查看所有参数
jcmd <pid> VM.flags
# 查看特定参数
java -XX:+PrintFlagsFinal -version | grep UseG1GC
# 查看参数默认值
java -XX:+PrintFlagsInitial
# 查看参数最终值
java -XX:+PrintFlagsFinal
Q170: 如何生成和分析 GC 日志?
答案: JDK 8:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-XX:+PrintHeapAtGC
-Xloggc:/path/to/gc.log
JDK 9+:
-Xlog:gc*:file=/path/to/gc.log:time,tags,level:filecount=10,filesize=50M
分析工具:
- GCViewer:图形化分析 GC 日志
- GCEasy:在线 GC 日志分析(gceasy.io)
- GCPlot:GC 日志可视化
十三、高级主题
Q171: 什么是 Java 的 Platform Module System(JPMS)?
答案: JPMS 是 JDK 9 引入的模块化系统,将 JDK 拆分为多个模块。
核心概念:
- module-info.java:定义模块的导出和依赖
- 强封装:模块内部的包默认不对外可见
- 减少了 JDK 的启动时间和内存占用
与类加载的关系:
- 每个模块有独立的类加载器命名空间
- 模块之间的访问受 exports 和 requires 约束
Q172: 什么是 Value Types(Valhalla 项目)?
答案: Valhalla 是 OpenJDK 的项目,目标是引入值类型(Value Types)。
核心思想:
- 值类型是不可变的、无引用的数据类型
- 直接在栈上或数组中内联存储,不需要堆分配和 GC
- 类似于 C 语言的 struct
优势:
- 消除对象头开销
- 提高缓存命中率
- 减少 GC 压力
Q173: 什么是 Project Loom(虚拟线程)?
答案: Project Loom 是 JDK 21 正式引入的虚拟线程(Virtual Threads)。
核心思想:
- 虚拟线程是 JVM 管理的轻量级线程
- 不直接映射到操作系统内核线程
- 数量可以达到百万级
- 由 JVM 自动调度到少量载体线程(Carrier Thread)上
与传统线程的对比:
| 创建开销 | 大(~1MB 栈) | 极小(~几 KB) |
| 数量限制 | ~几千 | ~百万 |
| 调度 | 操作系统 | JVM(ForkJoinPool) |
| 阻塞 | 阻塞内核线程 | 自动卸载(unmount) |
// 创建虚拟线程
Thread.startVirtualThread(() -> {
// 任务代码
});
// 虚拟线程池
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> task());
}
Q174: 什么是协程?Java 的虚拟线程是协程吗?
答案: 协程是用户态的轻量级线程,可以在执行过程中挂起和恢复。
Java 的虚拟线程:
- 虚拟线程在概念上类似于协程
- 但实现方式不同:虚拟线程通过 Continuation 实现挂起/恢复
- 虚拟线程的 API 与普通线程完全兼容
与其他语言的对比:
- Go 的 Goroutine:M:N 调度
- Kotlin 的协程:编译器 CPS 变换
- Java 的虚拟线程:Continuation + ForkJoinPool
Q175: 什么是 ZGC 的分代模式(Generational ZGC)?
答案: JDK 21 引入了分代 ZGC(-XX:+UseZGC -XX:+ZGenerational)。
核心思想:
- 将堆分为年轻代和老年代
- 优先回收年轻代(短命对象多)
- 提高回收效率,降低内存占用
与传统 ZGC 的区别:
- 传统 ZGC:非分代,每次回收整个堆
- 分代 ZGC:类似 G1 的分代回收,效率更高
Q176: 什么是 Shenandoah 的并发整理?
答案: Shenandoah 的并发整理通过转发指针实现:
关键:读屏障在访问对象时检查转发指针,自动跳转到新地址。
Q177: 什么是 GC 的安全点(Safepoint)机制的优化?
答案: JVM 不断优化安全点机制,减少 STW 时间:
JEP 312(JDK 10):Thread-Local Handshakes
- 不需要所有线程同时到达安全点
- 可以逐个线程执行握手操作
- 减少了安全点的全局暂停时间
JEP 358(JDK 12):改进安全点统计
- 更精确地测量安全点停顿时间
Q178: 什么是 JFR 的事件系统?
答案: JFR 记录 JVM 运行时的事件,每个事件包含时间戳、持续时间、相关数据。
事件类型:
- 瞬时事件:发生即记录(如对象分配)
- 持续事件:有开始和结束(如 GC 事件)
- 采样事件:定期采样(如方法采样)
关键事件:
- jdk.GCHeapSummary:堆使用摘要
- jdk.GCCause:GC 触发原因
- jdk.ThreadStart / jdk.ThreadEnd:线程生命周期
- jdk.ObjectAllocationInNewTLAB:TLAB 分配
- jdk.ExecuteVMOperation:VM 操作(如 GC)
Q179: 什么是 JVM 的 CDS(Class Data Sharing)?
答案: CDS 是 JVM 的类数据共享技术,加速应用启动。
原理:
JDK 版本演进:
- JDK 8:基础 CDS(只支持 Bootstrap ClassLoader 加载的类)
- JDK 10:AppCDS(支持应用类的共享)
- JDK 13:Dynamic CDS(动态归档)
# 生成共享归档
java -Xshare:dump -XX:SharedArchiveFile=app.jsa -cp app.jar
# 使用共享归档启动
java -Xshare:on -XX:SharedArchiveFile=app.jsa -cp app.jar
Q180: 什么是 JVM 的分层编译(Tiered Compilation)?
答案: 分层编译结合了解释器、C1 编译器和 C2 编译器的优势。
编译层级:
| 0 | 解释执行 |
| 1 | C1 编译,无性能分析 |
| 2 | C1 编译,有限性能分析 |
| 3 | C1 编译,完整性能分析 |
| 4 | C2 编译,完全优化 |
流程:
方法首次调用 → Level 0(解释执行)
→ 调用次数达到阈值 → Level 3(C1 编译 + 性能分析)
→ 热点数据足够 → Level 4(C2 编译,完全优化)
Q181: 什么是 JVM 的类数据压缩?
答案: JVM 使用多种压缩技术减少内存占用:
Q182: 什么是 JVM 的字节码验证?
答案: 字节码验证在类加载的连接阶段进行,确保字节码的安全性。
验证内容:
JDK 7+ 的优化:
- StackMapTable 属性:编译期生成栈映射表,减少运行时验证
- 类型检查验证(Type Checking Verification)替代类型推断验证
Q183: 什么是 JVM 的安全点轮询?
答案: 安全点轮询是线程检查是否需要暂停的机制。
实现:
- JVM 在安全点位置插入轮询代码
- 轮询代码检查一个全局标志(安全点标志)
- 如果标志被设置,线程暂停
HotSpot 的优化:
- 使用内存保护页实现轮询
- 安全点标志是一个内存页
- 正常情况:线程读取该页面(已映射)
- GC 时:取消该页面的映射 → 线程访问时触发页错误 → 暂停
Q184: 什么是 JVM 的去优化(Deoptimization)?
答案: 去优化是将 JIT 编译的代码废弃,回退到解释执行。
触发条件:
去优化的类型:
- 部分去优化:只废弃部分编译代码
- 完全去优化:废弃整个方法的编译代码
Q185: 什么是 JVM 的逃逸分析的局限性?
答案: 逃逸分析虽然强大,但有局限性:
十四、场景实战题
Q186: 线上出现 Full GC 频繁,如何排查?
答案:
- 内存泄漏 → 修复代码
- 堆太小 → 增大 -Xmx
- 新生代太小 → 增大 -Xmn
- 元空间不足 → 增大 MetaspaceSize
- System.gc() 调用 → -XX:+DisableExplicitGC
Q187: 线上出现 OOM,如何快速定位?
答案:
- Leak Suspects Report → 自动分析泄漏点
- Dominator Tree → 找到占用内存最大的对象
- Path to GC Roots → 追踪引用链
Q188: 线上出现 CPU 飙高,如何排查?
答案:
找到 CPU 高的线程:
top -Hp <pid>
printf '%x' <tid>
jstack <pid> | grep -A 20 <tid_hex>
常见原因:
- Full GC 频繁 → GC 线程占 CPU
- 死循环 → 线程栈显示在循环代码处
- 正则表达式回溯 → 正则匹配卡住
GC 导致的 CPU 飙高:查看 GC 日志,分析 Full GC 原因
Q189: 线上出现死锁,如何排查?
答案:
# 检测死锁
jstack -l <pid>
# 输出中会显示:
# Found one Java-level deadlock:
# "Thread-1":
# waiting to lock monitor 0x00007f…
# locked <0x000000076ab8b9d0> (a java.lang.Object)
# "Thread-2":
# waiting to lock monitor 0x00007f…
# locked <0x000000076ab8b9e0> (a java.lang.Object)
解决方案:
Q190: 如何优化一个频繁 GC 的服务?
答案:
- -Xms = -Xmx(避免动态扩展)
- 新生代占堆的 1/3~1/2
- 低延迟:G1(-XX:MaxGCPauseMillis=200)
- 吞吐量:Parallel
- 减少临时对象创建
- 使用对象池
- 避免大对象直接进入老年代
Q191: 如何排查 Metaspace OOM?
答案:
# 查看元空间使用
jstat -gcmetacapacity <pid>
# 生成堆转储
jmap -dump:format=b,file=dump.hprof <pid>
# MAT 中查看 ClassLoader 和 Class 的实例数
常见原因:
解决:
- 增大 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize
- 排查类加载器泄漏
- 限制动态生成类的数量
Q192: 如何优化大对象的内存使用?
答案:
Q193: 如何设计一个低延迟的 JVM 参数?
答案:
# 低延迟参数模板
-Xms8g -Xmx8g # 固定堆大小
-XX:+UseZGC # 使用 ZGC
-XX:+ZGenerational # 分代 ZGC(JDK 21+)
-XX:MaxGCPauseMillis=5 # 目标停顿 5ms
-XX:+AlwaysPreTouch # 启动时预分配内存
-XX:+UseNUMA # NUMA 感知
-XX:-UseBiasedLocking # 禁用偏向锁(JDK 15+已移除)
-XX:+UseTransparentHugePages # 大页
-XX:NativeMemoryTracking=summary # NMT
Q194: 如何排查内存泄漏中的 ThreadLocal 问题?
答案:
- OQL: SELECT * FROM java.lang.ThreadLocal$ThreadLocalMap
- 找到没有被 remove 的 ThreadLocal
Q195: 如何排查 NIO 的直接内存泄漏?
答案:
# 开启 NMT
-XX:NativeMemoryTracking=detail
# 查看直接内存
jcmd <pid> VM.native_memory detail
# 对比两次快照
jcmd <pid> VM.native_memory baseline
# … 运行一段时间 …
jcmd <pid> VM.native_memory summary.diff
排查:
- 检查 ByteBuffer.allocateDirect() 是否正确释放
- 检查 Netty 的 ByteBuf 是否 release()
- 使用 Unsafe.allocateMemory() 的代码是否有 freeMemory()
Q196: 如何监控 GC 对服务延迟的影响?
答案:
GC 日志分析:
- 记录每次 GC 的 STW 时间
- 统计 P99/P999 GC 停顿时间
JFR 分析:
- 记录 jdk.GCHeapSummary 事件
- 分析 GC 停顿时间分布
应用层监控:
- 记录请求的 P99 延迟
- 与 GC 时间关联分析
Q197: 如何优化 G1 的停顿时间?
答案:
# 设置目标停顿时间
-XX:MaxGCPauseMillis=200
# 调整触发并发标记的阈值
-XX:InitiatingHeapOccupancyPercent=45
# 控制 Mixed GC 的老年代 Region 数
-XX:G1MixedGCCountTarget=8
# 控制 RSet 更新时间
-XX:G1RSetUpdatingPauseTimePercent=5
# 调整 Region 大小
-XX:G1HeapRegionSize=16m
Q198: 如何排查类加载冲突?
答案: 类加载冲突是指同一个类被多个类加载器加载,导致 ClassCastException 或行为不一致。
排查:
# 查看类的加载器
jcmd <pid> VM.classloader_stats
# Arthas 查看类的加载信息
sc -d com.example.MyClass
# 查看类的来源
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
解决:
- 排除重复的 jar 包
- 使用 mvn dependency:tree 分析依赖冲突
- 统一类加载器
Q199: 如何设计一个高并发服务的 JVM 参数?
答案:
# 16 核 32GB 机器
-Xms16g -Xmx16g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=35
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:MaxDirectMemorySize=2g
-Xss512k
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/logs/dump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/opt/logs/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=50M
-XX:NativeMemoryTracking=summary
Q200: 如何进行 JVM 的性能基准测试?
答案:
JMH(Java Microbenchmark Harness):
@Benchmark
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
public void testMethod() {
// 被测代码
}
测试指标:
- 吞吐量(ops/sec)
- 延迟(P50/P95/P99)
- GC 停顿时间
- 内存使用
测试方法:
- 预热:先运行 5~10 轮
- 测试:运行 10~20 轮,取平均
- 对比:不同 JVM 参数/不同代码版本
工具:
- JMH:微基准测试
- wrk/ab:HTTP 接口压测
- JMeter:复杂场景压测
附录:速查表
JVM 内存区域速查
| 程序计数器 | 私有 | 字节码指令地址 | 无 |
| 虚拟机栈 | 私有 | 栈帧(局部变量、操作数栈) | StackOverflowError / OOM |
| 本地方法栈 | 私有 | Native 方法栈帧 | StackOverflowError / OOM |
| 堆 | 共享 | 对象实例、数组 | OOM |
| 方法区 | 共享 | 类信息、常量、静态变量 | OOM |
| 直接内存 | 共享 | NIO ByteBuffer | OOM |
GC 算法速查
| 标记-清除 | 简单 | 碎片 | CMS |
| 标记-复制 | 无碎片、高效 | 空间浪费 | 新生代 |
| 标记-整理 | 无碎片 | 移动成本 | 老年代 |
垃圾回收器速查
| Serial | 新生代 | 单线程 STW | -XX:+UseSerialGC |
| ParNew | 新生代 | 多线程 STW | -XX:+UseParNewGC |
| Parallel Scavenge | 新生代 | 吞吐量优先 | -XX:+UseParallelGC |
| CMS | 老年代 | 低延迟 | -XX:+UseConcMarkSweepGC |
| G1 | 整堆 | 可控停顿 | -XX:+UseG1GC |
| ZGC | 整堆 | 超低延迟 | -XX:+UseZGC |
| Shenandoah | 整堆 | 超低延迟 | -XX:+UseShenandoahGC |
常用 JVM 参数速查
| -Xms / -Xmx | 初始/最大堆 | 设为相同 |
| -Xmn | 新生代 | 堆的 1/3~1/2 |
| -Xss | 线程栈 | 512k~1m |
| -XX:MetaspaceSize | 元空间初始 | 256m |
| -XX:MaxMetaspaceSize | 元空间最大 | 512m |
| -XX:MaxGCPauseMillis | G1 停顿目标 | 200ms |
| -XX:+HeapDumpOnOutOfMemoryError | OOM 时 dump | 开启 |
常用诊断命令速查
# 进程
jps -l
# GC 监控
jstat -gcutil <pid> 1000
# 堆转储
jmap -dump:format=b,file=dump.hprof <pid>
# 线程栈
jstack <pid>
# 综合
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
jcmd <pid> Thread.print
# 在线诊断
java -jar arthas-boot.jar
📝 总结:本文共 200 道 JVM 面试题,覆盖从内存模型到 GC 调优的全部核心知识。重点掌握:JVM 内存区域(堆/栈/方法区)、对象创建与内存分配、类加载与双亲委派、GC 算法与收集器(CMS/G1/ZGC)、JIT 编译与逃逸分析、JVM 调优参数、内存泄漏排查工具(MAT/jstack/jmap)。面试时结合线上排查经验,展示从原理到实践的完整能力。



