Skip to content

三、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(); } // StackOverflowError

3. 本地方法栈:

  • 服务于 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

对象分配流程:

  1. 新对象优先分配在 Eden
  2. Eden 满 → Minor GC,存活对象复制到 S0/S1
  3. 第二次 Minor GC:Eden + S0 存活对象复制到 S1,清空 S0
  4. 每次GC后存活年龄 +1,达到阈值(默认15)→ 晋升老年代

对象晋升老年代的几种方式:

  1. 年龄达到阈值:默认 15(-XX:MaxTenuringThreshold
  2. 大对象直接进入老年代:超过 -XX:PretenureSizeThreshold
  3. 动态年龄判断:Survivor 中相同年龄所有对象大小超过 Survivor 空间一半,年龄 ≥ 该年龄的对象直接晋升
  4. Survivor 空间不足:Minor GC 时 Eden + Survivor 存活对象太多放不下,需要老年代担保(空间分配担保)

JDK 8 用元空间替代永久代的原因:

  1. 永久代大小固定:容易出现 OOM(java.lang.OutOfMemoryError: PermGen
    • 加载类多时(动态生成类、JSP、Groovy)容易爆
  2. 调优困难:PermSize 和 MaxPermSize 难以预估
  3. 合并 HotSpot 和 JRockit:JRockit 没有永久代
  4. 元空间使用本地内存:可用空间大,只受物理内存限制
  5. GC 优化:元空间的元数据生命周期与类加载器一致,回收更高效

元空间 vs 永久代:

维度永久代(JDK 7-)元空间(JDK 8+)
位置JVM 进程内本地内存
大小PermSize/MaxPermSizeMaxMetaspaceSize(默认无限)
OOMPermGen spaceMetaspace
GCFull GC类卸载时回收

字符串常量池的位置变化:

  • JDK 6:永久代
  • JDK 7:堆(永久代逻辑部分)
  • JDK 8+:堆(元空间是方法区,但字符串常量池在堆中)

Minor GC vs Full GC:

  • Minor GC:只清理新生代,频繁但快
  • Full GC:清理整个堆 + 方法区,慢,应尽量避免

3.3 ★★JVM 的栈帧结构包含哪些部分?局部变量表和操作数栈的关系是什么?

📖 点击查看答案

栈帧结构:

每个方法调用创建一个栈帧,栈帧包含:

  1. 局部变量表(Local Variable Table)
  2. 操作数栈(Operand Stack)
  3. 动态链接(Dynamic Linking)
  4. 方法返回地址(Return Address)
  5. 附加信息(与实现相关)
┌─────────────────────┐
│  方法返回地址        │
├─────────────────────┤
│  动态链接            │
├─────────────────────┤
│  操作数栈            │
├─────────────────────┤
│  局部变量表          │
└─────────────────────┘

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]=c

2. 操作数栈:

  • 方法执行过程中的工作区,类似 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 对象的创建过程是怎样的?对象的内存布局包含哪些部分?对象头中存储了什么信息?

📖 点击查看答案

对象创建过程:

  1. 类加载检查:遇到 new 指令,检查常量池中类的符号引用是否已加载、解析、初始化,没有则先类加载
  2. 分配内存:在堆中分配对象所需的内存
    • 指针碰撞(Bump the Pointer):内存规整时,移动指针即可
    • 空闲列表(Free List):内存不规整时,维护可用块列表
  3. 初始化零值:将分配的内存初始化为零值(不包括对象头)
  4. 设置对象头:设置 Mark Word、类型指针、数组长度(数组对象)
  5. 执行 <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 不可达,可回收

可达性分析流程:

  1. 找到所有 GC Roots
  2. 从 GC Roots 开始遍历对象图
  3. 标记所有可达对象
  4. 未被标记的对象视为可回收

可作为 GC Roots 的对象:

  1. 虚拟机栈中引用的对象:方法中的局部变量、参数
  2. 本地方法栈中 JNI 引用的对象:全局引用、局部引用
  3. 方法区中类静态变量引用的对象static 字段
  4. 方法区中常量引用的对象static final 常量
  5. Java 虚拟机内部引用:基本类型对应的 Class 对象、常驻异常对象、系统类加载器
  6. 被同步锁(synchronized)持有的对象
  7. JMXBean、JVMTI 中引用的对象(扩展)
  8. 跨代引用(临时加入 GC Roots,避免漏标)

两次标记过程:

  1. 第一次标记:不可达对象,判断是否需要执行 finalize() 方法
    • 没有重写 finalize() 或 finalize() 已被调用过 → 直接回收
    • 重写了 finalize() → 加入 F-Queue
  2. 第二次标记: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-CJava、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 会被加入 queue

3.7 ★★大对象直接进入老年代的机制是什么?什么是"大对象",参数如何配置?

📖 点击查看答案

大对象的定义:

  • 需要连续内存空间的"巨型对象",如长数组、长字符串
  • 没有明确的大小阈值,取决于 JVM 参数和内存配置

大对象直接进入老年代的原因:

  • 新生代 Eden 区空间有限,大对象会触发频繁 Minor GC
  • 新生代采用复制算法,大对象在 Survivor 之间复制开销大
  • 直接放入老年代避免复制开销

配置参数:

  1. -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

其他影响对象分配的因素:

  1. -XX:MaxTenuringThreshold:对象晋升老年代的年龄阈值(默认 15,CMS 6)
  2. -XX:TargetSurvivorRatio:Survivor 目标使用率(默认 50%)
  3. 空间分配担保: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、E

2. 标记-复制(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 触发条件:

  1. 老年代空间不足

    • 大对象直接进入老年代但空间不够
    • 晋升到老年代的对象大于老年代剩余空间
  2. 方法区(元空间)空间不足

    • 加载类过多,Metaspace 达到 MaxMetaspaceSize
  3. System.gc() 显式调用

    • 建议 JVM 进行 Full GC,但不一定立即执行
    • 可用 -XX:+DisableExplicitGC 禁用
  4. 空间分配担保失败

    • Minor GC 前检查老年代可用空间是否够容纳新生代所有对象
    • 不够且不允许担保失败 → Full GC
  5. CMS 并发模式失败(Concurrent Mode Failure)

    • CMS GC 期间老年代空间不足,退化为 Serial Old(STW 长)
  6. 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:

  1. 合理设置新生代和老年代比例(避免大对象直接进老年代)
  2. 设置合理的晋升阈值
  3. 避免内存泄漏(导致老年代被占满)
  4. 避免显式 System.gc()
  5. 调大 MetaspaceSize(避免频繁 Full GC 回收元空间)
  6. 选择合适的 GC 收集器(G1、ZGC)

监控 GC:

bash
# 查看 GC 日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:gc.log

# JDK 9+ 统一日志
-Xlog:gc*=info:file=gc.log:time,uptime,level,tags

3.10 ★★★★CMS 垃圾收集器的工作流程是怎样的?G1 收集器的核心思想和 Region 划分是怎样的?

📖 点击查看答案

CMS(Concurrent Mark Sweep):

  • 目标:低停顿的垃圾收集器
  • 老年代收集器,配合 Serial/ParNew 处理新生代
  • 算法:标记-清除(有碎片)

CMS 工作流程(4 个阶段):

  1. 初始标记(Initial Mark):STW

    • 标记 GC Roots 直接引用的对象
    • 速度很快
  2. 并发标记(Concurrent Mark)

    • 与用户线程并发执行
    • 从 GC Roots 遍历整个对象图
    • 耗时长但不 STW
  3. 重新标记(Remark):STW

    • 修正并发标记期间用户线程运行导致标记变化的对象
    • 使用增量更新或 Card Table
    • 比初始标记长,但远短于并发标记
  4. 并发清除(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 工作流程:

  1. 初始标记(Initial Mark):STW,搭便车一次 Minor GC
  2. 并发标记(Concurrent Mark):并发,发现垃圾
  3. 最终标记(Final Marking / Remark):STW,处理并发标记期间的变更
  4. 筛选回收(Live Data Counting and Evacuation):STW
    • 按 GC 收益排序 Region
    • 回收垃圾最多的 Region(不全部回收)
    • 复制存活对象到空 Region

G1 vs CMS:

维度CMSG1
算法标记-清除标记-整理 + 复制
碎片
停顿可控是(MaxGCPauseMillis)
适用堆小(<8G)大(8G+)
全堆回收是(Mixed GC)
默认JDK 5-8JDK 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 的核心目标:

  1. 停顿时间 < 10ms,且不随堆大小增加而增加
  2. 支持 TB 级堆(4TB / 16TB)
  3. 吞吐量下降 < 15%
  4. 初始堆和最大堆可以相差很大

染色指针技术(Colored Pointer):

  • 64 位指针中,ZGC 借用高位存储 GC 信息
64 位指针:
[未使用 16 位][Finalizable 1][Remapped 1][Marked1 1][Marked0 1][对象地址 42 位]
  • 利用 4 个 bit 存储 4 种状态:

    • Marked0、Marked1:标记状态(交替使用)
    • Remapped:地址是否转移
    • Finalizable:是否通过 finalize 引用
  • 通过虚拟内存映射,不同状态下同一指针指向不同的物理地址(多视图映射)

染色指针的优势:

  1. GC 信息直接在指针上:不需要额外内存存标记位
  2. 可并发移动对象:移动对象时,染色指针记录状态,不需要修改所有引用
  3. 降低内存开销:相比 Card Table、Mark Bitmap 更省内存

读屏障(Load Barrier):

  • ZGC 在对象读取时插入读屏障
  • 修复过期指针(指向已移动的对象)
  • 让应用线程协助 GC 工作
c
// 伪代码
Object obj = field;
if (is_colored_pointer(obj)) {
    obj = fix_pointer(obj); // 修复指针
    field = obj;
}

ZGC 工作流程:

  1. 并发标记(Concurrent Mark):从 GC Roots 标记,染色指针
  2. 并发转移(Concurrent Relocate):选择 Region,复制存活对象
  3. 并发重定位(Concurrent Remap):修复引用
  • 几乎全程并发,STW 只在几个安全点(每次约 1ms)

ZGC vs G1:

维度G1ZGC
停顿时间100-200ms<10ms(目标)
堆大小8-32GBTB 级
算法分代 + RegionRegion + 染色指针
并发性部分并发几乎全并发
碎片无(整理)
内存开销中等高(染色指针 + 读屏障)
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 个阶段):

加载 → 验证 → 准备 → 解析 → 初始化
              ↑_____ 链接 _____↑
  1. 加载(Loading)

    • 通过类的全限定名获取定义此类的二进制字节流
    • 将字节流转化为方法区的运行时数据结构
    • 在堆中生成 Class 对象
  2. 验证(Verification)

    • 文件格式验证、元数据验证、字节码验证、符号引用验证
    • 确保字节码安全、合法
  3. 准备(Preparation)

    • 为静态变量分配内存并赋零值(不是代码中的初始值)
    • static int x = 10 此阶段 x = 0
    • static final int x = 10(常量)此阶段 x = 10
  4. 解析(Resolution)

    • 将常量池中的符号引用替换为直接引用
    • 符号引用:字符串形式的引用
    • 直接引用:内存地址/偏移量
  5. 初始化(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;
    }
}

双亲委派的好处:

  1. 避免重复加载:父加载器加载过的类,子加载器不需要再加载
  2. 安全性:核心类(如 java.lang.Object)只能由 Bootstrap 加载,防止用户伪造
  3. 层次清晰:各级加载器职责明确

为什么要打破双亲委派:

  1. 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);
  1. Tomcat 等 Web 容器

    • 每个 Web 应用独立的 ClassLoader,实现应用隔离
    • Tomcat 的 WebappClassLoader 优先自己加载(违反双亲委派)
  2. 热部署

    • 修改类后重新加载,需要新的 ClassLoader
    • 一个 ClassLoader 加载的类不能重新加载
  3. 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 ★★如何自定义类加载器?自定义类加载器时需要注意哪些问题?

📖 点击查看答案

自定义类加载器步骤:

  1. 继承 ClassLoader
  2. 重写 findClass 方法(推荐)或 loadClass 方法(破坏双亲委派)
  3. 通过 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();

自定义类加载器的常见应用:

  1. 类隔离:Tomcat 每个 Web 应用独立 ClassLoader
  2. 热部署:JRebel、Spring Boot DevTools
  3. 加密解密:对字节码加密,加载时解密
  4. 从非标准来源加载:网络、数据库、内存
  5. 模块化:OSGi

注意事项:

  1. 不要轻易重写 loadClass

    • 重写 loadClass 会破坏双亲委派
    • 通常重写 findClass 即可
  2. 保证父加载器优先

    • 避免核心类(java.*)被自定义加载器加载
    • 否则可能引发 SecurityException
  3. 类唯一性由 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
  1. 避免内存泄漏

    • 类加载器加载的类会持有加载器引用
    • 类加载器无法被 GC,会泄漏
    • Tomcat 重部署时要确保旧 ClassLoader 被清理
  2. 正确处理 parent

    • 构造时传入 parent ClassLoader
    • 否则默认 parent 是 AppClassLoader
  3. defineClass 限制

    • 包名以 java. 开头的不能由自定义加载器加载
    • 否则抛 SecurityException
  4. 线程上下文类加载器

    • 跨加载器调用时使用 TCCL
    • Thread.currentThread().setContextClassLoader(cl)

3.14 ★★★JIT 即时编译的原理是什么?C1 和 C2 编译器有什么区别?分层编译机制是什么?

📖 点击查看答案

JIT(Just-In-Time)即时编译:

  • JVM 把热点代码(频繁执行的字节码)编译成本地机器码,提升执行速度
  • 与解释器配合:解释器先执行,JIT 后台编译

JIT 的工作流程:

  1. JVM 启动时,代码由解释器逐条解释执行(启动快)
  2. 热点探测:判断哪些方法是热点代码
    • 基于计数器:方法调用计数器、回边计数器
    • 阈值默认 10000(-XX:CompileThreshold
  3. 触发 JIT 编译,把方法编译成机器码
  4. 后续调用直接执行机器码

热点探测方法:

  • 基于采样的热点探测:周期性检查线程栈顶方法(实现简单,但不精准)
  • 基于计数的热点探测:每个方法有计数器,达到阈值编译(HotSpot 使用)

C1 编译器(Client Compiler):

  • 关注快速编译
  • 优化较少(局部优化、简单内联)
  • 编译快,启动快
  • 适合桌面应用、短生命周期应用

C2 编译器(Server Compiler):

  • 关注极致性能
  • 大量优化:
    • 逃逸分析
    • 标量替换
    • 锁消除
    • 循环展开
    • 死代码消除
  • 编译慢,运行快
  • 适合服务端长期运行的应用

C1 vs C2:

维度C1C2
编译速度
优化程度
启动时间
峰值性能一般
适用客户端、启动敏感服务端、长期运行

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

  • 结合 C1 和 C2 的优点
  • 5 个编译层级:
层级说明
0解释执行
1C1 编译,无 profiling
2C1 编译,带方法调用和回边计数
3C1 编译,带完整 profiling
4C2 编译
  • 流程:
    1. 解释执行(0层)
    2. 触发 C1 编译,带 profiling(3层)
    3. 收集足够的 profile 数据后,触发 C2 编译(4层)
    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):

  • 分析对象的动态作用域,判断对象是否"逃逸"出方法或线程
  • 优化基础:不逃逸的对象可以更高效地处理

三种逃逸级别:

  1. 不逃逸(NoEscape):对象仅在方法内使用,不会被外部访问
  2. 方法逃逸(MethodEscape):对象被方法外的代码引用(作为返回值、赋值给全局变量)
  3. 线程逃逸(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:SurvivorRatioEden:Survivor 比例(默认 8)
-XX:MetaspaceSize元空间初始大小
-XX:MaxMetaspaceSize元空间最大大小
-XX:MaxDirectMemorySize直接内存最大值

2. GC 收集器选择:

参数说明
-XX:+UseSerialGCSerial + Serial Old
-XX:+UseParNewGCParNew + CMS(已废弃)
-XX:+UseParallelGCParallel Scavenge + Parallel Old(JDK 8 默认)
-XX:+UseConcMarkSweepGCParNew + CMS
-XX:+UseG1GCG1(JDK 9+ 默认)
-XX:+UseZGCZGC
-XX:+UseShenandoahGCShenandoah

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=10M

5. 故障诊断:

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(已废弃)-

选择原则:

  1. 优先用默认:JDK 8 默认 Parallel,JDK 9+ 默认 G1
  2. 吞吐型:Parallel Scavenge + Parallel Old
  3. 响应型:G1(推荐)/ ZGC(超大堆)
  4. 超大堆:ZGC(4GB+)/ Shenandoah

调优流程:

  1. 监控 GC 日志、内存使用
  2. 分析瓶颈:频繁 GC?Full GC?OOM?
  3. 调整参数:堆大小、GC 收集器、比例
  4. 压测验证
  5. 持续监控

常用监控工具:

  • 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 排查步骤:

  1. 保留现场

    bash
    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/path/dump.hprof
  2. 分析 dump 文件

    • MAT(Eclipse Memory Analyzer)
    • JProfiler、YourKit
    • VisualVM
  3. MAT 分析流程

    • 查看 Leak Suspects 报告
    • 分析 Dominator Tree(哪些对象占用最多内存)
    • 查看 GC Roots 引用链
    • 找到泄漏源
  4. 代码层面排查

    • 静态集合持有对象(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.hprof

3.18 ★★★线上频繁 Full GC 如何排查定位问题?

📖 点击查看答案

频繁 Full GC 的常见原因:

  1. 老年代空间不足

    • 内存泄漏,对象无法回收
    • 大对象直接进入老年代
    • Survivor 太小,对象过早晋升
  2. 元空间不足

    • 加载类过多
    • 类加载器泄漏
  3. 显式调用 System.gc()

    • 代码或第三方库(如 Netty、RMI)触发
  4. 内存分配过快

    • 业务峰值
    • 大批量查询
  5. 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,或调大老年代

预防措施:

  1. 配置 GC 日志和告警
  2. 设置合理的堆大小和 GC 收集器
  3. 监控内存使用趋势
  4. 压测发现潜在问题
  5. 代码评审关注资源释放

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() 会每次生成新 Class

4. 排查第三方库

  • 检查 Groovy、JSP 等动态加载的库
  • 限制脚本编译次数

5. 关闭 JSP 自动重载

xml
<!-- Tomcat 的 server.xml 或 web.xml -->
<init-param>
    <param-name>development</param-name>
    <param-value>false</param-value>
</init-param>

预防措施:

  1. 合理设置 -XX:MaxMetaspaceSize
  2. 监控元空间使用趋势
  3. 避免频繁热部署
  4. 代码评审关注动态类生成

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 进程
jstatGC 统计、类加载统计
jmap堆信息、dump、对象统计
jstack线程堆栈、死锁检测
jcmd多功能命令(JDK 8+)
jinfo查看/修改 JVM 参数
jhat分析 dump(已废弃)

2. 图形化工具:

工具特点
JConsoleJDK 自带,简单监控
VisualVMJDK 自带,功能全面
JMC(JDK Mission Control)飞行记录器,低开销
MATEclipse 出品,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

基于 4 份主流面试题库整合精选,共 200 题