🎯 适用:Java 后端开发、架构师、中间件开发岗位面试
📖 内容覆盖:内存模型 · 对象创建 · 类加载 · 垃圾回收算法 · 垃圾回收器 · JIT编译 · JVM调优 · 内存排查 · 字节码 · 线程模型 · G1/ZGC/Shenandoah · 工具监控 · 场景实战
目录
- [一、JVM 内存模型(Q1-Q25)](#一、JVM 内存模型(Q1-Q25))
- 二、对象创建与内存分配(Q26-Q35)
- 三、类加载机制(Q36-Q50)
- 四、垃圾回收算法(Q51-Q65)
- 五、垃圾回收器(Q66-Q85)
- [六、JIT 编译与执行引擎(Q86-Q95)](#六、JIT 编译与执行引擎(Q86-Q95))
- [七、JVM 参数与调优(Q96-Q110)](#七、JVM 参数与调优(Q96-Q110))
- 八、内存泄漏与溢出排查(Q111-Q125)
- 九、字节码与执行引擎深入(Q126-Q135)
- 十、线程模型与并发深入(Q136-Q145)
- [十一、G1/ZGC/Shenandoah 深入(Q146-Q160)](#十一、G1/ZGC/Shenandoah 深入(Q146-Q160))
- [十二、JVM 工具与监控(Q161-Q170)](#十二、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: 堆和栈的区别是什么?
答案:
| 对比项 | 栈(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 位
long和double占 2 个 Slot- 非静态方法的第 0 个 Slot 存储
this引用 - Slot 可复用(超出作用域的变量的 Slot 可被后续变量复用)
操作数栈的底层实现:
- 操作数栈是一个栈结构,字节码指令从操作数栈中弹出操作数、执行运算、将结果压回
- 例如
iadd指令:弹出两个 int,相加,将结果压回
Q7: 方法区中存储什么内容?
答案 :
方法区(JDK 8+ 为元空间)存储:
- 类的元数据:类名、访问修饰符、父类、接口列表
- 方法信息:方法名、参数类型、返回类型、字节码
- 字段信息:字段名、类型、修饰符
- 运行时常量池:字面量、符号引用
- 静态变量:类的静态字段(JDK 7+ 移到堆中)
- JIT 编译后的代码缓存:热点方法编译的机器码(CodeCache)
- 方法表(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)
底层执行过程:
- 检查字符串常量池中是否有 "abc"
- 如果没有,在常量池中创建 "abc" 字符串对象
- 在堆中创建一个新的 String 对象,内容为 "abc"
- 栈中的引用
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 |
| 原因 | 代码缺陷(如静态集合未清理) | 内存太小 / 对象太多 / 泄漏导致 |
| 解决 | 找到并修复引用未释放的代码 | 增大内存 / 修复泄漏 / 优化代码 |
常见内存泄漏场景:
- 静态集合持有对象引用
- 未关闭的资源(数据库连接、IO 流)
- 监听器未注销
- 内部类持有外部类引用
- ThreadLocal 使用后未 remove
Q13: 堆溢出(OOM: Java heap space)的常见原因?
答案:
- 内存泄漏:对象无法被 GC 回收(如静态 Map 持续添加)
- 大对象分配:一次加载超大文件到内存
- 堆设置过小:-Xmx 设置太小
- 频繁 Full GC 但无法释放足够空间
排查步骤:
- 添加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./dump.hprof - 使用 MAT 或 JVisualVM 分析堆转储
- 查看 Dominator Tree 和 Leak Suspects Report
Q14: 栈溢出(StackOverflowError)的常见原因?
答案:
- 递归过深:无限递归或递归层级太深
- 栈帧过大:方法中定义大量局部变量或大数组
- -Xss 设置过小
解决方案:
- 检查递归终止条件
- 将递归改为迭代
- 适当增大 -Xss(但会减少可创建的线程数)
Q15: 元空间溢出(OOM: Metaspace)的常见原因?
答案:
- 动态生成类过多:CGLIB 代理、反射、Groovy 动态脚本
- 类加载器泄漏:Web 容器反复部署/卸载应用
- 大量 JSP 页面:每个 JSP 编译为一个类
底层原因:
- 元空间使用本地内存,由
Metaspace的ClassLoaderDataGraph管理 - 每个类加载器有独立的 Metaspace 分配
- 类加载器未被回收 → 其加载的所有类都无法回收 → 元空间持续增长
Q16: 什么是逃逸分析?
答案 :
逃逸分析是 JIT 编译器的一种优化技术,分析对象的作用域是否逃逸出方法或线程。
逃逸类型:
- 方法逃逸:对象被外部方法引用(如作为参数传递或返回值)
- 线程逃逸:对象被其他线程访问
基于逃逸分析的优化:
- 栈上分配(Stack Allocation):不逃逸的对象直接在栈上分配,方法结束自动释放,不需要 GC
- 标量替换(Scalar Replacement):将对象拆分为基本类型变量,直接在栈上存储
- 同步消除(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 步):
- 类加载检查:检查类是否已加载、解析、初始化。如果没有,先执行类加载
- 分配内存 :
- 指针碰撞(Bump the Pointer):堆内存规整时使用(Serial、ParNew 收集器)
- 空闲列表(Free List):堆内存不规整时使用(CMS 收集器)
- 优先在 TLAB 中分配
- 初始化零值:将分配的内存空间初始化为零值(int=0, boolean=false, 引用=null)
- 设置对象头:设置 Mark Word(哈希码、GC 年龄、锁状态)和类型指针
- 执行
<init>方法:执行构造方法,按照程序员的意愿初始化对象 - 栈上引用指向堆中对象
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 包括:
- 虚拟机栈中引用的对象(局部变量表中的引用)
- 方法区中静态变量引用的对象 (
static Object obj) - 方法区中常量引用的对象 (
static final Object) - 本地方法栈中 JNI 引用的对象
- JVM 内部引用(如 Class 对象、异常对象、系统类加载器)
- 被同步锁(synchronized)持有的对象
底层实现:
- 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: 对象什么时候进入老年代?
答案:
- 年龄达到阈值 :经过
-XX:MaxTenuringThreshold(默认 15)次 Minor GC 后晋升 - 动态年龄判断:Survivor 中相同年龄对象总和 ≥ Survivor 空间 50%,该年龄及以上对象晋升
- 大对象直接分配 :超过
-XX:PretenureSizeThreshold的大对象直接进入老年代 - 空间分配担保:Minor GC 后 Survivor 放不下的对象直接进入老年代
Q25: 什么是空间分配担保?
答案 :
Minor GC 前,JVM 检查老年代的可用空间是否大于新生代所有对象的总空间。
担保流程:
- 如果老年代可用空间 > 新生代所有对象总空间 → 安全执行 Minor GC
- 如果不成立,检查
-XX:+HandlePromotionFailure是否允许担保失败 - 如果允许,检查老年代可用空间 > 历次晋升的平均大小
- 如果大于平均值 → 冒险执行 Minor GC
- 如果小于平均值 → 触发 Full GC
JDK 6 Update 24 之后:规则简化为只要老年代可用空间 > 新生代所有对象总空间,就执行 Minor GC;否则 Full GC。
二、对象创建与内存分配
Q26: 对象在堆中的分配方式有哪些?
答案:
-
指针碰撞(Bump the Pointer):
- 堆内存是规整的(已用和空闲分开)
- 分配时移动指针即可
- 使用 Serial、ParNew 等带有压缩整理功能的收集器时使用
-
空闲列表(Free List):
- 堆内存不规整(已用和空闲交错)
- 维护一个空闲列表,分配时找到足够大的空闲块
- 使用 CMS 等不带压缩功能的收集器时使用
并发安全:
- 多线程同时分配需要同步,使用 CAS + 失败重试
- 优先在 TLAB 中分配(无需加锁)
Q27: 对象的生命周期是怎样的?
答案:
创建(new)→ 使用 → 不可达 → 被标记 → 被回收 → 内存释放
详细流程:
- 创建 :
new关键字 → 类加载 → 分配内存 → 初始化零值 → 设置对象头 → 执行构造方法 - 使用:被引用、调用方法
- 不可达:所有引用被置 null 或离开作用域
- 第一次标记:可达性分析发现不可达
- 第二次标记(finalize) :如果重写了
finalize()且未被调用过,放入 F-Queue - Finalizer 线程执行
finalize():如果在 finalize 中重新建立引用,对象复活 - 第二次标记完成:如果仍然不可达,标记为可回收
- 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: 对象的访问定位方式有哪些?
答案:
-
句柄访问:
- 堆中划分一块"句柄池"
- 引用存储的是句柄地址,句柄中包含对象实例数据指针和类型数据指针
- 优点:引用稳定(对象移动时只需改句柄,不改引用)
- 缺点:两次指针定位,性能较低
-
直接指针访问(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 字节的整数倍。
原因:
- CPU 缓存行对齐:CPU 缓存以缓存行(Cache Line,通常 64 字节)为单位读取,8 字节对齐减少跨缓存行访问
- 指针压缩:压缩指针以 8 字节为偏移单位
- 性能:对齐后内存访问更快
对象大小计算:
对象大小 = 对象头(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: 双亲委派模型的作用是什么?
答案:
- 避免类的重复加载:父加载器已加载的类,子加载器不会重复加载
- 保证核心类的安全 :防止用户自定义
java.lang.String等核心类被篡改 - 保证类的唯一性:同一个类只被同一个类加载器加载一次
为什么核心类不能被篡改:
- 用户定义的
java.lang.String会委派给 Bootstrap ClassLoader - Bootstrap ClassLoader 会加载
rt.jar中的官方 String - 用户的自定义 String 永远不会被加载
Q40: 如何打破双亲委派模型?
答案:
- 重写
loadClass()方法:不委派父加载器,直接自己加载(如 Tomcat) - 线程上下文类加载器(Thread Context ClassLoader):父加载器通过线程上下文加载器委托子加载器加载(如 SPI 机制)
- OSGi 模块化:网状的类加载结构
SPI 机制打破双亲委派的原因:
- JDBC 的 Driver 接口由 Bootstrap ClassLoader 加载(在 rt.jar 中)
- 但 Driver 的实现类在 CLASSPATH 中,需要 Application ClassLoader 加载
- 使用
Thread.currentThread().getContextClassLoader()获取 Application ClassLoader 来加载
Tomcat 打破双亲委派的原因:
- 不同 Web 应用可能依赖不同版本的同一个库
- 需要每个 Web 应用有独立的类加载器,互不影响
- WebAppClassLoader 优先加载自己目录下的类,不委派父加载器
Q41: 什么是类的初始化时机?
答案 :
类的初始化(执行 <clinit>())在以下情况下触发:
主动引用(会触发初始化):
new实例化对象- 读取/设置类的静态字段(final 常量除外)
- 调用类的静态方法
- 反射调用(
Class.forName()) - 初始化子类时,如果父类未初始化,先初始化父类
- JVM 启动时的主类
被动引用(不会触发初始化):
- 子类引用父类的静态字段(只初始化父类)
- 定义类的数组(
MyClass[] arr = new MyClass[10]) - 引用类的常量(
static final,编译期已放入常量池)
Q42: <clinit>() 方法有什么特点?
答案 :
<clinit>() 是编译器自动生成的类构造器方法,用于初始化类变量和静态代码块。
特点:
- 由编译器自动收集所有类变量的赋值动作 和静态代码块合并而成
- JVM 保证
<clinit>()在多线程环境中被正确加锁同步(只会执行一次) - 如果
<clinit>()执行缓慢,会造成死锁(其他线程一直等待) - 父类的
<clinit>()先于子类执行 - 接口的
<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: 什么是类的卸载?什么条件下会卸载?
答案 :
类的卸载是指从方法区(元空间)中移除类的元数据。
卸载条件(三个条件同时满足):
- 该类的所有实例都已被 GC 回收
- 加载该类的类加载器已被 GC 回收
- 该类的
java.lang.Class对象没有被引用
实际场景:
- 由 Bootstrap、Extension、Application ClassLoader 加载的类基本不会被卸载
- 自定义类加载器加载的类可能被卸载(如 OSGi、热部署场景)
Q44: 什么是字节码?Java 如何实现跨平台?
答案 :
字节码(Bytecode)是 Java 源代码编译后的中间代码 ,存储在 .class 文件中。
跨平台原理:
Java 源码 → javac 编译 → .class 字节码 → JVM(各平台不同)→ 机器码
- 字节码是平台无关的,可以在任何 JVM 上运行
- 每个平台有对应的 JVM 实现,负责将字节码转换为该平台的机器码
字节码指令:
- 由一个字节的操作码(opcode)+ 操作数组成
- 共约 200 条指令,如
aload_0、invokevirtual、ireturn等
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: 什么是类的连接阶段中的验证?
答案 :
验证是连接的第一步,确保字节码的安全性和正确性。
四种验证:
- 文件格式验证 :是否以
0xCAFEBABE开头、版本号是否合法、常量池类型是否正确 - 元数据验证:是否有父类、是否继承了 final 类、是否实现了所有抽象方法
- 字节码验证:操作数栈的数据类型是否正确、跳转指令是否合法、类型转换是否安全
- 符号引用验证:符号引用对应的类/方法/字段是否存在、是否有访问权限
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 中的动态代理有哪几种实现方式?
答案:
-
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 的对象越难以消亡
实践:
- 新生代:对象生命周期短,使用标记-复制算法,频繁回收
- 老年代:对象生命周期长,使用标记-清除/整理算法,低频回收
- 新生代中存活的对象经过多次 GC 后晋升到老年代
跨代引用假说:
- 跨代引用相对于同代引用是极少数的
- 使用记忆集(Remembered Set) 解决跨代引用问题
Q54: Minor GC、Major GC、Full GC 的区别?
答案:
| GC 类型 | 回收区域 | 触发条件 | 频率 | 耗时 |
|---|---|---|---|---|
| Minor GC / Young GC | 新生代 | Eden 区满 | 频繁 | 短(毫秒级) |
| Major GC / Old GC | 老年代 | 老年代空间不足 | 较少 | 较长 |
| Full GC | 整个堆 + 方法区 | 多种(见下方) | 最少 | 最长(秒级) |
Full GC 的触发条件:
System.gc()建议 JVM 执行 Full GC- 老年代空间不足
- 方法区(元空间)不足
- 空间分配担保失败
- 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 结束后仍为白色的对象被回收) |
| 灰色 | 已被扫描,但其引用的对象尚未全部扫描 |
| 黑色 | 已被扫描,且其引用的对象全部扫描完成
标记过程:
- 初始:所有对象为白色,GC Roots 为灰色
- 从灰色集合中取出对象,扫描其引用
- 将引用的对象标为灰色,自身标为黑色
- 重复直到灰色集合为空
- 剩余白色对象不可达,可以回收
并发标记的问题:
- 漏标(对象消失):黑色对象新增了对白色对象的引用,但灰色对象删除了对该白色对象的引用
- 解决方案:写屏障 + 增量更新(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)?
答案 :
对象从新生代晋升到老年代的过程。
晋升条件:
- 年龄达到阈值:经过 MaxTenuringThreshold 次 Minor GC 后
- 动态年龄判断:Survivor 中同龄对象 ≥ Survivor 空间 50%
- 大对象直接晋升:超过 PretenureSizeThreshold
- 空间分配担保: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 检查老年代是否有足够空间容纳新生代可能晋升的对象。
流程:
- 检查老年代可用空间 > 新生代所有对象总空间 → 安全
- 检查老年代可用空间 > 历次晋升平均大小 → 冒险执行
- 以上都不满足 → 触发 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. 并发清除 ------ 与用户线程并发执行,清除未标记对象
缺点:
- CPU 敏感:并发阶段占用 CPU 资源
- 内存碎片:使用标记-清除算法,产生碎片
- 浮动垃圾:并发清除阶段新产生的垃圾本次 GC 无法回收
- 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 ,且不随堆大小增长。
核心技术:
-
染色指针(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 的影响)
- 生命周期很短的应用
- 一次性任务
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)实现可控停顿。
原理:
- G1 维护每个 Region 的回收价值(垃圾量 / 回收耗时)
- 根据
-XX:MaxGCPauseMillis的目标停顿时间 - 选择回收价值最高的 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) 操作
- 自愈:将指针的标记位修正为当前正确的值
并发转移的原理:
- 对象被转移后,旧地址的转发指针指向新地址
- 访问旧地址时,读屏障检测到标记位变化
- 自动修正为新地址,访问新位置的对象
Q84: 什么是 GC 的并发标记与用户线程的竞争?
答案 :
并发标记阶段,GC 线程和用户线程同时执行,可能导致:
- 漏标:用户线程修改了引用关系,导致 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: 什么是热点代码?如何检测?
答案 :
热点代码是被频繁执行的代码段。
检测方法:
- 基于采样:定时采样线程的 PC 寄存器,统计哪些方法/循环被频繁执行
- 基于计数器:维护方法调用计数器和回边计数器
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 编译代码被废弃,回退到解释执行的过程。
触发条件:
- 类被卸载:编译的代码依赖的类被 GC 回收
- 类型推断失败:内联的虚方法的类型假设被打破
- 代码缓存不足:CodeCache 满了,需要清理旧代码
- 调试需要:断点调试时需要回到解释执行
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 开发的高性能多语言虚拟机。
核心特性:
- Graal 编译器:新一代 JIT 编译器,优化能力更强
- Native Image:AOT 编译,生成独立可执行文件
- 多语言支持:Java、JavaScript、Python、Ruby、R 等
- 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)
调优步骤:
- 观察 GC 日志,分析 Minor GC 频率和耗时
- 如果 Minor GC 过于频繁 → 增大新生代
- 如果 Minor GC 耗时过长 → 减小新生代
- 如果对象过早晋升 → 增大 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 暂停所有用户线程的时间。
优化方法:
- 选择低延迟收集器:G1、ZGC、Shenandoah
- 调整堆大小:堆太大 → GC 耗时长;堆太小 → 频繁 GC
- 调整新生代大小:新生代大 → Minor GC 耗时长但频率低
- 减少对象分配:优化代码,减少临时对象
- G1 停顿目标 :
-XX:MaxGCPauseMillis=200 - 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: 如何判断堆内存是否设置合理?
答案:
-
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 高的线程:
bashtop -Hp <pid> # 找到 CPU 高的线程 ID printf '%x' <tid> # 转换为 16 进制 jstack <pid> | grep <tid_hex> # 查看线程栈 -
常见原因:
- Full GC 频繁(GC 线程占 CPU)
- 死循环
- 死锁
- 正则表达式回溯
Q108: 如何排查内存飙高问题?
答案:
-
查看堆内存使用:
bashjmap -heap <pid> jstat -gcutil <pid> 1000 -
生成堆转储:
bashjmap -dump:format=b,file=dump.hprof <pid> -
分析堆转储:
- 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: 常见的内存泄漏场景有哪些?
答案:
- 静态集合持有对象 :
static List<Object> list持续添加不清理 - 未关闭的资源:数据库连接、IO 流、Socket
- 监听器未注销:事件监听器、观察者模式
- 内部类持有外部类引用:非静态内部类、匿名内部类
- ThreadLocal 未 remove:线程池中 ThreadLocal 不清理
- 缓存无过期策略:HashMap 做缓存,无淘汰机制
- 自定义类加载器泄漏:类加载器未被 GC 回收
Q112: ThreadLocal 的内存泄漏问题?
答案 :
ThreadLocal 使用 ThreadLocalMap 存储数据,key 是弱引用,value 是强引用。
泄漏原因:
- ThreadLocal 对象被 GC 回收(key 是弱引用)
- 但 value 仍然被 Entry 强引用
- 如果线程是线程池中的核心线程,不会销毁
- value 永远无法被回收
解决方案:
java
try {
threadLocal.set(value);
// 使用 value
} finally {
threadLocal.remove(); // 必须清理!
}
Q113: 如何使用 MAT 分析堆转储?
答案:
- 打开 dump.hprof 文件
- Leak Suspects Report:自动分析可能的泄漏点
- Dominator Tree:查看占用内存最大的对象
- Histogram:查看各类的实例数和内存占用
- GC Roots → Path to GC Roots:找到对象的引用链
- 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:正在运行或等待 CPUBLOCKED:等待获取锁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 频繁的问题?
答案:
- 查看 GC 日志:分析 Full GC 的频率、耗时、前后内存变化
- 分析堆转储:查看哪些对象占用最多内存
- 常见原因 :
- 老年代空间不足 → 增大堆或优化代码
- 内存泄漏 → 修复泄漏
- 元空间不足 → 增大 MetaspaceSize
- System.gc() 调用 → 禁用
-XX:+DisableExplicitGC
- 解决方案 :
- 增大堆内存
- 优化代码减少对象分配
- 选择合适的收集器
- 调整新生代/老年代比例
Q119: 如何排查 OOM 问题?
答案 :
步骤:
- 保留现场:
-XX:+HeapDumpOnOutOfMemoryError - 分析堆转储:
- MAT → Leak Suspects → Dominator Tree
- 找到占用内存最多的对象
- 追踪 GC Roots 引用链
- 定位代码:找到创建/持有这些对象的代码
- 修复:
- 内存泄漏:修复引用未释放的代码
- 内存不足:增大堆 / 优化代码 / 分批处理
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 动态解析:
invokespecial、invokestatic:在类加载的解析阶段静态解析invokevirtual、invokeinterface:在运行时动态解析(多态)
Q129: 什么是操作数栈?
答案 :
操作数栈是栈帧中的工作区,字节码指令在此进行运算。
java
// 计算 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标志
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
三大特性:
- 原子性 :
synchronized和Lock保证 - 可见性 :
volatile和synchronized保证 - 有序性 :
volatile和synchronized保证(禁止指令重排序)
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: 重入计数
工作流程:
- 线程尝试获取锁 → CAS 设置
_owner - 成功 → 进入同步代码
- 失败 → 进入
_EntryList阻塞 - 调用
wait()→ 进入_WaitSet等待 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 触发条件?
答案:
- 老年代占用率达到
-XX:InitiatingHeapOccupancyPercent(默认 45%) - 触发并发标记
- 并发标记完成后,触发 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)几乎完全并发:
- 选择待转移的 Region
- 并发转移:将存活对象复制到新 Region
- 并发重映射:修正所有指向旧地址的引用
关键:
- 使用转发表(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 的并发标记失败会怎样?
答案 :
并发标记失败会导致:
- 退化为单线程的 Full GC(Serial Old)
- 完全 STW,耗时很长
- 原因:并发标记期间堆满了,无法继续
避免:
- 不要让堆使用率过高
- 增大堆或调整
-XX:InitiatingHeapOccupancyPercent
Q159: ZGC 的指针压缩如何工作?
答案 :
ZGC 在 JDK 16+ 支持指针压缩(-XX:+UseCompressedOops):
- 堆 < 4GB 时,指针压缩为 32 位
- 堆 4GB~4TB 时,使用 64 位指针(带染色位)
- 染色位存储在指针的高 4 位
Q160: 什么是 G1 的疏散(Evacuation)?
答案 :
疏散是 G1 将存活对象从一个 Region 复制到另一个 Region 的过程。
过程:
- 选择要回收的 Region
- 将存活对象复制到空闲 Region
- 更新引用(RSet 辅助)
- 回收原 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 分析内存泄漏?
答案:
-
打开
.hprof文件 -
Leak Suspects Report:自动分析泄漏可疑点
-
Dominator Tree:查看对象的内存占用排名
-
Path to GC Roots(排除弱引用/软引用):找到引用链
-
OQL 查询:
sqlSELECT * 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 的启动时间和内存占用
与类加载的关系:
- 每个模块有独立的类加载器命名空间
- 模块之间的访问受
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) |
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 的并发整理通过转发指针实现:
- 并发标记:确定存活对象
- 并发整理:将存活对象复制到新 Region
- 并发引用修正:通过读屏障修正引用
关键:读屏障在访问对象时检查转发指针,自动跳转到新地址。
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 的类数据共享技术,加速应用启动。
原理:
- 首次启动时,将类的元数据转储到共享归档文件(.jsa)
- 后续启动时,直接映射共享归档文件到内存
- 避免重复解析和加载类
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 使用多种压缩技术减少内存占用:
- 指针压缩(Compressed Oops):64 位引用压缩为 32 位
- 类指针压缩(Compressed Class Pointers):类元数据指针压缩
- Klass 压缩:类的 Klass 对象压缩存储
Q182: 什么是 JVM 的字节码验证?
答案 :
字节码验证在类加载的连接阶段进行,确保字节码的安全性。
验证内容:
- 类型安全:操作数栈的数据类型正确
- 访问控制:private/protected 成员不被非法访问
- 初始化安全:对象使用前已初始化
- 分支安全:跳转目标在方法范围内
JDK 7+ 的优化:
StackMapTable属性:编译期生成栈映射表,减少运行时验证- 类型检查验证(Type Checking Verification)替代类型推断验证
Q183: 什么是 JVM 的安全点轮询?
答案 :
安全点轮询是线程检查是否需要暂停的机制。
实现:
- JVM 在安全点位置插入轮询代码
- 轮询代码检查一个全局标志(安全点标志)
- 如果标志被设置,线程暂停
HotSpot 的优化:
- 使用内存保护页实现轮询
- 安全点标志是一个内存页
- 正常情况:线程读取该页面(已映射)
- GC 时:取消该页面的映射 → 线程访问时触发页错误 → 暂停
Q184: 什么是 JVM 的去优化(Deoptimization)?
答案 :
去优化是将 JIT 编译的代码废弃,回退到解释执行。
触发条件:
- 类被卸载:编译代码依赖的类被回收
- 类型推测失败:内联的虚方法假设被打破
- 代码缓存满:需要清理旧代码
- 调试需要:断点调试
去优化的类型:
- 部分去优化:只废弃部分编译代码
- 完全去优化:废弃整个方法的编译代码
Q185: 什么是 JVM 的逃逸分析的局限性?
答案 :
逃逸分析虽然强大,但有局限性:
- 分析开销:逃逸分析本身消耗 CPU
- 保守估计:如果无法确定是否逃逸,保守认为会逃逸
- 不支持跨方法分析:只能分析单个方法内的逃逸
- 依赖 JIT:只有被 JIT 编译的方法才会做逃逸分析
- 不能替代 GC:逃逸分析只能减少短命对象的堆分配
十四、场景实战题
Q186: 线上出现 Full GC 频繁,如何排查?
答案:
- 查看 GC 日志:分析 Full GC 的频率、触发原因、耗时
- 查看堆内存 :
jstat -gcutil <pid>观察老年代使用率 - 生成堆转储 :
jmap -dump:format=b,file=dump.hprof <pid> - 分析堆转储:MAT → Leak Suspects → 找到内存泄漏
- 常见原因 :
- 内存泄漏 → 修复代码
- 堆太小 → 增大 -Xmx
- 新生代太小 → 增大 -Xmn
- 元空间不足 → 增大 MetaspaceSize
- System.gc() 调用 →
-XX:+DisableExplicitGC
Q187: 线上出现 OOM,如何快速定位?
答案:
- 保留现场 :确保启动参数有
-XX:+HeapDumpOnOutOfMemoryError - 获取堆转储 :如果没配置自动 dump,用
jmap -dump:live手动生成 - MAT 分析 :
- Leak Suspects Report → 自动分析泄漏点
- Dominator Tree → 找到占用内存最大的对象
- Path to GC Roots → 追踪引用链
- 定位代码:根据引用链找到创建/持有对象的代码
- 修复:内存泄漏→修复引用;内存不足→增大堆/优化代码
Q188: 线上出现 CPU 飙高,如何排查?
答案:
-
找到 CPU 高的线程:
bashtop -Hp <pid> printf '%x' <tid> jstack <pid> | grep -A 20 <tid_hex> -
常见原因:
- Full GC 频繁 → GC 线程占 CPU
- 死循环 → 线程栈显示在循环代码处
- 正则表达式回溯 → 正则匹配卡住
-
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)
解决方案:
- 按固定顺序获取锁
- 使用
tryLock()设置超时 - 使用
jstack找到死锁的线程和锁,修复代码
Q190: 如何优化一个频繁 GC 的服务?
答案:
- 分析 GC 日志:确定 GC 类型(Minor/Major/Full)、频率、耗时
- 调整堆大小 :
-Xms=-Xmx(避免动态扩展)- 新生代占堆的 1/3~1/2
- 选择合适的收集器 :
- 低延迟:G1(
-XX:MaxGCPauseMillis=200) - 吞吐量:Parallel
- 低延迟:G1(
- 优化代码 :
- 减少临时对象创建
- 使用对象池
- 避免大对象直接进入老年代
- 监控:JFR + Prometheus + Grafana
Q191: 如何排查 Metaspace OOM?
答案:
bash
# 查看元空间使用
jstat -gcmetacapacity <pid>
# 生成堆转储
jmap -dump:format=b,file=dump.hprof <pid>
# MAT 中查看 ClassLoader 和 Class 的实例数
常见原因:
- CGLIB 动态代理生成大量类
- Groovy/JSP 动态编译
- 类加载器泄漏(Web 容器热部署)
解决:
- 增大
-XX:MetaspaceSize和-XX:MaxMetaspaceSize - 排查类加载器泄漏
- 限制动态生成类的数量
Q192: 如何优化大对象的内存使用?
答案:
- 避免一次性加载大文件到内存:使用流式处理
- 使用直接内存:NIO ByteBuffer,避免堆内复制
- 压缩数据:使用压缩算法减少内存占用
- 分块处理:将大对象拆分为多个小对象
- 使用堆外缓存:如 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 问题?
答案:
- 生成堆转储 :
jmap -dump:format=b,file=dump.hprof <pid> - MAT 中查找 ThreadLocalMap :
- OQL:
SELECT * FROM java.lang.ThreadLocal$ThreadLocalMap
- OQL:
- 查看 Entry 的 value :
- 找到没有被 remove 的 ThreadLocal
- 追踪线程:查看哪个线程持有该 ThreadLocalMap
- 修复 :在 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 对服务延迟的影响?
答案:
-
GC 日志分析:
- 记录每次 GC 的 STW 时间
- 统计 P99/P999 GC 停顿时间
-
JFR 分析:
- 记录
jdk.GCHeapSummary事件 - 分析 GC 停顿时间分布
- 记录
-
应用层监控:
- 记录请求的 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 的性能基准测试?
答案:
-
JMH(Java Microbenchmark Harness):
java@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 | 开启 |
常用诊断命令速查
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)。面试时结合线上排查经验,展示从原理到实践的完整能力。