JVM 常见八股面试问答:深入底层原理

🎯 适用:Java 后端开发、架构师、中间件开发岗位面试

📖 内容覆盖:内存模型 · 对象创建 · 类加载 · 垃圾回收算法 · 垃圾回收器 · JIT编译 · JVM调优 · 内存排查 · 字节码 · 线程模型 · G1/ZGC/Shenandoah · 工具监控 · 场景实战


目录


一、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 类管理,底层通过 ChunkManagerSpaceManager 分配内存。


Q2: 堆和栈的区别是什么?

答案

对比项 栈(Stack) 堆(Heap)
用途 存储局部变量、方法调用上下文 存储对象实例、数组
线程 线程私有 线程共享
生命周期 方法结束即销毁 GC 回收时销毁
存取速度 快(LIFO,指针移动) 慢(需要 GC 管理)
空间大小 小(-Xss,默认 1MB) 大(-Xms/-Xmx)
异常 StackOverflowError OutOfMemoryError
内存分配 编译期确定大小(连续分配) 运行时动态分配(可能不连续)

底层原理:栈的分配/释放只需要移动栈指针(SP 寄存器),是一个 O(1) 的操作。堆的分配需要查找空闲内存块(通过空闲链表或 TLAB),释放需要 GC 标记-清除/复制,复杂度更高。


Q3: 栈中存的是对象还是引用?

答案

栈中存储的是基本类型的值对象的引用(reference)不是对象本身

java 复制代码
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 位
  • longdouble 占 2 个 Slot
  • 非静态方法的第 0 个 Slot 存储 this 引用
  • Slot 可复用(超出作用域的变量的 Slot 可被后续变量复用)

操作数栈的底层实现

  • 操作数栈是一个栈结构,字节码指令从操作数栈中弹出操作数、执行运算、将结果压回
  • 例如 iadd 指令:弹出两个 int,相加,将结果压回

Q7: 方法区中存储什么内容?

答案

方法区(JDK 8+ 为元空间)存储:

  1. 类的元数据:类名、访问修饰符、父类、接口列表
  2. 方法信息:方法名、参数类型、返回类型、字节码
  3. 字段信息:字段名、类型、修饰符
  4. 运行时常量池:字面量、符号引用
  5. 静态变量:类的静态字段(JDK 7+ 移到堆中)
  6. JIT 编译后的代码缓存:热点方法编译的机器码(CodeCache)
  7. 方法表(Method Table):虚方法分派使用

JDK 7 vs JDK 8 的变化

  • JDK 7:字符串常量池从永久代移到堆中
  • JDK 8:永久代被元空间取代,静态变量移到堆中
  • 元空间使用本地内存,不再受堆大小限制

Q8: 字符串常量池在哪里?

答案

  • JDK 6 及之前:永久代(方法区)
  • JDK 7+:

底层实现

  • 字符串常量池本质是一个 StringTable ,底层是哈希表
  • 每个字符串常量在表中有一个唯一的 String 对象
  • String.intern() 方法:如果常量池中已有相同内容的字符串,返回常量池中的引用;否则将当前字符串的引用放入常量池
java 复制代码
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)

底层执行过程

  1. 检查字符串常量池中是否有 "abc"
  2. 如果没有,在常量池中创建 "abc" 字符串对象
  3. 在堆中创建一个新的 String 对象,内容为 "abc"
  4. 栈中的引用 s 指向堆中的新对象

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: 强引用、软引用、弱引用、虚引用有什么区别?

答案

引用类型 GC 行为 使用场景 实现类
强引用 不回收(只要引用存在) 普通对象引用 赋值操作 A a = new A()
软引用 内存不足时回收 缓存(如图片缓存) SoftReference<T>
弱引用 下次 GC 时一定回收 WeakHashMap 的键 WeakReference<T>
虚引用 随时回收,无法通过虚引用获取对象 堆外内存管理、对象回收跟踪 PhantomReference<T>

底层实现

  • 所有引用类型都继承自 java.lang.ref.Reference
  • JVM 维护一个 Reference Handler 线程,负责处理引用队列
  • 软引用的回收时机:JVM 在抛出 OOM 之前,会清理所有软引用
  • 虚引用必须配合 ReferenceQueue 使用,当对象被 GC 后,虚引用被加入队列

Q12: 内存泄漏和内存溢出有什么区别?

答案

对比项 内存泄漏(Memory Leak) 内存溢出(OOM)
定义 对象不再使用但仍被引用,无法被 GC 回收 内存不足,无法分配新对象
结果 可用内存逐渐减少 直接抛出 OutOfMemoryError
原因 代码缺陷(如静态集合未清理) 内存太小 / 对象太多 / 泄漏导致
解决 找到并修复引用未释放的代码 增大内存 / 修复泄漏 / 优化代码

常见内存泄漏场景

  1. 静态集合持有对象引用
  2. 未关闭的资源(数据库连接、IO 流)
  3. 监听器未注销
  4. 内部类持有外部类引用
  5. ThreadLocal 使用后未 remove

Q13: 堆溢出(OOM: Java heap space)的常见原因?

答案

  1. 内存泄漏:对象无法被 GC 回收(如静态 Map 持续添加)
  2. 大对象分配:一次加载超大文件到内存
  3. 堆设置过小:-Xmx 设置太小
  4. 频繁 Full GC 但无法释放足够空间

排查步骤

  1. 添加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./dump.hprof
  2. 使用 MAT 或 JVisualVM 分析堆转储
  3. 查看 Dominator TreeLeak Suspects Report

Q14: 栈溢出(StackOverflowError)的常见原因?

答案

  1. 递归过深:无限递归或递归层级太深
  2. 栈帧过大:方法中定义大量局部变量或大数组
  3. -Xss 设置过小

解决方案

  1. 检查递归终止条件
  2. 将递归改为迭代
  3. 适当增大 -Xss(但会减少可创建的线程数)

Q15: 元空间溢出(OOM: Metaspace)的常见原因?

答案

  1. 动态生成类过多:CGLIB 代理、反射、Groovy 动态脚本
  2. 类加载器泄漏:Web 容器反复部署/卸载应用
  3. 大量 JSP 页面:每个 JSP 编译为一个类

底层原因

  • 元空间使用本地内存,由 MetaspaceClassLoaderDataGraph 管理
  • 每个类加载器有独立的 Metaspace 分配
  • 类加载器未被回收 → 其加载的所有类都无法回收 → 元空间持续增长

Q16: 什么是逃逸分析?

答案

逃逸分析是 JIT 编译器的一种优化技术,分析对象的作用域是否逃逸出方法或线程。

逃逸类型

  • 方法逃逸:对象被外部方法引用(如作为参数传递或返回值)
  • 线程逃逸:对象被其他线程访问

基于逃逸分析的优化

  1. 栈上分配(Stack Allocation):不逃逸的对象直接在栈上分配,方法结束自动释放,不需要 GC
  2. 标量替换(Scalar Replacement):将对象拆分为基本类型变量,直接在栈上存储
  3. 同步消除(Lock Elision):不逃逸线程的对象,去掉同步锁
java 复制代码
// 栈上分配示例
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) 分配
java 复制代码
// 对象分配流程
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)

锁状态 Mark Word 内容 标志位
无锁 hashCode(31) + age(4) + biased(1) 01
偏向锁 threadId(54) + epoch(2) + age(4) 01
轻量级锁 指向栈中锁记录的指针(62) 00
重量级锁 指向 Monitor 对象的指针(62) 10
GC 标记 空(62) 11

Q19: 对象的创建过程是怎样的?

答案

java 复制代码
Object obj = new Object();

完整创建过程(底层 6 步):

  1. 类加载检查:检查类是否已加载、解析、初始化。如果没有,先执行类加载
  2. 分配内存
    • 指针碰撞(Bump the Pointer):堆内存规整时使用(Serial、ParNew 收集器)
    • 空闲列表(Free List):堆内存不规整时使用(CMS 收集器)
    • 优先在 TLAB 中分配
  3. 初始化零值:将分配的内存空间初始化为零值(int=0, boolean=false, 引用=null)
  4. 设置对象头:设置 Mark Word(哈希码、GC 年龄、锁状态)和类型指针
  5. 执行 <init> 方法:执行构造方法,按照程序员的意愿初始化对象
  6. 栈上引用指向堆中对象

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 包括

  1. 虚拟机栈中引用的对象(局部变量表中的引用)
  2. 方法区中静态变量引用的对象static Object obj
  3. 方法区中常量引用的对象static final Object
  4. 本地方法栈中 JNI 引用的对象
  5. JVM 内部引用(如 Class 对象、异常对象、系统类加载器)
  6. 被同步锁(synchronized)持有的对象

底层实现

  • HotSpot 使用 OopMap(Ordinary Object Pointer Map) 记录栈帧中哪些位置存储了引用
  • GC 不需要遍历整个栈帧,直接查 OopMap 即可
  • 安全点(Safepoint):只有在安全点才会生成/更新 OopMap

Q22: 什么是安全点(Safepoint)和安全区域(Safe Region)?

答案

安全点

  • 程序执行过程中,只有在特定位置才能暂停进行 GC
  • 安全点位置:方法调用、循环跳转、异常跳转等
  • GC 时,所有线程必须跑到最近的安全点才能暂停(主动式中断

安全区域

  • 线程处于 SleepBlocked 状态时,无法主动跑到安全点
  • 安全区域是代码片段中引用关系不会发生变化的区域
  • 线程进入安全区域时设置标志,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: 对象什么时候进入老年代?

答案

  1. 年龄达到阈值 :经过 -XX:MaxTenuringThreshold(默认 15)次 Minor GC 后晋升
  2. 动态年龄判断:Survivor 中相同年龄对象总和 ≥ Survivor 空间 50%,该年龄及以上对象晋升
  3. 大对象直接分配 :超过 -XX:PretenureSizeThreshold 的大对象直接进入老年代
  4. 空间分配担保:Minor GC 后 Survivor 放不下的对象直接进入老年代

Q25: 什么是空间分配担保?

答案

Minor GC 前,JVM 检查老年代的可用空间是否大于新生代所有对象的总空间。

担保流程

  1. 如果老年代可用空间 > 新生代所有对象总空间 → 安全执行 Minor GC
  2. 如果不成立,检查 -XX:+HandlePromotionFailure 是否允许担保失败
  3. 如果允许,检查老年代可用空间 > 历次晋升的平均大小
  4. 如果大于平均值 → 冒险执行 Minor GC
  5. 如果小于平均值 → 触发 Full GC

JDK 6 Update 24 之后:规则简化为只要老年代可用空间 > 新生代所有对象总空间,就执行 Minor GC;否则 Full GC。


二、对象创建与内存分配

Q26: 对象在堆中的分配方式有哪些?

答案

  1. 指针碰撞(Bump the Pointer)

    • 堆内存是规整的(已用和空闲分开)
    • 分配时移动指针即可
    • 使用 Serial、ParNew 等带有压缩整理功能的收集器时使用
  2. 空闲列表(Free List)

    • 堆内存不规整(已用和空闲交错)
    • 维护一个空闲列表,分配时找到足够大的空闲块
    • 使用 CMS 等不带压缩功能的收集器时使用

并发安全

  • 多线程同时分配需要同步,使用 CAS + 失败重试
  • 优先在 TLAB 中分配(无需加锁)

Q27: 对象的生命周期是怎样的?

答案

复制代码
创建(new)→ 使用 → 不可达 → 被标记 → 被回收 → 内存释放

详细流程:

  1. 创建new 关键字 → 类加载 → 分配内存 → 初始化零值 → 设置对象头 → 执行构造方法
  2. 使用:被引用、调用方法
  3. 不可达:所有引用被置 null 或离开作用域
  4. 第一次标记:可达性分析发现不可达
  5. 第二次标记(finalize) :如果重写了 finalize() 且未被调用过,放入 F-Queue
  6. Finalizer 线程执行 finalize():如果在 finalize 中重新建立引用,对象复活
  7. 第二次标记完成:如果仍然不可达,标记为可回收
  8. GC 回收:标记-清除/复制/整理算法回收内存

💡 要点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: 对象的访问定位方式有哪些?

答案

  1. 句柄访问

    • 堆中划分一块"句柄池"
    • 引用存储的是句柄地址,句柄中包含对象实例数据指针和类型数据指针
    • 优点:引用稳定(对象移动时只需改句柄,不改引用)
    • 缺点:两次指针定位,性能较低
  2. 直接指针访问(HotSpot 使用)

    • 引用直接存储对象的堆地址
    • 对象头中包含类型指针,指向方法区中的类元数据
    • 优点:一次定位,性能更高
    • 缺点:对象移动时需要更新所有引用

    句柄访问: 引用 → 句柄池 → 对象实例数据
    → 类型数据(方法区)

    直接指针: 引用 → 对象实例数据 → 类型数据(方法区)


Q31: 什么是对象的标量替换?

答案

标量替换是逃逸分析的优化之一。如果 JIT 发现对象不逃逸,会将对象拆解为基本类型变量,直接在栈上存储。

java 复制代码
// 原始代码
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 的锁升级优化:

锁状态 Mark Word 适用场景 性能
无锁 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 字节的整数倍

原因

  1. CPU 缓存行对齐:CPU 缓存以缓存行(Cache Line,通常 64 字节)为单位读取,8 字节对齐减少跨缓存行访问
  2. 指针压缩:压缩指针以 8 字节为偏移单位
  3. 性能:对齐后内存访问更快

对象大小计算

复制代码
对象大小 = 对象头(12 字节)+ 实例数据 + 对齐填充(补齐到 8 的倍数

三、类加载机制

Q36: 类的加载过程是怎样的?

答案

类的生命周期包含 7 个阶段:

复制代码
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution)
→ 初始化(Initialization) → 使用(Using) → 卸载(Unloading)

其中,验证、准备、解析 合称为连接(Linking)

各阶段详解

阶段 工作内容
加载 通过类的全限定名获取字节码 → 将字节码转化为方法区的运行时数据结构 → 在堆中生成 Class 对象
验证 文件格式验证、元数据验证、字节码验证、符号引用验证
准备 类变量 (static)分配内存并设置零值(不是初始值)
解析 符号引用 替换为直接引用(指针/偏移量)
初始化 执行 <clinit>() 方法(类构造器),为类变量赋初始值

准备阶段的细节

java 复制代码
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() 源码):

java 复制代码
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: 双亲委派模型的作用是什么?

答案

  1. 避免类的重复加载:父加载器已加载的类,子加载器不会重复加载
  2. 保证核心类的安全 :防止用户自定义 java.lang.String 等核心类被篡改
  3. 保证类的唯一性:同一个类只被同一个类加载器加载一次

为什么核心类不能被篡改

  • 用户定义的 java.lang.String 会委派给 Bootstrap ClassLoader
  • Bootstrap ClassLoader 会加载 rt.jar 中的官方 String
  • 用户的自定义 String 永远不会被加载

Q40: 如何打破双亲委派模型?

答案

  1. 重写 loadClass() 方法:不委派父加载器,直接自己加载(如 Tomcat)
  2. 线程上下文类加载器(Thread Context ClassLoader):父加载器通过线程上下文加载器委托子加载器加载(如 SPI 机制)
  3. OSGi 模块化:网状的类加载结构

SPI 机制打破双亲委派的原因

  • JDBC 的 Driver 接口由 Bootstrap ClassLoader 加载(在 rt.jar 中)
  • 但 Driver 的实现类在 CLASSPATH 中,需要 Application ClassLoader 加载
  • 使用 Thread.currentThread().getContextClassLoader() 获取 Application ClassLoader 来加载

Tomcat 打破双亲委派的原因

  • 不同 Web 应用可能依赖不同版本的同一个库
  • 需要每个 Web 应用有独立的类加载器,互不影响
  • WebAppClassLoader 优先加载自己目录下的类,不委派父加载器

Q41: 什么是类的初始化时机?

答案

类的初始化(执行 <clinit>())在以下情况下触发:

主动引用(会触发初始化)

  1. new 实例化对象
  2. 读取/设置类的静态字段(final 常量除外)
  3. 调用类的静态方法
  4. 反射调用(Class.forName()
  5. 初始化子类时,如果父类未初始化,先初始化父类
  6. JVM 启动时的主类

被动引用(不会触发初始化)

  1. 子类引用父类的静态字段(只初始化父类)
  2. 定义类的数组(MyClass[] arr = new MyClass[10]
  3. 引用类的常量(static final,编译期已放入常量池)

Q42: <clinit>() 方法有什么特点?

答案

<clinit>() 是编译器自动生成的类构造器方法,用于初始化类变量和静态代码块。

特点

  1. 由编译器自动收集所有类变量的赋值动作静态代码块合并而成
  2. JVM 保证 <clinit>() 在多线程环境中被正确加锁同步(只会执行一次)
  3. 如果 <clinit>() 执行缓慢,会造成死锁(其他线程一直等待)
  4. 父类的 <clinit>() 先于子类执行
  5. 接口的 <clinit>() 不会先执行父接口的
java 复制代码
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: 什么是类的卸载?什么条件下会卸载?

答案

类的卸载是指从方法区(元空间)中移除类的元数据。

卸载条件(三个条件同时满足):

  1. 该类的所有实例都已被 GC 回收
  2. 加载该类的类加载器已被 GC 回收
  3. 该类的 java.lang.Class 对象没有被引用

实际场景

  • 由 Bootstrap、Extension、Application ClassLoader 加载的类基本不会被卸载
  • 自定义类加载器加载的类可能被卸载(如 OSGi、热部署场景)

Q44: 什么是字节码?Java 如何实现跨平台?

答案

字节码(Bytecode)是 Java 源代码编译后的中间代码 ,存储在 .class 文件中。

跨平台原理

复制代码
Java 源码 → javac 编译 → .class 字节码 → JVM(各平台不同)→ 机器码
  • 字节码是平台无关的,可以在任何 JVM 上运行
  • 每个平台有对应的 JVM 实现,负责将字节码转换为该平台的机器码

字节码指令

  • 由一个字节的操作码(opcode)+ 操作数组成
  • 共约 200 条指令,如 aload_0invokevirtualireturn

Q45: 类加载器的命名空间是什么?

答案

每个类加载器有自己的命名空间,由该加载器及其所有父加载器加载的类组成。

规则

  • 同一个命名空间中,类名必须唯一
  • 不同命名空间中,可以存在同名的类(不同实例)
  • 判断两个类是否相同:类名相同 + 同一个类加载器加载
java 复制代码
// 两个不同类加载器加载的同名类是不同的类
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: 什么是类的连接阶段中的验证?

答案

验证是连接的第一步,确保字节码的安全性和正确性。

四种验证

  1. 文件格式验证 :是否以 0xCAFEBABE 开头、版本号是否合法、常量池类型是否正确
  2. 元数据验证:是否有父类、是否继承了 final 类、是否实现了所有抽象方法
  3. 字节码验证:操作数栈的数据类型是否正确、跳转指令是否合法、类型转换是否安全
  4. 符号引用验证:符号引用对应的类/方法/字段是否存在、是否有访问权限

Q47: 如何自定义类加载器?

答案

继承 java.lang.ClassLoader,重写 findClass() 方法:

java 复制代码
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 中的动态代理有哪几种实现方式?

答案

  1. JDK 动态代理

    • 基于接口,通过 java.lang.reflect.Proxy 实现
    • 运行时生成代理类($Proxy0 等),实现目标接口
    • 底层使用 ProxyGenerator 生成字节码
  2. CGLIB 动态代理

    • 基于继承,通过生成目标类的子类实现代理
    • 使用 ASM 字节码框架生成子类的字节码
    • 不能代理 final 类和 final 方法

底层差异

  • JDK 代理:代理类继承 Proxy,实现目标接口,通过 InvocationHandler 调用
  • CGLIB:代理类继承目标类,重写目标方法,通过 MethodInterceptor 调用

四、垃圾回收算法

Q51: 如何判断对象是否可以被回收?

答案

  1. 引用计数法(JVM 不使用):

    • 每个对象维护一个引用计数器
    • 引用 +1,断开 -1,计数为 0 时回收
    • 问题:无法解决循环引用
  2. 可达性分析算法(JVM 使用):

    • 从 GC Roots 出发,沿引用链遍历
    • 不可达的对象判定为可回收
    • 需要两次标记确认(finalize 可能复活对象)

Q52: 垃圾回收算法有哪些?

答案

算法 原理 优点 缺点 使用场景
标记-清除 标记存活→清除未标记 简单 碎片、效率低 CMS 老年代
标记-复制 存活对象复制到新区域 无碎片、高效 空间浪费 50% 新生代
标记-整理 标记→存活对象向一端压缩 无碎片、空间高效 移动成本高 老年代
分代收集 按生命周期分区,不同区用不同算法 综合最优 实现复杂 所有主流收集器

标记-复制的优化

  • 新生代 Eden:S0:S1 = 8:1:1
  • 每次只使用 Eden + 一个 Survivor(90% 空间利用率)
  • 只浪费 10% 的空间

Q53: 什么是分代收集理论?

答案

分代收集基于两个分代假说

  1. 弱分代假说:绝大多数对象都是朝生夕灭的
  2. 强分代假说:熬过越多次 GC 的对象越难以消亡

实践

  • 新生代:对象生命周期短,使用标记-复制算法,频繁回收
  • 老年代:对象生命周期长,使用标记-清除/整理算法,低频回收
  • 新生代中存活的对象经过多次 GC 后晋升到老年代

跨代引用假说

  • 跨代引用相对于同代引用是极少数的
  • 使用记忆集(Remembered Set) 解决跨代引用问题

Q54: Minor GC、Major GC、Full GC 的区别?

答案

GC 类型 回收区域 触发条件 频率 耗时
Minor GC / Young GC 新生代 Eden 区满 频繁 短(毫秒级)
Major GC / Old GC 老年代 老年代空间不足 较少 较长
Full GC 整个堆 + 方法区 多种(见下方) 最少 最长(秒级)

Full GC 的触发条件

  1. System.gc() 建议 JVM 执行 Full GC
  2. 老年代空间不足
  3. 方法区(元空间)不足
  4. 空间分配担保失败
  5. CMS 的并发模式失败(Concurrent Mode Failure)

Q55: 什么是可达性分析的两次标记?

答案

对象被判定为不可达后,需要经过两次标记才能确定回收:

第一次标记:可达性分析发现对象不可达

第二次标记 :检查对象是否需要执行 finalize()

  • 如果对象没有重写 finalize()finalize() 已被调用过 → 标记为可回收
  • 如果对象重写了 finalize() 且未被调用过 → 放入 F-Queue
  • Finalizer 线程 异步执行 F-Queue 中对象的 finalize()
  • 如果在 finalize() 中重新建立引用(如 this 赋值给类变量)→ 对象复活
  • 复活的对象在第二次标记时被移出回收集合
java 复制代码
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 结束后仍为白色的对象被回收)
灰色 已被扫描,但其引用的对象尚未全部扫描

| 黑色 | 已被扫描,且其引用的对象全部扫描完成

标记过程

  1. 初始:所有对象为白色,GC Roots 为灰色
  2. 从灰色集合中取出对象,扫描其引用
  3. 将引用的对象标为灰色,自身标为黑色
  4. 重复直到灰色集合为空
  5. 剩余白色对象不可达,可以回收

并发标记的问题

  • 漏标(对象消失):黑色对象新增了对白色对象的引用,但灰色对象删除了对该白色对象的引用
  • 解决方案:写屏障 + 增量更新(CMS)写屏障 + 原始快照(G1)

Q57: 什么是写屏障(Write Barrier)?

答案

写屏障是在引用赋值操作前后插入的一段代码,类似于 AOP 的 Around 通知。

作用

  • 维护记忆集(Remembered Set / Card Table)
  • 实现增量更新或原始快照

写屏障的类型

  • 写前屏障(Pre-Write Barrier):赋值前执行(G1 的 SATB 使用)
  • 写后屏障(Post-Write Barrier):赋值后执行(CMS 的增量更新使用)

底层实现

java 复制代码
// 写后屏障的伪代码
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)表示没有跨代引用

写屏障维护卡表

java 复制代码
// 引用赋值时
obj.field = new_ref;
// 写后屏障自动执行
card_table[card_index(obj)] = DIRTY;

Q62: 什么是对象的晋升(Promotion)?

答案

对象从新生代晋升到老年代的过程。

晋升条件

  1. 年龄达到阈值:经过 MaxTenuringThreshold 次 Minor GC 后
  2. 动态年龄判断:Survivor 中同龄对象 ≥ Survivor 空间 50%
  3. 大对象直接晋升:超过 PretenureSizeThreshold
  4. 空间分配担保:Minor GC 后 Survivor 放不下

动态年龄判断的底层实现

复制代码
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 检查老年代是否有足够空间容纳新生代可能晋升的对象。

流程

  1. 检查老年代可用空间 > 新生代所有对象总空间 → 安全
  2. 检查老年代可用空间 > 历次晋升平均大小 → 冒险执行
  3. 以上都不满足 → 触发 Full GC

Q64: 什么是引用计数法?为什么 JVM 不使用?

答案

引用计数法为每个对象维护一个引用计数器:

  • 引用 +1,断开引用 -1
  • 计数为 0 时回收

为什么 JVM 不使用

  • 无法解决循环引用:两个对象互相引用,计数永远不为 0
  • 需要额外的计数器空间
  • 每次引用变更都需要更新计数器
java 复制代码
// 循环引用示例
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. 并发清除 ------ 与用户线程并发执行,清除未标记对象

缺点

  1. CPU 敏感:并发阶段占用 CPU 资源
  2. 内存碎片:使用标记-清除算法,产生碎片
  3. 浮动垃圾:并发清除阶段新产生的垃圾本次 GC 无法回收
  4. Concurrent Mode Failure:并发阶段老年代满了,退化为 Serial Old

参数

  • -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 的区别?

答案

对比项 CMS G1
算法 标记-清除 复制+标记-整理
碎片 有碎片 无碎片(Region 内压缩)
停顿 不可预测 可设定目标(MaxGCPauseMillis
内存 连续分代 Region 化
适用 中小堆(< 8GB) 大堆(> 4GB)
回收范围 老年代 整堆(Mixed GC)
并发阶段 并发标记+并发清除 仅并发标记
JDK JDK 9 标记废弃 JDK 9 默认

Q73: ZGC 收集器的特点?

答案

ZGC 是 JDK 11 引入的超低延迟 收集器,目标停顿时间 < 10ms ,且不随堆大小增长

核心技术

  1. 染色指针(Colored Pointer)

    • 64 位指针中,高 4 位用于存储标记信息(Marked0/Marked1/Remapped/Finalizable)
    • 对象的引用信息直接存储在指针中,不需要读取对象头
  2. 读屏障(Load Barrier)

    • 访问对象时检查指针的标记位
    • 如果指针被标记,先执行修正操作再访问
  3. 并发整理

    • 标记、转移、重定位几乎完全并发执行
    • 几乎无 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 的影响)
  • 生命周期很短的应用
  • 一次性任务
bash 复制代码
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)实现可控停顿。

原理

  1. G1 维护每个 Region 的回收价值(垃圾量 / 回收耗时)
  2. 根据 -XX:MaxGCPauseMillis 的目标停顿时间
  3. 选择回收价值最高的 Region 集合,使得总耗时不超过目标

为什么叫 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) 操作
  • 自愈:将指针的标记位修正为当前正确的值

并发转移的原理

  1. 对象被转移后,旧地址的转发指针指向新地址
  2. 访问旧地址时,读屏障检测到标记位变化
  3. 自动修正为新地址,访问新位置的对象

Q84: 什么是 GC 的并发标记与用户线程的竞争?

答案

并发标记阶段,GC 线程和用户线程同时执行,可能导致:

  1. 漏标:用户线程修改了引用关系,导致 GC 误认为对象不可达
  2. 多标:用户线程删除了引用,导致 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 编译器

  1. C1(Client Compiler)

    • 编译速度快,优化少
    • 适合客户端应用
    • 参数:-client
  2. 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: 什么是热点代码?如何检测?

答案

热点代码是被频繁执行的代码段。

检测方法

  1. 基于采样:定时采样线程的 PC 寄存器,统计哪些方法/循环被频繁执行
  2. 基于计数器:维护方法调用计数器和回边计数器

HotSpot 的计数器

  • 方法调用计数器 :方法被调用的次数
    • 阈值:-XX:CompileThreshold(Client 1500,Server 10000)
  • 回边计数器 :循环体执行的次数
    • 阈值:-XX:OnStackReplacePercentage

热度衰减

  • 方法调用计数器在半衰期内未达到阈值,计数减半
  • -XX:-UseCounterDecay 控制是否开启

Q89: JIT 编译器有哪些优化技术?

答案

优化技术 说明
方法内联 将被调用方法的代码直接嵌入调用处
逃逸分析 分析对象是否逃逸出方法/线程
标量替换 将对象拆分为基本类型变量
栈上分配 不逃逸的对象在栈上分配
同步消除 不逃逸线程的对象去除同步锁
循环展开 减少循环跳转次数
空值检查消除 去除不必要的 null 检查
公共子表达式消除 复用已计算的表达式
内联缓存 缓存虚方法的调用目标

方法内联的条件

  • 方法体 ≤ 35 字节(C2 默认)自动内联
  • 方法体 ≤ 325 字节(C2)且调用频繁时内联
  • -XX:MaxInlineSize 控制内联阈值

Q90: 什么是方法内联(Method Inlining)?

答案

方法内联是 JIT 最重要的优化,将被调用方法的代码直接嵌入调用处,消除方法调用开销。

java 复制代码
// 原始代码
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

对比项 AOT JIT
编译时机 运行前 运行时
启动速度 极快 较慢
峰值性能 较低(无法做运行时优化) 更高(可做运行时优化)
编译时间 极长(分钟级) 短(毫秒级)

Q92: 什么是逃逸分析的标量替换?

答案

标量替换是逃逸分析的优化之一。如果 JIT 发现对象不逃逸,将对象拆解为基本类型变量

java 复制代码
// 原始代码
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 编译代码被废弃,回退到解释执行的过程。

触发条件

  1. 类被卸载:编译的代码依赖的类被 GC 回收
  2. 类型推断失败:内联的虚方法的类型假设被打破
  3. 代码缓存不足:CodeCache 满了,需要清理旧代码
  4. 调试需要:断点调试时需要回到解释执行

Q94: 什么是 CodeCache?

答案

CodeCache 是 JVM 存储JIT 编译后的本地机器码的区域。

底层实现

  • 使用本地内存(Native Memory)
  • 默认大小:240MB(JDK 8)或 240MB(JDK 11+)
  • -XX:ReservedCodeCacheSize 控制

CodeCache 满了会怎样

  • JIT 编译停止,所有代码使用解释执行
  • 性能显著下降
  • JVM 日志中会出现 CodeCache is full 警告
bash 复制代码
# 监控 CodeCache
jcmd <pid> Compiler.codecache

Q95: 什么是 GraalVM?

答案

GraalVM 是 Oracle 开发的高性能多语言虚拟机

核心特性

  1. Graal 编译器:新一代 JIT 编译器,优化能力更强
  2. Native Image:AOT 编译,生成独立可执行文件
  3. 多语言支持:Java、JavaScript、Python、Ruby、R 等
  4. Polyglot API:不同语言之间无缝互调

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 耗时长,老年代空间不足

参数

bash 复制代码
-Xmn2g                          # 新生代 2GB
-XX:SurvivorRatio=8             # Eden:S0:S1 = 8:1:1
-XX:MaxTenuringThreshold=15     # 最大晋升年龄
-XX:TargetSurvivorRatio=90      # Survivor 目标使用率
-XX:+UseAdaptiveSizePolicy      # 自适应调节(Parallel Scavenge)

调优步骤

  1. 观察 GC 日志,分析 Minor GC 频率和耗时
  2. 如果 Minor GC 过于频繁 → 增大新生代
  3. 如果 Minor GC 耗时过长 → 减小新生代
  4. 如果对象过早晋升 → 增大 Survivor 或 MaxTenuringThreshold

Q98: 如何调优老年代?

答案

原则

  • 老年代太小 → 频繁 Full GC
  • 老年代太大 → Full GC 耗时长

参数

bash 复制代码
-XX:NewRatio=2                  # 老年代:新生代 = 2:1
-XX:CMSInitiatingOccupancyFraction=75  # CMS 触发阈值
-XX:G1MixedGCCountTarget=8      # G1 Mixed GC 次数目标

Q99: 如何开启和分析 GC 日志?

答案

JDK 8 GC 日志参数

bash 复制代码
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/to/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M

JDK 9+ 统一日志格式

bash 复制代码
-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 暂停所有用户线程的时间。

优化方法

  1. 选择低延迟收集器:G1、ZGC、Shenandoah
  2. 调整堆大小:堆太大 → GC 耗时长;堆太小 → 频繁 GC
  3. 调整新生代大小:新生代大 → Minor GC 耗时长但频率低
  4. 减少对象分配:优化代码,减少临时对象
  5. G1 停顿目标-XX:MaxGCPauseMillis=200
  6. ZGC:几乎无 STW

Q101: 如何设置 JVM 参数?

答案

bash 复制代码
# 基本参数
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?

答案

bash 复制代码
-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\[\] 数组指向同一个
  • 节省内存

参数

bash 复制代码
-XX:+UseStringDeduplication    # 开启字符串去重
-XX:StringDeduplicationAgeThreshold=3  # 只去重存活 3 次 GC 的 String

Q104: 什么是大页(Huge Pages)?

答案

操作系统默认内存页大小 4KB,大页使用 2MB 或 1GB 的页。

优势

  • 减少 TLB(Translation Lookaside Buffer)缺失
  • 减少页表项数量
  • 提高内存访问性能

JVM 参数

bash 复制代码
-XX:+UseLargePages            # 启用大页
-XX:LargePageSizeInBytes=2m   # 大页大小

前提 :操作系统需要配置大页支持(Linux: vm.nr_hugepages)。


Q105: 如何判断堆内存是否设置合理?

答案

  1. GC 日志分析

    • Full GC 频率 < 1 天一次 → 合理
    • Full GC 后老年代使用率 < 70% → 合理
    • Minor GC 频率合理(几秒到几十秒一次)
  2. 指标

    • GC 时间占比 < 5% → 合理
    • Full GC 停顿时间可接受
    • 堆使用率峰值 < 80%
  3. 不合理的信号

    • 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 飙高问题?

答案

  1. 找到 CPU 高的线程

    bash 复制代码
    top -Hp <pid>  # 找到 CPU 高的线程 ID
    printf '%x' <tid>  # 转换为 16 进制
    jstack <pid> | grep <tid_hex>  # 查看线程栈
  2. 常见原因

    • Full GC 频繁(GC 线程占 CPU)
    • 死循环
    • 死锁
    • 正则表达式回溯

Q108: 如何排查内存飙高问题?

答案

  1. 查看堆内存使用

    bash 复制代码
    jmap -heap <pid>
    jstat -gcutil <pid> 1000
  2. 生成堆转储

    bash 复制代码
    jmap -dump:format=b,file=dump.hprof <pid>
  3. 分析堆转储

    • MAT:Dominator Tree、Leak Suspects
    • JVisualVM:类实例数、内存占用

Q109: 如何设置一个合理的 JVM 参数模板?

答案

bash 复制代码
# 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 代码缓存

监控方法

bash 复制代码
# 查看 NIO 直接内存
jcmd <pid> VM.native_memory summary

# 开启 NMT(Native Memory Tracking)
-XX:NativeMemoryTracking=summary

八、内存泄漏与溢出排查

Q111: 常见的内存泄漏场景有哪些?

答案

  1. 静态集合持有对象static List<Object> list 持续添加不清理
  2. 未关闭的资源:数据库连接、IO 流、Socket
  3. 监听器未注销:事件监听器、观察者模式
  4. 内部类持有外部类引用:非静态内部类、匿名内部类
  5. ThreadLocal 未 remove:线程池中 ThreadLocal 不清理
  6. 缓存无过期策略:HashMap 做缓存,无淘汰机制
  7. 自定义类加载器泄漏:类加载器未被 GC 回收

Q112: ThreadLocal 的内存泄漏问题?

答案

ThreadLocal 使用 ThreadLocalMap 存储数据,key 是弱引用,value 是强引用。

泄漏原因

  1. ThreadLocal 对象被 GC 回收(key 是弱引用)
  2. 但 value 仍然被 Entry 强引用
  3. 如果线程是线程池中的核心线程,不会销毁
  4. value 永远无法被回收

解决方案

java 复制代码
try {
    threadLocal.set(value);
    // 使用 value
} finally {
    threadLocal.remove(); // 必须清理!
}

Q113: 如何使用 MAT 分析堆转储?

答案

  1. 打开 dump.hprof 文件
  2. Leak Suspects Report:自动分析可能的泄漏点
  3. Dominator Tree:查看占用内存最大的对象
  4. Histogram:查看各类的实例数和内存占用
  5. GC Roots → Path to GC Roots:找到对象的引用链
  6. OQL:使用对象查询语言查找特定对象

Q114: 如何使用 jstat 监控 GC?

答案

bash 复制代码
# 每 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 生成堆转储?

答案

bash 复制代码
# 生成堆转储
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 分析线程状态?

答案

bash 复制代码
# 输出线程栈
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 事件
  • 方法采样
  • 内存分配

使用方法

bash 复制代码
# 开始录制
jcmd <pid> JFR.start duration=60s filename=recording.jfr

# 或使用参数启动
-XX:StartFlightRecording=duration=60s,filename=recording.jfr

分析工具:JDK Mission Control(JMC)


Q118: 如何排查 Full GC 频繁的问题?

答案

  1. 查看 GC 日志:分析 Full GC 的频率、耗时、前后内存变化
  2. 分析堆转储:查看哪些对象占用最多内存
  3. 常见原因
    • 老年代空间不足 → 增大堆或优化代码
    • 内存泄漏 → 修复泄漏
    • 元空间不足 → 增大 MetaspaceSize
    • System.gc() 调用 → 禁用 -XX:+DisableExplicitGC
  4. 解决方案
    • 增大堆内存
    • 优化代码减少对象分配
    • 选择合适的收集器
    • 调整新生代/老年代比例

Q119: 如何排查 OOM 问题?

答案

步骤

  1. 保留现场:-XX:+HeapDumpOnOutOfMemoryError
  2. 分析堆转储:
    • MAT → Leak Suspects → Dominator Tree
    • 找到占用内存最多的对象
    • 追踪 GC Roots 引用链
  3. 定位代码:找到创建/持有这些对象的代码
  4. 修复:
    • 内存泄漏:修复引用未释放的代码
    • 内存不足:增大堆 / 优化代码 / 分批处理

Q120: 什么是 Metaspace 的类加载器泄漏?

答案

类加载器泄漏是指类加载器无法被 GC 回收,导致其加载的所有类都无法卸载。

常见场景

  • Web 容器(Tomcat)反复部署/卸载应用
  • 每次部署创建新的类加载器,旧的类加载器未被回收
  • 某些静态引用持有旧类加载器的引用

排查

bash 复制代码
jmap -dump:live,format=b,file=dump.hprof <pid>
# MAT 中查看 ClassLoader 的实例数和 GC Roots

Q121: 什么是直接内存泄漏?

答案

直接内存(NIO ByteBuffer)不受 GC 直接管理,如果未正确释放会导致泄漏。

泄漏原因

  • ByteBuffer.allocateDirect() 分配的内存未释放
  • Cleaner 未被触发(GC 不及时)
  • Netty 的 ByteBuf 未 release

排查

bash 复制代码
# 监控直接内存
-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 的线程数?

答案

bash 复制代码
# 查看线程数
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 动态解析

  • invokespecialinvokestatic:在类加载的解析阶段静态解析
  • invokevirtualinvokeinterface:在运行时动态解析(多态)

Q129: 什么是操作数栈?

答案

操作数栈是栈帧中的工作区,字节码指令在此进行运算。

java 复制代码
// 计算 1 + 2
// 字节码:
iconst_1    // 将 1 压入操作数栈
iconst_2    // 将 2 压入操作数栈
iadd        // 弹出两个 int,相加,将结果 3 压回
istore_1    // 弹出结果,存入局部变量表第 1 个 Slot

Q130: 什么是方法的分派(Dispatch)?

答案

静态分派

  • 编译期确定方法目标
  • 对应 invokespecialinvokestatic
  • 也叫解析(Resolution)

动态分派

  • 运行时确定方法目标
  • 对应 invokevirtualinvokeinterface
  • 实现多态

单分派 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 在字节码层面是如何实现的?

答案

  • 同步代码块 :使用 monitorentermonitorexit 指令
  • 同步方法 :方法的 access_flags 中设置 ACC_SYNCHRONIZED 标志
java 复制代码
// 同步代码块
synchronized (obj) {
    // 代码
}
// 字节码:
monitorenter    // 获取 obj 的监视器锁
// 代码
monitorexit     // 释放锁
// 异常处理中的 monitorexit(保证异常时也能释放锁)

Q134: 什么是局部变量表的 Slot 复用?

答案

局部变量表中的 Slot 可以被后续变量复用(超出作用域后)。

java 复制代码
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

三大特性

  1. 原子性synchronizedLock 保证
  2. 可见性volatilesynchronized 保证
  3. 有序性volatilesynchronized 保证(禁止指令重排序)

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:  重入计数

工作流程

  1. 线程尝试获取锁 → CAS 设置 _owner
  2. 成功 → 进入同步代码
  3. 失败 → 进入 _EntryList 阻塞
  4. 调用 wait() → 进入 _WaitSet 等待
  5. notify() → 从 _WaitSet 移到 _EntryList

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 需要重新读取

解决

java 复制代码
// 使用 @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 触发条件?

答案

  1. 老年代占用率达到 -XX:InitiatingHeapOccupancyPercent(默认 45%)
  2. 触发并发标记
  3. 并发标记完成后,触发 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 堆)

读屏障

java 复制代码
// 访问对象时
Object o = ref.obj;
// 读屏障:检查指针标记位
if (指针标记位 != 期望值) {
    // 自愈:修正指针
    ref.obj = self_heal(ref.obj);
    o = ref.obj;
}

Q150: ZGC 的并发转移如何实现?

答案

ZGC 的对象转移(Relocation)几乎完全并发

  1. 选择待转移的 Region
  2. 并发转移:将存活对象复制到新 Region
  3. 并发重映射:修正所有指向旧地址的引用

关键

  • 使用转发表(Forwarding Table) 记录旧地址到新地址的映射
  • 读屏障在访问旧地址时,自动修正为新地址
  • 不需要 STW

Q151: Shenandoah 的转发指针如何工作?

答案

Shenandoah 在每个对象头部添加一个转发指针(Brooks Pointer)

原理

  • 对象移动时,更新转发指针指向新地址
  • 后续访问旧地址时,通过转发指针跳转到新地址
  • 并发转移时,使用 CAS 更新转发指针

读屏障

  • 每次读取对象时检查转发指针
  • 如果转发指针不指向自身,说明对象已移动,自动跳转

Q152: ZGC 和 Shenandoah 的区别?

答案

对比项 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 的并发标记失败会怎样?

答案

并发标记失败会导致:

  1. 退化为单线程的 Full GC(Serial Old)
  2. 完全 STW,耗时很长
  3. 原因:并发标记期间堆满了,无法继续

避免

  • 不要让堆使用率过高
  • 增大堆或调整 -XX:InitiatingHeapOccupancyPercent

Q159: ZGC 的指针压缩如何工作?

答案

ZGC 在 JDK 16+ 支持指针压缩(-XX:+UseCompressedOops):

  • 堆 < 4GB 时,指针压缩为 32 位
  • 堆 4GB~4TB 时,使用 64 位指针(带染色位)
  • 染色位存储在指针的高 4 位

Q160: 什么是 G1 的疏散(Evacuation)?

答案

疏散是 G1 将存活对象从一个 Region 复制到另一个 Region 的过程。

过程

  1. 选择要回收的 Region
  2. 将存活对象复制到空闲 Region
  3. 更新引用(RSet 辅助)
  4. 回收原 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?

答案

bash 复制代码
# 列出 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 诊断工具。

bash 复制代码
# 启动
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)?

答案

bash 复制代码
# 启动录制(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 分析内存泄漏?

答案

  1. 打开 .hprof 文件

  2. Leak Suspects Report:自动分析泄漏可疑点

  3. Dominator Tree:查看对象的内存占用排名

  4. Path to GC Roots(排除弱引用/软引用):找到引用链

  5. OQL 查询

    sql 复制代码
    SELECT * FROM java.util.HashMap WHERE size > 10000
    SELECT * FROM java.lang.String WHERE value.length > 1000

Q166: 如何监控元空间?

答案

bash 复制代码
# jstat 查看元空间
jstat -gcmetacapacity <pid>

# JMX 监控
# java.lang:type=MemoryPool,name=Metaspace

# 参数
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

Q167: 如何监控直接内存?

答案

bash 复制代码
# 开启 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 编译?

答案

bash 复制代码
# 查看 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 的所有参数?

答案

bash 复制代码
# 查看所有参数
jcmd <pid> VM.flags

# 查看特定参数
java -XX:+PrintFlagsFinal -version | grep UseG1GC

# 查看参数默认值
java -XX:+PrintFlagsInitial

# 查看参数最终值
java -XX:+PrintFlagsFinal

Q170: 如何生成和分析 GC 日志?

答案

JDK 8

bash 复制代码
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-XX:+PrintHeapAtGC
-Xloggc:/path/to/gc.log

JDK 9+

bash 复制代码
-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 的启动时间和内存占用

与类加载的关系

  • 每个模块有独立的类加载器命名空间
  • 模块之间的访问受 exportsrequires 约束

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)
java 复制代码
// 创建虚拟线程
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 的并发整理通过转发指针实现:

  1. 并发标记:确定存活对象
  2. 并发整理:将存活对象复制到新 Region
  3. 并发引用修正:通过读屏障修正引用

关键:读屏障在访问对象时检查转发指针,自动跳转到新地址。


Q177: 什么是 GC 的安全点(Safepoint)机制的优化?

答案

JVM 不断优化安全点机制,减少 STW 时间:

  1. JEP 312(JDK 10):Thread-Local Handshakes

    • 不需要所有线程同时到达安全点
    • 可以逐个线程执行握手操作
    • 减少了安全点的全局暂停时间
  2. 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 的类数据共享技术,加速应用启动。

原理

  1. 首次启动时,将类的元数据转储到共享归档文件(.jsa)
  2. 后续启动时,直接映射共享归档文件到内存
  3. 避免重复解析和加载类

JDK 版本演进

  • JDK 8:基础 CDS(只支持 Bootstrap ClassLoader 加载的类)
  • JDK 10:AppCDS(支持应用类的共享)
  • JDK 13:Dynamic CDS(动态归档)
bash 复制代码
# 生成共享归档
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 编译器的优势。

编译层级

Level 说明
0 解释执行
1 C1 编译,无性能分析
2 C1 编译,有限性能分析
3 C1 编译,完整性能分析
4 C2 编译,完全优化

流程

复制代码
方法首次调用 → Level 0(解释执行)
→ 调用次数达到阈值 → Level 3(C1 编译 + 性能分析)
→ 热点数据足够 → Level 4(C2 编译,完全优化)

Q181: 什么是 JVM 的类数据压缩?

答案

JVM 使用多种压缩技术减少内存占用:

  1. 指针压缩(Compressed Oops):64 位引用压缩为 32 位
  2. 类指针压缩(Compressed Class Pointers):类元数据指针压缩
  3. Klass 压缩:类的 Klass 对象压缩存储

Q182: 什么是 JVM 的字节码验证?

答案

字节码验证在类加载的连接阶段进行,确保字节码的安全性。

验证内容

  1. 类型安全:操作数栈的数据类型正确
  2. 访问控制:private/protected 成员不被非法访问
  3. 初始化安全:对象使用前已初始化
  4. 分支安全:跳转目标在方法范围内

JDK 7+ 的优化

  • StackMapTable 属性:编译期生成栈映射表,减少运行时验证
  • 类型检查验证(Type Checking Verification)替代类型推断验证

Q183: 什么是 JVM 的安全点轮询?

答案

安全点轮询是线程检查是否需要暂停的机制。

实现

  • JVM 在安全点位置插入轮询代码
  • 轮询代码检查一个全局标志(安全点标志)
  • 如果标志被设置,线程暂停

HotSpot 的优化

  • 使用内存保护页实现轮询
  • 安全点标志是一个内存页
  • 正常情况:线程读取该页面(已映射)
  • GC 时:取消该页面的映射 → 线程访问时触发页错误 → 暂停

Q184: 什么是 JVM 的去优化(Deoptimization)?

答案

去优化是将 JIT 编译的代码废弃,回退到解释执行。

触发条件

  1. 类被卸载:编译代码依赖的类被回收
  2. 类型推测失败:内联的虚方法假设被打破
  3. 代码缓存满:需要清理旧代码
  4. 调试需要:断点调试

去优化的类型

  • 部分去优化:只废弃部分编译代码
  • 完全去优化:废弃整个方法的编译代码

Q185: 什么是 JVM 的逃逸分析的局限性?

答案

逃逸分析虽然强大,但有局限性:

  1. 分析开销:逃逸分析本身消耗 CPU
  2. 保守估计:如果无法确定是否逃逸,保守认为会逃逸
  3. 不支持跨方法分析:只能分析单个方法内的逃逸
  4. 依赖 JIT:只有被 JIT 编译的方法才会做逃逸分析
  5. 不能替代 GC:逃逸分析只能减少短命对象的堆分配

十四、场景实战题

Q186: 线上出现 Full GC 频繁,如何排查?

答案

  1. 查看 GC 日志:分析 Full GC 的频率、触发原因、耗时
  2. 查看堆内存jstat -gcutil <pid> 观察老年代使用率
  3. 生成堆转储jmap -dump:format=b,file=dump.hprof <pid>
  4. 分析堆转储:MAT → Leak Suspects → 找到内存泄漏
  5. 常见原因
    • 内存泄漏 → 修复代码
    • 堆太小 → 增大 -Xmx
    • 新生代太小 → 增大 -Xmn
    • 元空间不足 → 增大 MetaspaceSize
    • System.gc() 调用 → -XX:+DisableExplicitGC

Q187: 线上出现 OOM,如何快速定位?

答案

  1. 保留现场 :确保启动参数有 -XX:+HeapDumpOnOutOfMemoryError
  2. 获取堆转储 :如果没配置自动 dump,用 jmap -dump:live 手动生成
  3. MAT 分析
    • Leak Suspects Report → 自动分析泄漏点
    • Dominator Tree → 找到占用内存最大的对象
    • Path to GC Roots → 追踪引用链
  4. 定位代码:根据引用链找到创建/持有对象的代码
  5. 修复:内存泄漏→修复引用;内存不足→增大堆/优化代码

Q188: 线上出现 CPU 飙高,如何排查?

答案

  1. 找到 CPU 高的线程

    bash 复制代码
    top -Hp <pid>
    printf '%x' <tid>
    jstack <pid> | grep -A 20 <tid_hex>
  2. 常见原因

    • Full GC 频繁 → GC 线程占 CPU
    • 死循环 → 线程栈显示在循环代码处
    • 正则表达式回溯 → 正则匹配卡住
  3. GC 导致的 CPU 飙高:查看 GC 日志,分析 Full GC 原因


Q189: 线上出现死锁,如何排查?

答案

bash 复制代码
# 检测死锁
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)

解决方案

  1. 按固定顺序获取锁
  2. 使用 tryLock() 设置超时
  3. 使用 jstack 找到死锁的线程和锁,修复代码

Q190: 如何优化一个频繁 GC 的服务?

答案

  1. 分析 GC 日志:确定 GC 类型(Minor/Major/Full)、频率、耗时
  2. 调整堆大小
    • -Xms = -Xmx(避免动态扩展)
    • 新生代占堆的 1/3~1/2
  3. 选择合适的收集器
    • 低延迟:G1(-XX:MaxGCPauseMillis=200
    • 吞吐量:Parallel
  4. 优化代码
    • 减少临时对象创建
    • 使用对象池
    • 避免大对象直接进入老年代
  5. 监控:JFR + Prometheus + Grafana

Q191: 如何排查 Metaspace OOM?

答案

bash 复制代码
# 查看元空间使用
jstat -gcmetacapacity <pid>

# 生成堆转储
jmap -dump:format=b,file=dump.hprof <pid>

# MAT 中查看 ClassLoader 和 Class 的实例数

常见原因

  1. CGLIB 动态代理生成大量类
  2. Groovy/JSP 动态编译
  3. 类加载器泄漏(Web 容器热部署)

解决

  • 增大 -XX:MetaspaceSize-XX:MaxMetaspaceSize
  • 排查类加载器泄漏
  • 限制动态生成类的数量

Q192: 如何优化大对象的内存使用?

答案

  1. 避免一次性加载大文件到内存:使用流式处理
  2. 使用直接内存:NIO ByteBuffer,避免堆内复制
  3. 压缩数据:使用压缩算法减少内存占用
  4. 分块处理:将大对象拆分为多个小对象
  5. 使用堆外缓存:如 Ehcache 的 Off-Heap 存储

Q193: 如何设计一个低延迟的 JVM 参数?

答案

bash 复制代码
# 低延迟参数模板
-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 问题?

答案

  1. 生成堆转储jmap -dump:format=b,file=dump.hprof <pid>
  2. MAT 中查找 ThreadLocalMap
    • OQL: SELECT * FROM java.lang.ThreadLocal$ThreadLocalMap
  3. 查看 Entry 的 value
    • 找到没有被 remove 的 ThreadLocal
  4. 追踪线程:查看哪个线程持有该 ThreadLocalMap
  5. 修复 :在 finally 中调用 threadLocal.remove()

Q195: 如何排查 NIO 的直接内存泄漏?

答案

bash 复制代码
# 开启 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 对服务延迟的影响?

答案

  1. GC 日志分析

    • 记录每次 GC 的 STW 时间
    • 统计 P99/P999 GC 停顿时间
  2. JFR 分析

    • 记录 jdk.GCHeapSummary 事件
    • 分析 GC 停顿时间分布
  3. 应用层监控

    • 记录请求的 P99 延迟
    • 与 GC 时间关联分析

Q197: 如何优化 G1 的停顿时间?

答案

bash 复制代码
# 设置目标停顿时间
-XX:MaxGCPauseMillis=200

# 调整触发并发标记的阈值
-XX:InitiatingHeapOccupancyPercent=45

# 控制 Mixed GC 的老年代 Region 数
-XX:G1MixedGCCountTarget=8

# 控制 RSet 更新时间
-XX:G1RSetUpdatingPauseTimePercent=5

# 调整 Region 大小
-XX:G1HeapRegionSize=16m

Q198: 如何排查类加载冲突?

答案

类加载冲突是指同一个类被多个类加载器加载,导致 ClassCastException 或行为不一致。

排查

bash 复制代码
# 查看类的加载器
jcmd <pid> VM.classloader_stats

# Arthas 查看类的加载信息
sc -d com.example.MyClass

# 查看类的来源
-XX:+TraceClassLoading
-XX:+TraceClassUnloading

解决

  • 排除重复的 jar 包
  • 使用 mvn dependency:tree 分析依赖冲突
  • 统一类加载器

Q199: 如何设计一个高并发服务的 JVM 参数?

答案

bash 复制代码
# 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 的性能基准测试?

答案

  1. JMH(Java Microbenchmark Harness)

    java 复制代码
    @Benchmark
    @BenchmarkMode(Mode.Throughput)
    @OutputTimeUnit(TimeUnit.SECONDS)
    public void testMethod() {
        // 被测代码
    }
  2. 测试指标

    • 吞吐量(ops/sec)
    • 延迟(P50/P95/P99)
    • GC 停顿时间
    • 内存使用
  3. 测试方法

    • 预热:先运行 5~10 轮
    • 测试:运行 10~20 轮,取平均
    • 对比:不同 JVM 参数/不同代码版本
  4. 工具

    • 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 开启

常用诊断命令速查

bash 复制代码
# 进程
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)。面试时结合线上排查经验,展示从原理到实践的完整能力。

相关推荐
youtootech7 小时前
HarmonyOS《柚兔学伴》项目实战08-关系型数据库入门——待办事项持久化
jvm·数据库·华为·harmonyos
想吃火锅10057 小时前
【leetcode】5.最长回文子串js
算法·leetcode·职场和发展
CoderYanger8 小时前
A.每日一题:1967题+1331题
java·程序人生·算法·leetcode·面试·职场和发展·学习方法
ShineWinsu8 小时前
对于Linux:TCPsocket编程基础的解析
linux·网络协议·tcp/ip·面试·笔试·进程·远程指令
LoveAmySun9 小时前
关于引入双供应商调度系统的论证报告
职场和发展
Java技术实战10 小时前
一文通解:数据结构之 栈
java·jvm·数据结构·
胡萝卜术11 小时前
从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路
前端·javascript·面试
触底反弹21 小时前
🔥 Git 从零到精通:彻底搞懂核心概念与工作原理
git·面试·开源
天选之子1231 天前
DuckDB学习
jvm·学习