主题
三、JVM(20 题)
3.1 ★★★★JVM 运行时内存区域是如何划分的?每个区域的作用和可能出现的异常是什么?哪些是线程私有,哪些是线程共享?
📖 点击查看答案
JVM 运行时内存区域(JDK 8+):
┌─────────────────────────────────────────┐
│ JVM 内存 │
├──────────────────────┬──────────────────┤
│ 线程私有 │ 线程共享 │
├──────────────────────┼──────────────────┤
│ 程序计数器(PC) │ 堆(Heap) │
│ 虚拟机栈(VM Stack)│ 方法区(Metaspace)│
│ 本地方法栈 │ │
└──────────────────────┴──────────────────┘各区域详情:
| 区域 | 线程 | 作用 | 异常 |
|---|---|---|---|
| 程序计数器 | 私有 | 当前线程执行字节码的行号 | 唯一不会 OOM 的区域 |
| 虚拟机栈 | 私有 | 方法调用的栈帧(局部变量、操作数栈) | StackOverflowError、OOM |
| 本地方法栈 | 私有 | Native 方法调用 | StackOverflowError、OOM |
| 堆 | 共享 | 对象实例、数组 | OOM: Java heap space |
| 方法区(元空间) | 共享 | 类元数据、常量池、静态变量 | OOM: Metaspace |
1. 程序计数器(PC Register):
- 记录当前线程执行的字节码行号
- 线程切换后能恢复到正确位置
- Native 方法时为 undefined
- 唯一不会 OOM 的区域
2. 虚拟机栈:
- 每个方法调用创建一个栈帧
- 栈帧包含:局部变量表、操作数栈、动态链接、方法返回地址
- 线程请求的栈深度超过限制 → StackOverflowError
- 栈动态扩展但无法申请到内存 → OOM
java
void infinite() { infinite(); } // StackOverflowError3. 本地方法栈:
- 服务于 native 方法(JNI)
- HotSpot 把本地方法栈和虚拟机栈合并
4. 堆:
- 存储对象实例和数组
- GC 的主要区域
- 物理上可不连续,逻辑上连续
- 参数:
-Xms(初始)、-Xmx(最大)
5. 方法区(元空间):
- 存储类元信息、常量池、静态变量、JIT 编译后的代码
- JDK 7:永久代(堆的一部分)
- JDK 8+:元空间(Metaspace,本地内存)
- 参数:
-XX:MaxMetaspaceSize
直接内存(非运行时数据区):
- NIO 的 DirectByteBuffer 分配的堆外内存
- 参数:
-XX:MaxDirectMemorySize - 不受 JVM 堆大小限制,但受物理内存限制
运行时常量池:
- 方法区的一部分
- 存储编译期生成的字面量、符号引用
- JDK 7+ 移到堆中
- StringTable(字符串常量池)也在堆中
java
// JDK 8+
String s = new String("a") + new String("b");
// 1. "a"、"b" 在字符串常量池
// 2. new String("a") 在堆
// 3. s.intern() 会把 s 的引用放入常量池(JDK 7+)3.2 ★★堆内存细分结构是怎样的(Eden、Survivor、老年代)?JDK 8 为什么用元空间替代永久代?对象如何晋升到老年代?
📖 点击查看答案
堆内存结构:
┌─────────────────────────────────────────┐
│ 堆 (Heap) │
├──────────────────────┬──────────────────┤
│ 新生代 (1/3) │ 老年代 (2/3) │
├───────┬──────┬───────┤ │
│ Eden │ S0 │ S1 │ │
│ 8/10 │ 1/10 │ 1/10 │ │
└───────┴──────┴───────┴──────────────────┘- 新生代:Eden + 2 个 Survivor(S0、S1),比例 8:1:1
- 老年代:存放长期存活的对象
- 参数:
-XX:NewRatio=2(老:新=2:1)、-XX:SurvivorRatio=8
对象分配流程:
- 新对象优先分配在 Eden
- Eden 满 → Minor GC,存活对象复制到 S0/S1
- 第二次 Minor GC:Eden + S0 存活对象复制到 S1,清空 S0
- 每次GC后存活年龄 +1,达到阈值(默认15)→ 晋升老年代
对象晋升老年代的几种方式:
- 年龄达到阈值:默认 15(
-XX:MaxTenuringThreshold) - 大对象直接进入老年代:超过
-XX:PretenureSizeThreshold - 动态年龄判断:Survivor 中相同年龄所有对象大小超过 Survivor 空间一半,年龄 ≥ 该年龄的对象直接晋升
- Survivor 空间不足:Minor GC 时 Eden + Survivor 存活对象太多放不下,需要老年代担保(空间分配担保)
JDK 8 用元空间替代永久代的原因:
- 永久代大小固定:容易出现 OOM(
java.lang.OutOfMemoryError: PermGen)- 加载类多时(动态生成类、JSP、Groovy)容易爆
- 调优困难:PermSize 和 MaxPermSize 难以预估
- 合并 HotSpot 和 JRockit:JRockit 没有永久代
- 元空间使用本地内存:可用空间大,只受物理内存限制
- GC 优化:元空间的元数据生命周期与类加载器一致,回收更高效
元空间 vs 永久代:
| 维度 | 永久代(JDK 7-) | 元空间(JDK 8+) |
|---|---|---|
| 位置 | JVM 进程内 | 本地内存 |
| 大小 | PermSize/MaxPermSize | MaxMetaspaceSize(默认无限) |
| OOM | PermGen space | Metaspace |
| GC | Full GC | 类卸载时回收 |
字符串常量池的位置变化:
- JDK 6:永久代
- JDK 7:堆(永久代逻辑部分)
- JDK 8+:堆(元空间是方法区,但字符串常量池在堆中)
Minor GC vs Full GC:
- Minor GC:只清理新生代,频繁但快
- Full GC:清理整个堆 + 方法区,慢,应尽量避免
3.3 ★★JVM 的栈帧结构包含哪些部分?局部变量表和操作数栈的关系是什么?
📖 点击查看答案
栈帧结构:
每个方法调用创建一个栈帧,栈帧包含:
- 局部变量表(Local Variable Table)
- 操作数栈(Operand Stack)
- 动态链接(Dynamic Linking)
- 方法返回地址(Return Address)
- 附加信息(与实现相关)
┌─────────────────────┐
│ 方法返回地址 │
├─────────────────────┤
│ 动态链接 │
├─────────────────────┤
│ 操作数栈 │
├─────────────────────┤
│ 局部变量表 │
└─────────────────────┘1. 局部变量表:
- 存储方法参数和方法内定义的局部变量
- 以**变量槽(Slot)**为单位,32 位类型占 1 个槽,64 位(long/double)占 2 个槽
- 实例方法的第 0 个槽是
this引用 - 编译期确定大小
java
public int add(int a, int b) {
int c = a + b;
return c;
}
// 局部变量表:[0]=this, [1]=a, [2]=b, [3]=c2. 操作数栈:
- 方法执行过程中的工作区,类似 CPU 的寄存器
- 字节码指令把数据压入/弹出操作数栈
- 编译期确定最大深度
java
int c = a + b;
// 对应字节码:
// iload_1 // 把局部变量表 slot 1 (a) 压入操作数栈
// iload_2 // 把 slot 2 (b) 压入操作数栈
// iadd // 弹出栈顶两个,相加,结果压栈
// istore_3 // 弹出栈顶存到 slot 3 (c)局部变量表与操作数栈的关系:
- 字节码指令在两者之间搬运数据
- 局部变量表是存储区,操作数栈是计算区
- 执行流程:从局部变量表加载到操作数栈 → 计算 → 结果存回局部变量表
3. 动态链接:
- 栈帧中保存指向运行时常量池中该方法的引用
- 支持动态分派(多态、虚方法调用)
- 通过常量池中的符号引用解析到实际方法
4. 方法返回地址:
- 方法返回时恢复上层方法的执行位置
- 正常返回:调用者的 PC 计数器 + 调用指令长度
- 异常返回:通过异常表查找处理器
栈帧优化:
- 栈帧共享:优化时让调用者和被调用者的操作数栈部分重叠,避免数据复制
- 逃逸分析:让对象在栈上分配,避免堆分配
3.4 ★★★★Java 对象的创建过程是怎样的?对象的内存布局包含哪些部分?对象头中存储了什么信息?
📖 点击查看答案
对象创建过程:
- 类加载检查:遇到 new 指令,检查常量池中类的符号引用是否已加载、解析、初始化,没有则先类加载
- 分配内存:在堆中分配对象所需的内存
- 指针碰撞(Bump the Pointer):内存规整时,移动指针即可
- 空闲列表(Free List):内存不规整时,维护可用块列表
- 初始化零值:将分配的内存初始化为零值(不包括对象头)
- 设置对象头:设置 Mark Word、类型指针、数组长度(数组对象)
- 执行
<init>方法:执行构造器,按程序员的意图初始化
指针碰撞 vs 空闲列表:
| 方式 | 适用 | GC |
|---|---|---|
| 指针碰撞 | 内存规整 | Serial、ParNew(带压缩) |
| 空闲列表 | 内存不规整 | CMS(标记-清除) |
线程安全:
- CAS + 失败重试:保证分配指针更新的原子性
- TLAB(Thread Local Allocation Buffer):每个线程预分配一小块内存,本地分配无锁
java
-XX:+UseTLAB // 默认开启
-XX:TLABSize= // 设置 TLAB 大小对象内存布局(64 位 JVM):
┌────────────────────────────┐
│ 对象头 (Object Header) │
│ ├─ Mark Word (8 字节) │
│ └─ 类型指针 (4/8 字节,压缩后 4)
├────────────────────────────┤
│ 实例数据 (Instance Data) │
├────────────────────────────┤
│ 对齐填充 (Padding) │
└────────────────────────────┘1. 对象头(Header):
Mark Word(8 字节):存储运行时数据
- hashCode(25/31 bit)
- GC 分代年龄(4 bit)
- 锁状态标志位(2 bit)
- 偏向锁线程 ID(54 bit)
- 等等(具体见 2.4)
类型指针(Klass Pointer):指向方法区中类的元数据
- 开启指针压缩(-XX:+UseCompressedOops)4 字节,否则 8 字节
数组长度(仅数组对象):4 字节
2. 实例数据(Instance Data):
- 对象的字段数据
- 字段存储顺序受字段类型、字段顺序、字段重排序优化影响
- 父类字段在子类字段之前
3. 对齐填充(Padding):
- 对象大小必须是 8 字节的整数倍
- 不够则填充
查看对象布局:
java
// 使用 JOL 工具
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
</dependency>
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());对象大小估算:
- new Object():16 字节(8 字节 Mark Word + 4 字节 Klass + 4 字节 padding)
- 包装类 Integer:16 字节
- 一个 int 字段的对象:24 字节(16 + 4 + 4 padding)
3.5 ★★★如何判断一个对象是否可以被回收?引用计数法和可达性分析法有什么区别?哪些对象可作为 GC Roots?
📖 点击查看答案
两种判断方法:
1. 引用计数法:
对象每被引用一次计数 +1,引用失效 -1
计数为 0 时可回收
缺点:无法解决循环引用
java
class A { B b; }
class B { A a; }
A a = new A(); // A.count=1
B b = new B(); // B.count=1
a.b = b; // B.count=2
b.a = a; // A.count=2
a = null; // A.count=1
b = null; // B.count=1
// A 和 B 都不为 0,但实际已不可达 → 内存泄漏- Java 不使用引用计数法
2. 可达性分析法(Java 使用):
- 从 GC Roots 出发,按引用关系遍历
- 不可达的对象被认为是可回收的
GC Roots ──> 对象 A ──> 对象 B
│
└──> 对象 C ←── 对象 D(无 Root 引用 D)
D 不可达,可回收可达性分析流程:
- 找到所有 GC Roots
- 从 GC Roots 开始遍历对象图
- 标记所有可达对象
- 未被标记的对象视为可回收
可作为 GC Roots 的对象:
- 虚拟机栈中引用的对象:方法中的局部变量、参数
- 本地方法栈中 JNI 引用的对象:全局引用、局部引用
- 方法区中类静态变量引用的对象:
static字段 - 方法区中常量引用的对象:
static final常量 - Java 虚拟机内部引用:基本类型对应的 Class 对象、常驻异常对象、系统类加载器
- 被同步锁(synchronized)持有的对象
- JMXBean、JVMTI 中引用的对象(扩展)
- 跨代引用(临时加入 GC Roots,避免漏标)
两次标记过程:
- 第一次标记:不可达对象,判断是否需要执行
finalize()方法- 没有重写 finalize() 或 finalize() 已被调用过 → 直接回收
- 重写了 finalize() → 加入 F-Queue
- 第二次标记:F-Queue 中的对象,finalize() 执行后再次判断可达性
- finalize() 中重新建立引用 → 复活,不回收
- 否则回收
java
public class FinalizeEscape {
static FinalizeEscape SAVE;
@Override
protected void finalize() throws Throwable {
SAVE = this; // 复活自己
}
}- finalize() 不推荐使用(JDK 9 已废弃),用 try-with-resources 或 Cleaner 替代
引用计数 vs 可达性分析:
| 维度 | 引用计数法 | 可达性分析法 |
|---|---|---|
| 实现 | 简单 | 复杂(需要遍历对象图) |
| 循环引用 | 无法处理 | 能处理 |
| 性能 | 增减计数有开销 | STW 时遍历 |
| 实时性 | 实时(计数为 0 即回收) | 周期性 GC |
| 使用 | Python、Objective-C | Java、C#、Go |
3.6 ★★★★强引用、软引用、弱引用、虚引用的区别是什么?各自的使用场景是什么?
📖 点击查看答案
四种引用类型(JDK 1.2 引入):
| 类型 | 级别 | 回收时机 | 应用场景 |
|---|---|---|---|
| 强引用(Strong) | 最强 | 永不回收(除非置 null) | 普通对象 |
| 软引用(Soft) | 次之 | 内存不足时回收 | 内存敏感缓存 |
| 弱引用(Weak) | 较弱 | 下次 GC 就回收 | 缓存、ThreadLocalMap |
| 虚引用(Phantom) | 最弱 | 随时回收,形同虚设 | 跟踪对象被回收的时机 |
1. 强引用(StrongReference):
- 默认的引用类型,
Object obj = new Object() - 只要强引用还在,GC 永不回收
- 即使 OOM 也不回收强引用对象
java
Object obj = new Object();
obj = null; // 断开强引用,对象可被回收2. 软引用(SoftReference):
- 内存不足时才回收
- 适合内存敏感的缓存
java
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = cache.get();
if (data == null) {
data = loadFromDisk();
cache = new SoftReference<>(data);
}- 应用:网页缓存、图片缓存、字节码缓存
- JVM 参数
-XX:SoftRefLRUPolicyMSPerMB控制回收策略
3. 弱引用(WeakReference):
- 下次 GC 就回收(无论内存是否充足)
- 适合生命周期短的对象或键值对的 key
java
WeakReference<Object> weakRef = new WeakReference<>(new Object());
System.gc();
weakRef.get(); // 通常为 null- 经典应用:
WeakHashMap- key 是弱引用,key 没有强引用后,entry 会被回收
- 适合做"附加属性"映射(如对象 → 元数据),不阻止对象被 GC
java
Map<Object, Metadata> map = new WeakHashMap<>();
Object key = new Object();
map.put(key, metadata);
key = null;
System.gc();
// map 中的 entry 会被回收- ThreadLocalMap 的 key 也是弱引用(参见 2.20)
4. 虚引用(PhantomReference):
- 形同虚设,
get()永远返回 null - 唯一作用:对象被回收时收到通知(通过 ReferenceQueue)
- 必须配合 ReferenceQueue 使用
java
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), queue);
phantom.get(); // 永远 null
// 监听队列
new Thread(() -> {
while (true) {
Reference<?> ref = queue.remove();
System.out.println("对象被回收: " + ref);
}
}).start();- 应用:
- 替代 finalize() 做资源清理(NIO DirectByteBuffer 的 Cleaner 机制)
- 监控对象生命周期
引用队列(ReferenceQueue):
- 软/弱/虚引用被回收后,引用对象本身会被加入 ReferenceQueue
- 可用于清理工作
java
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
SoftReference<byte[]> ref = new SoftReference<>(new byte[1024], queue);
// 当 byte[] 被回收时,ref 会被加入 queue3.7 ★★大对象直接进入老年代的机制是什么?什么是"大对象",参数如何配置?
📖 点击查看答案
大对象的定义:
- 需要连续内存空间的"巨型对象",如长数组、长字符串
- 没有明确的大小阈值,取决于 JVM 参数和内存配置
大对象直接进入老年代的原因:
- 新生代 Eden 区空间有限,大对象会触发频繁 Minor GC
- 新生代采用复制算法,大对象在 Survivor 之间复制开销大
- 直接放入老年代避免复制开销
配置参数:
-XX:PretenureSizeThreshold:超过该大小的对象直接进入老年代
bash
-XX:PretenureSizeThreshold=1048576 # 1MB- 只对 Serial 和 ParNew 收集器有效,G1 有自己的大对象处理机制
G1 中的大对象:
- G1 中超过 Region 大小一半的对象(默认 Region 1-32MB)被认为是 Humongous 对象
- Humongous 对象存放在专门的 Humongous Region 中
- JDK 8u40 之后,G1 可以在 mixed GC 中回收 Humongous 对象
bash
# G1 Region 大小
-XX:G1HeapRegionSize=16m
# 大于 8MB 的对象就是 Humongous其他影响对象分配的因素:
-XX:MaxTenuringThreshold:对象晋升老年代的年龄阈值(默认 15,CMS 6)-XX:TargetSurvivorRatio:Survivor 目标使用率(默认 50%)- 空间分配担保:Minor GC 前检查老年代连续空间是否大于新生代所有对象总空间
空间分配担保:
bash
-XX:-HandlePromotionFailure # JDK 6 update 24 之后默认开启- Minor GC 前检查:老年代最大可用连续空间是否 > 新生代所有对象总空间
- 不大于:检查是否允许担保失败
- 允许:继续 Minor GC(有风险)
- 不允许:触发 Full GC
动态对象年龄判断:
- Survivor 中相同年龄所有对象大小总和超过 Survivor 空间一半
- 年龄 ≥ 该年龄的对象直接进入老年代
大对象的危害:
- 提前进入老年代,加速 Full GC
- 可能触发"提前晋升"(Eden 不够分配)
- 碎片化问题(标记-清除算法)
优化建议:
- 避免在循环中创建大对象
- 大文件处理用流式读取而非全量加载
- 调整新生代大小(
-Xmn)让对象有足够空间
3.8 ★★★★常见的垃圾回收算法有哪些?各自的优缺点和适用场景是什么?
📖 点击查看答案
四种基础垃圾回收算法:
1. 标记-清除(Mark-Sweep):
- 过程:① 从 GC Roots 遍历,标记存活对象 ② 清除未标记对象
- 优点:实现简单
- 缺点:
- 产生内存碎片
- 效率不稳定(对象多时标记清除都慢)
- 适用:CMS 老年代
标记前: [A][B][C][D][E] A、C 存活
标记后: [A][_][C][_][_] 清除 B、D、E2. 标记-复制(Copying):
- 过程:将内存分两块,每次只用一块;GC 时把存活对象复制到另一块,清空当前块
- 优点:
- 无碎片
- 分配快(指针碰撞)
- 缺点:
- 内存利用率低(一半)
- 存活对象多时复制开销大
- 适用:新生代(Eden + Survivor,复制比例 8:1:1,利用率 90%)
From: [A][B][C][D][E] A、C 存活
To: [A][C][_][_][_] From 整块清空3. 标记-整理(Mark-Compact):
- 过程:① 标记存活对象 ② 把存活对象向一端移动 ③ 清理边界外的内存
- 优点:
- 无碎片
- 内存利用率高
- 缺点:
- 移动对象开销大
- 移动时需要更新引用(STW)
- 适用:老年代(Serial Old、Parallel Old、G1)
标记前: [A][B][C][D][E] A、C 存活
整理后: [A][C][_][_][_] 对象移动到一端4. 分代收集(Generational):
- 思想:不同生命周期的对象用不同算法
- 新生代:存活率低 → 复制算法
- 老年代:存活率高 → 标记-清除或标记-整理
新生代 vs 老年代对象特点:
| 区域 | 特点 | 算法 |
|---|---|---|
| 新生代 | 朝生夕死,存活少 | 复制 |
| 老年代 | 长期存活,存活多 | 标记-清除 / 标记-整理 |
算法对比:
| 算法 | 碎片 | 内存利用率 | 性能 | 移动对象 |
|---|---|---|---|---|
| 标记-清除 | 有 | 高 | 中 | 否 |
| 复制 | 无 | 低(50%/90%) | 高(存活少时) | 是 |
| 标记-整理 | 无 | 高 | 低(移动多) | 是 |
特殊算法:
- 增量收集:把一次 GC 拆分成多次,减少 STW(如 G1)
- 并发收集:GC 线程与用户线程并发执行(如 CMS、G1、ZGC)
3.9 ★★Minor GC、Major GC、Full GC 的区别与触发条件是什么?
📖 点击查看答案
三种 GC 的定义:
| GC 类型 | 回收区域 | 说明 |
|---|---|---|
| Minor GC(Young GC) | 新生代 | 回收 Eden + Survivor |
| Major GC | 老年代 | 通常与 Full GC 等价(定义模糊) |
| Full GC | 整个堆 + 方法区 | 回收新生代 + 老年代 + 元空间 |
Minor GC 触发条件:
- Eden 区空间不足(新对象分配失败)
- 注意:Survivor 不足不会触发 Minor GC,而是直接晋升老年代
Minor GC 特点:
- 频繁但快(几 ms ~ 几十 ms)
- 采用复制算法
- 会引发 STW(Stop The World)
Full GC 触发条件:
老年代空间不足
- 大对象直接进入老年代但空间不够
- 晋升到老年代的对象大于老年代剩余空间
方法区(元空间)空间不足
- 加载类过多,Metaspace 达到 MaxMetaspaceSize
System.gc()显式调用- 建议 JVM 进行 Full GC,但不一定立即执行
- 可用
-XX:+DisableExplicitGC禁用
空间分配担保失败
- Minor GC 前检查老年代可用空间是否够容纳新生代所有对象
- 不够且不允许担保失败 → Full GC
CMS 并发模式失败(Concurrent Mode Failure)
- CMS GC 期间老年代空间不足,退化为 Serial Old(STW 长)
GC 活动判断:CMS 的 Concurrent Mark 阶段发现新生代晋升速度过快
Full GC 特点:
- 慢(几百 ms ~ 几秒)
- STW 时间长
- 应尽量避免
Major GC:
- 严格意义上指老年代 GC
- CMS 的 Concurrent Mark & Sweep 可视为 Major GC
- 但很多场合 Major GC ≈ Full GC
各种 GC 的 STW 时间(量级):
- Minor GC:~10 ms
- CMS Old GC:~100 ms(多次短停顿)
- Full GC:~1 s 或更长
如何减少 Full GC:
- 合理设置新生代和老年代比例(避免大对象直接进老年代)
- 设置合理的晋升阈值
- 避免内存泄漏(导致老年代被占满)
- 避免显式
System.gc() - 调大 MetaspaceSize(避免频繁 Full GC 回收元空间)
- 选择合适的 GC 收集器(G1、ZGC)
监控 GC:
bash
# 查看 GC 日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:gc.log
# JDK 9+ 统一日志
-Xlog:gc*=info:file=gc.log:time,uptime,level,tags3.10 ★★★★CMS 垃圾收集器的工作流程是怎样的?G1 收集器的核心思想和 Region 划分是怎样的?
📖 点击查看答案
CMS(Concurrent Mark Sweep):
- 目标:低停顿的垃圾收集器
- 老年代收集器,配合 Serial/ParNew 处理新生代
- 算法:标记-清除(有碎片)
CMS 工作流程(4 个阶段):
初始标记(Initial Mark):STW
- 标记 GC Roots 直接引用的对象
- 速度很快
并发标记(Concurrent Mark)
- 与用户线程并发执行
- 从 GC Roots 遍历整个对象图
- 耗时长但不 STW
重新标记(Remark):STW
- 修正并发标记期间用户线程运行导致标记变化的对象
- 使用增量更新或 Card Table
- 比初始标记长,但远短于并发标记
并发清除(Concurrent Sweep)
- 与用户线程并发
- 清除未标记对象
CMS 的优缺点:
- 优点:并发收集,低停顿
- 缺点:
- CPU 敏感:并发占用 CPU,降低吞吐量
- 浮动垃圾:并发清除阶段产生的新垃圾本次无法回收
- 内存碎片:标记-清除算法
- Concurrent Mode Failure:老年代不足时退化为 Serial Old,STW 长
G1(Garbage First):
- JDK 9+ 默认收集器
- 目标:兼顾吞吐量和低停顿
- 算法:标记-整理 + 复制(局部)
G1 的 Region 划分:
┌──┬──┬──┬──┬──┬──┬──┬──┐
│E │S │O │O │H │H │O │E │ 每个 Region 1-32MB
├──┼──┼──┼──┼──┼──┼──┼──┤
│O │O │E │H │O │S │O │O │ E=Eden, S=Survivor, O=Old, H=Humongous
└──┴──┴──┴──┴──┴──┴──┴──┘- 堆被划分为多个大小相等的 Region(1-32MB)
- 每个 Region 可以动态地扮演 Eden、Survivor、Old、Humongous 角色
- 大对象(> Region 一半)进入 Humongous Region
G1 核心思想:
- 分代逻辑化:Region 不再是物理连续的新生代/老年代,而是逻辑划分
- 可预测停顿:用户设置期望停顿时间(
-XX:MaxGCPauseMillis),G1 选择回收价值最高的 Region - Garbage First:优先回收垃圾最多的 Region
G1 工作流程:
- 初始标记(Initial Mark):STW,搭便车一次 Minor GC
- 并发标记(Concurrent Mark):并发,发现垃圾
- 最终标记(Final Marking / Remark):STW,处理并发标记期间的变更
- 筛选回收(Live Data Counting and Evacuation):STW
- 按 GC 收益排序 Region
- 回收垃圾最多的 Region(不全部回收)
- 复制存活对象到空 Region
G1 vs CMS:
| 维度 | CMS | G1 |
|---|---|---|
| 算法 | 标记-清除 | 标记-整理 + 复制 |
| 碎片 | 有 | 无 |
| 停顿可控 | 否 | 是(MaxGCPauseMillis) |
| 适用堆 | 小(<8G) | 大(8G+) |
| 全堆回收 | 否 | 是(Mixed GC) |
| 默认 | JDK 5-8 | JDK 9+ |
G1 的 Mixed GC:
- 选择部分新生代 + 部分老年代 Region 回收
- 老年代回收通过回收部分 Region 实现,避免 Full GC
3.11 ★★★★ZGC 垃圾收集器的目标是什么?染色指针技术是什么?与 G1 相比有什么优势?
📖 点击查看答案
ZGC(Z Garbage Collector):
- JDK 11 引入(实验性),JDK 15 正式生产可用
- 目标:支持 TB 级堆,停顿时间 < 10ms(JDK 16 后 < 1ms)
- 算法:标记-复制 + 整理
ZGC 的核心目标:
- 停顿时间 < 10ms,且不随堆大小增加而增加
- 支持 TB 级堆(4TB / 16TB)
- 吞吐量下降 < 15%
- 初始堆和最大堆可以相差很大
染色指针技术(Colored Pointer):
- 64 位指针中,ZGC 借用高位存储 GC 信息
64 位指针:
[未使用 16 位][Finalizable 1][Remapped 1][Marked1 1][Marked0 1][对象地址 42 位]利用 4 个 bit 存储 4 种状态:
- Marked0、Marked1:标记状态(交替使用)
- Remapped:地址是否转移
- Finalizable:是否通过 finalize 引用
通过虚拟内存映射,不同状态下同一指针指向不同的物理地址(多视图映射)
染色指针的优势:
- GC 信息直接在指针上:不需要额外内存存标记位
- 可并发移动对象:移动对象时,染色指针记录状态,不需要修改所有引用
- 降低内存开销:相比 Card Table、Mark Bitmap 更省内存
读屏障(Load Barrier):
- ZGC 在对象读取时插入读屏障
- 修复过期指针(指向已移动的对象)
- 让应用线程协助 GC 工作
c
// 伪代码
Object obj = field;
if (is_colored_pointer(obj)) {
obj = fix_pointer(obj); // 修复指针
field = obj;
}ZGC 工作流程:
- 并发标记(Concurrent Mark):从 GC Roots 标记,染色指针
- 并发转移(Concurrent Relocate):选择 Region,复制存活对象
- 并发重定位(Concurrent Remap):修复引用
- 几乎全程并发,STW 只在几个安全点(每次约 1ms)
ZGC vs G1:
| 维度 | G1 | ZGC |
|---|---|---|
| 停顿时间 | 100-200ms | <10ms(目标) |
| 堆大小 | 8-32GB | TB 级 |
| 算法 | 分代 + Region | Region + 染色指针 |
| 并发性 | 部分并发 | 几乎全并发 |
| 碎片 | 少 | 无(整理) |
| 内存开销 | 中等 | 高(染色指针 + 读屏障) |
| JDK 版本 | 9+(默认) | 11+(实验)/ 15+(生产) |
ZGC 的演进:
- JDK 11:实验性,单代
- JDK 13:最大堆 16TB
- JDK 15:生产可用
- JDK 16:分代 ZGC(实验)
- JDK 21:分代 ZGC 正式可用
分代 ZGC(JDK 21):
- 引入分代设计(新生代、老年代)
- 显著降低 CPU 开销
- 进一步降低停顿时间
ZGC 适用场景:
- 大堆(32GB+)
- 低延迟敏感(交易、游戏)
- 容器化部署
- 微服务低延迟要求
启用 ZGC:
bash
-XX:+UseZGC
-XX:+ZGenerational # JDK 21+ 启用分代3.12 ★★★★类加载的完整过程是怎样的?双亲委派模型的原理是什么?为什么要打破双亲委派?
📖 点击查看答案
类加载的完整过程(7 个阶段):
加载 → 验证 → 准备 → 解析 → 初始化
↑_____ 链接 _____↑加载(Loading):
- 通过类的全限定名获取定义此类的二进制字节流
- 将字节流转化为方法区的运行时数据结构
- 在堆中生成 Class 对象
验证(Verification):
- 文件格式验证、元数据验证、字节码验证、符号引用验证
- 确保字节码安全、合法
准备(Preparation):
- 为静态变量分配内存并赋零值(不是代码中的初始值)
static int x = 10此阶段 x = 0static final int x = 10(常量)此阶段 x = 10
解析(Resolution):
- 将常量池中的符号引用替换为直接引用
- 符号引用:字符串形式的引用
- 直接引用:内存地址/偏移量
初始化(Initialization):
- 执行类构造器
<clinit>方法 <clinit>= 静态变量赋值 + static 块- JVM 保证
<clinit>线程安全(同步)
- 执行类构造器
类加载时机:
- new、getstatic、putstatic、invokestatic 指令
- 反射调用(Class.forName)
- 初始化子类时父类先初始化
- JVM 启动时的主类
- JDK 7+ 的 MethodHandle 句柄对应的类
双亲委派模型:
Bootstrap ClassLoader(rt.jar)
↑
Extension ClassLoader(ext/*.jar)
↑
Application ClassLoader(classpath)
↑
自定义 ClassLoader- 加载类时先委派父加载器加载,父加载器无法加载时才自己加载
java
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
c = parent.loadClass(name, false); // 先委派父
} catch (ClassNotFoundException e) {
c = findClass(name); // 父加载失败,自己加载
}
}
if (resolve) resolveClass(c);
return c;
}
}双亲委派的好处:
- 避免重复加载:父加载器加载过的类,子加载器不需要再加载
- 安全性:核心类(如 java.lang.Object)只能由 Bootstrap 加载,防止用户伪造
- 层次清晰:各级加载器职责明确
为什么要打破双亲委派:
- SPI 机制:JDBC、JNDI 等
- 接口在 rt.jar(Bootstrap 加载)
- 实现在 classpath(Application 加载)
- Bootstrap 看不到子加载器的类
- 解决:用线程上下文类加载器(TCCL)
java
// ServiceLoader 加载 JDBC Driver
ClassLoader cl = Thread.currentThread().getContextClassLoader();
ServiceLoader<Driver> loaders = ServiceLoader.load(Driver.class, cl);Tomcat 等 Web 容器:
- 每个 Web 应用独立的 ClassLoader,实现应用隔离
- Tomcat 的 WebappClassLoader 优先自己加载(违反双亲委派)
热部署:
- 修改类后重新加载,需要新的 ClassLoader
- 一个 ClassLoader 加载的类不能重新加载
OSGi:模块化,每个模块独立 ClassLoader,网状结构
打破双亲委派的实现:
- 重写
loadClass方法(破坏双亲委派逻辑) - 或重写
findClass方法(保留双亲委派但自定义加载位置)
java
class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] data = loadClassData(name);
return defineClass(name, data, 0, data.length);
}
}3.13 ★★如何自定义类加载器?自定义类加载器时需要注意哪些问题?
📖 点击查看答案
自定义类加载器步骤:
- 继承
ClassLoader - 重写
findClass方法(推荐)或loadClass方法(破坏双亲委派) - 通过
defineClass将字节数组转为 Class 对象
示例:从文件加载类
java
public class FileClassLoader extends ClassLoader {
private String basePath;
public FileClassLoader(String basePath, ClassLoader parent) {
super(parent);
this.basePath = basePath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] data = loadClassData(name);
if (data == null) throw new ClassNotFoundException(name);
return defineClass(name, data, 0, data.length);
}
private byte[] loadClassData(String name) {
String path = basePath + File.separator + name.replace('.', File.separatorChar) + ".class";
try (InputStream in = new FileInputStream(path);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = in.read(buffer)) != -1) out.write(buffer, 0, len);
return out.toByteArray();
} catch (IOException e) {
return null;
}
}
}使用示例:
java
ClassLoader cl = new FileClassLoader("/path/to/classes", getClass().getClassLoader());
Class<?> clazz = cl.loadClass("com.example.MyClass");
Object obj = clazz.newInstance();自定义类加载器的常见应用:
- 类隔离:Tomcat 每个 Web 应用独立 ClassLoader
- 热部署:JRebel、Spring Boot DevTools
- 加密解密:对字节码加密,加载时解密
- 从非标准来源加载:网络、数据库、内存
- 模块化:OSGi
注意事项:
不要轻易重写 loadClass
- 重写 loadClass 会破坏双亲委派
- 通常重写 findClass 即可
保证父加载器优先:
- 避免核心类(java.*)被自定义加载器加载
- 否则可能引发 SecurityException
类唯一性由 ClassLoader + 类全限定名决定
- 同一个类被两个 ClassLoader 加载,得到两个不同的 Class 对象
instanceof检查会失败
java
ClassLoader cl1 = new MyClassLoader();
ClassLoader cl2 = new MyClassLoader();
Class<?> c1 = cl1.loadClass("com.Foo");
Class<?> c2 = cl2.loadClass("com.Foo");
System.out.println(c1 == c2); // false避免内存泄漏:
- 类加载器加载的类会持有加载器引用
- 类加载器无法被 GC,会泄漏
- Tomcat 重部署时要确保旧 ClassLoader 被清理
正确处理 parent:
- 构造时传入 parent ClassLoader
- 否则默认 parent 是 AppClassLoader
defineClass 限制:
- 包名以 java. 开头的不能由自定义加载器加载
- 否则抛 SecurityException
线程上下文类加载器:
- 跨加载器调用时使用 TCCL
Thread.currentThread().setContextClassLoader(cl)
3.14 ★★★JIT 即时编译的原理是什么?C1 和 C2 编译器有什么区别?分层编译机制是什么?
📖 点击查看答案
JIT(Just-In-Time)即时编译:
- JVM 把热点代码(频繁执行的字节码)编译成本地机器码,提升执行速度
- 与解释器配合:解释器先执行,JIT 后台编译
JIT 的工作流程:
- JVM 启动时,代码由解释器逐条解释执行(启动快)
- 热点探测:判断哪些方法是热点代码
- 基于计数器:方法调用计数器、回边计数器
- 阈值默认 10000(
-XX:CompileThreshold)
- 触发 JIT 编译,把方法编译成机器码
- 后续调用直接执行机器码
热点探测方法:
- 基于采样的热点探测:周期性检查线程栈顶方法(实现简单,但不精准)
- 基于计数的热点探测:每个方法有计数器,达到阈值编译(HotSpot 使用)
C1 编译器(Client Compiler):
- 关注快速编译
- 优化较少(局部优化、简单内联)
- 编译快,启动快
- 适合桌面应用、短生命周期应用
C2 编译器(Server Compiler):
- 关注极致性能
- 大量优化:
- 逃逸分析
- 标量替换
- 锁消除
- 循环展开
- 死代码消除
- 编译慢,运行快
- 适合服务端长期运行的应用
C1 vs C2:
| 维度 | C1 | C2 |
|---|---|---|
| 编译速度 | 快 | 慢 |
| 优化程度 | 浅 | 深 |
| 启动时间 | 短 | 长 |
| 峰值性能 | 一般 | 高 |
| 适用 | 客户端、启动敏感 | 服务端、长期运行 |
分层编译(Tiered Compilation,JDK 8 默认开启):
- 结合 C1 和 C2 的优点
- 5 个编译层级:
| 层级 | 说明 |
|---|---|
| 0 | 解释执行 |
| 1 | C1 编译,无 profiling |
| 2 | C1 编译,带方法调用和回边计数 |
| 3 | C1 编译,带完整 profiling |
| 4 | C2 编译 |
- 流程:
- 解释执行(0层)
- 触发 C1 编译,带 profiling(3层)
- 收集足够的 profile 数据后,触发 C2 编译(4层)
- 如果 C2 编译忙或失败,继续用 C1(2层)
bash
-XX:+TieredCompilation # JDK 8 默认开启JDK 10+ 的 Graal 编译器:
- 用 Java 写的 C2 替代品
- 性能更激进,支持更多优化
- JDK 10+ 实验性,需要
-XX:+UseGraal
AOT 编译(Ahead-Of-Time):
- JDK 9 引入 jaotc,提前编译成本地代码
- 启动快,但失去部分优化(如基于 profile 的优化)
- JDK 17+ Spring Native、GraalVM Native Image 推广
查看 JIT 编译:
bash
-XX:+PrintCompilation # 打印编译日志
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining # 查看内联3.15 ★★★逃逸分析的原理是什么?它能带来哪些优化(栈上分配、标量替换、锁消除)?
📖 点击查看答案
逃逸分析(Escape Analysis):
- 分析对象的动态作用域,判断对象是否"逃逸"出方法或线程
- 优化基础:不逃逸的对象可以更高效地处理
三种逃逸级别:
- 不逃逸(NoEscape):对象仅在方法内使用,不会被外部访问
- 方法逃逸(MethodEscape):对象被方法外的代码引用(作为返回值、赋值给全局变量)
- 线程逃逸(ThreadEscape):对象被其他线程访问(赋值给共享变量、作为参数传给其他线程)
java
public User escape() {
User u = new User(); // 方法逃逸:作为返回值
return u;
}
public void noEscape() {
User u = new User(); // 不逃逸:仅在方法内使用
u.setName("a");
System.out.println(u.getName());
}逃逸分析带来的优化:
1. 栈上分配(Stack Allocation):
- 不逃逸的对象直接在栈上分配
- 方法结束自动回收,无需 GC
- 减少 GC 压力
java
public void method() {
User u = new User(); // 不逃逸 → 栈上分配
// ...
} // 方法结束,u 自动释放- HotSpot 没有真正的栈上分配,而是通过标量替换实现等价效果
2. 标量替换(Scalar Replacement):
- 标量:不可再分解的基本类型(int、long、引用)
- 聚合量:可分解的对象
- 标量替换:把不逃逸的对象拆解为多个标量,分散到局部变量表/寄存器
java
public void method() {
Point p = new Point(1, 2);
int sum = p.x + p.y;
}
// 标量替换后:
public void method() {
int x = 1; // 直接拆解
int y = 2;
int sum = x + y;
}
// 不创建 Point 对象,直接用标量- 这是 HotSpot 实际实现的"栈上分配"
3. 锁消除(Lock Elision / Synchronization Elimination):
- 同步代码块中的对象如果不逃逸(无竞争),JIT 消除锁
java
public void method() {
StringBuffer sb = new StringBuffer();
sb.append("a").append("b"); // StringBuffer 的 append 是 synchronized
}
// 锁消除后:synchronized 被去掉- 同步代码块在单线程下会被 JIT 消除
- 前提是同步对象不逃逸到其他线程
4. 锁粗化(Lock Coarsening):
- 把多个相邻的同步块合并为一个
java
// 优化前
synchronized (obj) { method1(); }
synchronized (obj) { method2(); }
synchronized (obj) { method3(); }
// 锁粗化后
synchronized (obj) { method1(); method2(); method3(); }开启逃逸分析:
bash
-XX:+DoEscapeAnalysis # JDK 8 默认开启
-XX:+EliminateAllocations # 标量替换
-XX:+EliminateLocks # 锁消除
-XX:+PrintEscapeAnalysis # 查看逃逸分析结果逃逸分析的局限:
- 不是所有对象都能逃逸分析
- 分析本身有开销
- 复杂控制流可能让分析失败
验证逃逸分析的效果:
java
public void test() {
for (int i = 0; i < 1000000; i++) {
Object obj = new Object(); // 不逃逸
}
}
// 开启逃逸分析:不会创建 100 万个对象(标量替换)
// 关闭逃逸分析:会创建 100 万个对象,GC 频繁3.16 ★★★★JVM 常见的调优参数有哪些?如何选择合适的 GC 收集器?
📖 点击查看答案
常见 JVM 参数分类:
1. 堆内存参数:
| 参数 | 说明 |
|---|---|
-Xms | 初始堆大小(建议等于 -Xmx,避免动态扩展) |
-Xmx | 最大堆大小 |
-Xmn | 新生代大小 |
-Xss | 线程栈大小(默认 512KB-1MB) |
-XX:NewRatio | 老年代:新生代比例(默认 2) |
-XX:SurvivorRatio | Eden:Survivor 比例(默认 8) |
-XX:MetaspaceSize | 元空间初始大小 |
-XX:MaxMetaspaceSize | 元空间最大大小 |
-XX:MaxDirectMemorySize | 直接内存最大值 |
2. GC 收集器选择:
| 参数 | 说明 |
|---|---|
-XX:+UseSerialGC | Serial + Serial Old |
-XX:+UseParNewGC | ParNew + CMS(已废弃) |
-XX:+UseParallelGC | Parallel Scavenge + Parallel Old(JDK 8 默认) |
-XX:+UseConcMarkSweepGC | ParNew + CMS |
-XX:+UseG1GC | G1(JDK 9+ 默认) |
-XX:+UseZGC | ZGC |
-XX:+UseShenandoahGC | Shenandoah |
3. G1 调优:
bash
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 期望停顿时间
-XX:G1HeapRegionSize=16m # Region 大小
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的阈值
-XX:G1NewSizePercent=5 # 新生代最小比例
-XX:G1MaxNewSizePercent=60 # 新生代最大比例4. GC 日志:
bash
# JDK 8
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/gc.log
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=10M
# JDK 9+
-Xlog:gc*=info:file=/path/gc.log:time,uptime,level,tags:filecount=10,filesize=10M5. 故障诊断:
bash
-XX:+HeapDumpOnOutOfMemoryError # OOM 时自动 dump 堆
-XX:HeapDumpPath=/path/dump.hprof
-XX:ErrorFile=/path/hs_err_%p.log # JVM 致命错误日志
-XX:OnOutOfMemoryError="kill -9 %p" # OOM 时执行命令6. JIT 相关:
bash
-XX:+TieredCompilation # 分层编译(JDK 8 默认开)
-XX:CompileThreshold=10000 # JIT 编译阈值
-XX:+PrintCompilation # 打印 JIT 日志GC 收集器选择策略:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 单核、小内存 | Serial | 资源占用小 |
| 多核、吞吐优先 | Parallel | 多线程并行 |
| 多核、低延迟 | G1 | 可预测停顿 |
| 大堆、超低延迟 | ZGC | <10ms 停顿 |
| 老系统兼容 | CMS(已废弃) | - |
选择原则:
- 优先用默认:JDK 8 默认 Parallel,JDK 9+ 默认 G1
- 吞吐型:Parallel Scavenge + Parallel Old
- 响应型:G1(推荐)/ ZGC(超大堆)
- 超大堆:ZGC(4GB+)/ Shenandoah
调优流程:
- 监控 GC 日志、内存使用
- 分析瓶颈:频繁 GC?Full GC?OOM?
- 调整参数:堆大小、GC 收集器、比例
- 压测验证
- 持续监控
常用监控工具:
- jstat:GC 统计
- jmap:堆信息、dump
- jstack:线程堆栈
- jconsole / VisualVM / JMC:图形化
- Arthas:在线诊断
- Prometheus + Grafana:长期监控
3.17 ★★★什么是 OOM?常见 OOM 类型及其原因?如何排查?
📖 点击查看答案
OOM(OutOfMemoryError):
- JVM 无法分配对象时抛出
- 通常由于内存泄漏、配置不当或资源耗尽
常见 OOM 类型:
1. java.lang.OutOfMemoryError: Java heap space
- 堆内存不足,无法分配对象
- 原因:
- 内存泄漏(对象无法回收)
- 堆设置过小(
-Xmx) - 大对象一次性加载(如全表查询)
- 短时间内创建大量对象
2. java.lang.OutOfMemoryError: GC overhead limit exceeded
- GC 花费超过 98% 时间但回收不到 2% 内存
- 原因:内存泄漏,GC 徒劳
- 可通过
-XX:-UseGCOverheadLimit关闭(不推荐)
3. java.lang.OutOfMemoryError: Metaspace
- 元空间不足
- 原因:
- 加载类过多(动态代理、CGLIB)
- 类加载器泄漏
- 解决:
-XX:MaxMetaspaceSize=512m
4. java.lang.OutOfMemoryError: unable to create new native thread
- 无法创建新线程
- 原因:
- 线程数超过系统限制(ulimit)
- 内存不足创建线程栈
- 解决:调整 ulimit、减小 -Xss、用线程池
5. java.lang.OutOfMemoryError: Direct buffer memory
- 直接内存(NIO ByteBuffer)不足
- 原因:未释放的 DirectByteBuffer
- 解决:
-XX:MaxDirectMemorySize、显式释放
6. java.lang.OutOfMemoryError: Requested array size exceeds VM limit
- 数组大小超过 JVM 限制
- 原因:分配超大数组
OOM 排查步骤:
保留现场:
bash-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/dump.hprof分析 dump 文件:
- MAT(Eclipse Memory Analyzer)
- JProfiler、YourKit
- VisualVM
MAT 分析流程:
- 查看 Leak Suspects 报告
- 分析 Dominator Tree(哪些对象占用最多内存)
- 查看 GC Roots 引用链
- 找到泄漏源
代码层面排查:
- 静态集合持有对象(Static List/Map)
- ThreadLocal 未 remove
- 监听器未注销
- 内部类持有外部类引用
- 连接未关闭(数据库、IO)
- 缓存无限增长
常见内存泄漏场景:
java
// 1. 静态集合
static List<Object> cache = new ArrayList<>();
cache.add(obj); // obj 永远无法回收
// 2. ThreadLocal
ThreadLocal<Object> tl = new ThreadLocal<>();
tl.set(obj); // 必须 tl.remove()
// 3. 未关闭资源
InputStream in = new FileInputStream(...); // 必须 close
// 4. 监听器未注销
listenerManager.register(this); // 必须 unregister线上快速定位:
bash
# 查看堆使用
jmap -heap <pid>
# 查看 GC 统计
jstat -gcutil <pid> 1000
# 触发 dump
jmap -dump:format=b,file=heap.hprof <pid>
# Arthas 在线分析
dashboard
heapdump /tmp/heap.hprof3.18 ★★★线上频繁 Full GC 如何排查定位问题?
📖 点击查看答案
频繁 Full GC 的常见原因:
老年代空间不足
- 内存泄漏,对象无法回收
- 大对象直接进入老年代
- Survivor 太小,对象过早晋升
元空间不足
- 加载类过多
- 类加载器泄漏
显式调用
System.gc()- 代码或第三方库(如 Netty、RMI)触发
内存分配过快
- 业务峰值
- 大批量查询
CMS 问题
- Concurrent Mode Failure
- 内存碎片严重
排查步骤:
1. 查看 GC 日志
bash
# JDK 8
jstat -gcutil <pid> 1000
# 输出:S0 S1 E O M YGC YGCT FGC FGCT GCT
# 关注:FGC 次数、FGCT 总时间、O 老年代使用率- FGC 快速增长 → 频繁 Full GC
- O 接近 100% → 老年代不足
2. 查看堆使用情况
bash
jmap -heap <pid>
# 查看堆配置、新生代/老年代使用情况3. Dump 堆分析
bash
jmap -dump:format=b,file=heap.hprof <pid>- 用 MAT 分析:
- Leak Suspects 报告
- Dominator Tree 找占用大的对象
- GC Roots 引用链
4. 查看对象统计
bash
jmap -histo:live <pid> | head -20
# 查看存活对象统计(按大小排序)- 找出占用内存最大的对象类型
5. 检查元空间
bash
jstat -gcmetacapacity <pid> 1000
# 查看 Metaspace 使用情况- Metaspace 持续增长 → 类加载器泄漏
6. 检查代码
- 是否有
System.gc()调用 - 是否有大对象(如全表查询、大文件)
- 是否有内存泄漏模式(静态集合、ThreadLocal)
- 是否有未关闭的资源
7. Arthas 在线诊断
bash
# 启动 Arthas
java -jar arthas-boot.jar <pid>
# 查看线程和 GC
dashboard
# 查看 JVM 信息
jvm
# 查看类加载情况
classloader
# 查看方法调用
watch com.xxx.Method params+returnObj典型问题与解决:
问题1:内存泄漏
- 现象:老年代使用率持续上升,Full GC 后不下降
- 解决:找到泄漏对象,修复代码
问题2:大对象
- 现象:每次请求都创建大对象,触发 Full GC
- 解决:分页查询、流式处理、限制大小
问题3:System.gc()
- 现象:日志显示 System.gc() 触发
- 解决:加
-XX:+DisableExplicitGC
问题4:元空间不足
- 现象:Metaspace 持续增长
- 解决:增大
-XX:MaxMetaspaceSize,检查类加载器
问题5:CMS 碎片
- 现象:CMS 老年代使用率高但不连续,触发 Full GC 整理
- 解决:换 G1,或调大老年代
预防措施:
- 配置 GC 日志和告警
- 设置合理的堆大小和 GC 收集器
- 监控内存使用趋势
- 压测发现潜在问题
- 代码评审关注资源释放
3.19 ★★元空间溢出的原因及解决方案是什么?
📖 点击查看答案
元空间(Metaspace)溢出:
- 错误:
java.lang.OutOfMemoryError: Metaspace - 元空间存储类元数据(类的元信息、常量池、方法字节码等)
- 元空间使用本地内存,默认无上限(受物理内存限制)
常见原因:
1. 动态生成类过多
- CGLIB、ASM、ByteBuddy 动态生成代理类
- 每次调用生成新类,未复用
java
// 反例:每次都创建新的代理类
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Target.class);
enhancer.setCallback(...);
Target proxy = (Target) enhancer.create(); // 每次都生成新 Class- Spring AOP、Hibernate、MyBatis 等框架会动态生成类
2. 类加载器泄漏
- 自定义 ClassLoader 无法被 GC
- 加载的 Class 持有 ClassLoader 引用 → ClassLoader 持有的 Class 无法卸载
- Tomcat 热部署时旧 ClassLoader 未清理
java
// 反例:静态字段持有 ClassLoader
static ClassLoader cl = new MyClassLoader();3. 第三方库问题
- 反射调用
Class.forName加载大量类 - Groovy、JRuby 脚本引擎
- JSP 重编译
4. 元空间设置过小
-XX:MaxMetaspaceSize设置太小,正常加载的类也撑爆
排查方法:
1. 查看元空间使用
bash
jstat -gcmetacapacity <pid>
# MC (Metaspace Capacity), MU (Metaspace Used), CCSC, CCSU
jstat -gcutil <pid> 1000
# M 列:Metaspace 使用率2. 查看类加载情况
bash
jmap -clstats <pid>
# 查看每个 ClassLoader 加载的类数量3. Arthas
bash
classloader # 列出所有 ClassLoader
classloader -t # 树形结构
classloader -l # 加载的类列表4. dump 分析
bash
jmap -dump:format=b,file=heap.hprof <pid>
# MAT 中看 ClassLoader 相关对象解决方案:
1. 增大元空间
bash
-XX:MaxMetaspaceSize=512m
-XX:MetaspaceSize=256m # 触发 Full GC 的阈值2. 修复类加载器泄漏
- 找出泄漏的 ClassLoader
- 解除静态引用
- 确保 Tomcat 等容器 redeploy 时清理旧 ClassLoader
3. 优化动态代理
- 缓存代理类(Spring 默认会缓存 Bean 的代理)
- 避免每次创建新代理
java
// Spring 中 Bean 的代理是单例的,不会反复生成
// 但手动 new Enhancer() 会每次生成新 Class4. 排查第三方库
- 检查 Groovy、JSP 等动态加载的库
- 限制脚本编译次数
5. 关闭 JSP 自动重载
xml
<!-- Tomcat 的 server.xml 或 web.xml -->
<init-param>
<param-name>development</param-name>
<param-value>false</param-value>
</init-param>预防措施:
- 合理设置
-XX:MaxMetaspaceSize - 监控元空间使用趋势
- 避免频繁热部署
- 代码评审关注动态类生成
3.20 ★★★如何 dump 堆快照和线程快照?常用的 JVM 监控和故障排查工具有哪些?
📖 点击查看答案
堆快照(Heap Dump):
- 记录 JVM 堆中所有对象的状态、引用关系
- 文件格式:
.hprof - 用于分析内存泄漏、OOM
生成堆快照的方式:
bash
# 1. jmap
jmap -dump:format=b,file=heap.hprof <pid>
jmap -dump:live,format=b,file=heap.hprof <pid> # 只 dump 存活对象
# 2. jcmd(JDK 8+ 推荐)
jcmd <pid> GC.heap_dump /path/heap.hprof
# 3. OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/heap.hprof
# 4. JMX 编程
HotSpotDiagnosticMXBean.dumpHeap(path, live);
# 5. Arthas
heapdump /tmp/heap.hprof
heapdump --live /tmp/heap.hprof线程快照(Thread Dump):
- 记录所有线程的堆栈、锁状态
- 用于分析 CPU 高、死锁、阻塞
- 文件格式:文本文件
生成线程快照的方式:
bash
# 1. jstack
jstack <pid> > thread.txt
jstack -l <pid> > thread.txt # 包含锁信息
# 2. jcmd
jcmd <pid> Thread.print > thread.txt
# 3. kill -3(Linux)
kill -3 <pid> # 输出到 stdout / 日志
# 4. JMX 编程
ThreadMXBean.dumpAllThreads(true, true);
# 5. Arthas
thread > thread.txt
thread -b # 查找阻塞线程常用监控工具:
1. 命令行工具:
| 工具 | 用途 |
|---|---|
| jps | 列出 Java 进程 |
| jstat | GC 统计、类加载统计 |
| jmap | 堆信息、dump、对象统计 |
| jstack | 线程堆栈、死锁检测 |
| jcmd | 多功能命令(JDK 8+) |
| jinfo | 查看/修改 JVM 参数 |
| jhat | 分析 dump(已废弃) |
2. 图形化工具:
| 工具 | 特点 |
|---|---|
| JConsole | JDK 自带,简单监控 |
| VisualVM | JDK 自带,功能全面 |
| JMC(JDK Mission Control) | 飞行记录器,低开销 |
| MAT | Eclipse 出品,dump 分析利器 |
| JProfiler | 商业,功能强大 |
| YourKit | 商业,性能优秀 |
3. 在线诊断工具:
Arthas(阿里开源):在线诊断,无需重启
dashboard:综合面板thread:线程jvm:JVM 信息watch:方法观察trace:方法耗时sc/sm:类/方法搜索heapdump:堆 dump
async-profiler:低开销 profiler,火焰图
GCViewer:GC 日志可视化
4. 监控系统:
- Prometheus + Grafana:JVM Exporter + 可视化
- SkyWalking / Pinpoint / Zipkin:APM
- ELK:日志分析
典型排查场景:
CPU 飙高:
bash
top # 找 CPU 高的进程
top -Hp <pid> # 找 CPU 高的线程
printf '%x' <tid> # 线程 ID 转 16 进制
jstack <pid> | grep -A 20 <hex_tid> # 查看堆栈内存泄漏:
bash
jmap -heap <pid> # 堆使用情况
jmap -histo:live <pid> | head # 对象统计
jmap -dump:format=b,file=heap.hprof <pid> # dump
# 用 MAT 分析死锁:
bash
jstack -l <pid> # 自动检测死锁
# 或 Arthas: thread -b