Appearance
Day13 JVM:内存区域、GC 垃圾回收、类加载、JVM 调优
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. JVM 全景:一个 .java 文件从源码到执行的完整链路
面试问 JVM,大部分人直接背「堆、栈、方法区」,但面试官真正想听的是你知道这些区域在整条链路的哪个环节被用到。先把全景图建立起来,后面所有知识点都能挂到这张图上。
text
HelloWorld.java
│ javac 编译(前端编译,词法/语法/语义分析 + 字节码生成)
▼
HelloWorld.class ← 字节码,平台无关,JVM 的"唯一输入"
│ 类加载子系统(加载 → 验证 → 准备 → 解析 → 初始化)
▼
运行时数据区
├─ 线程私有:程序计数器 PC、虚拟机栈、本地方法栈
└─ 线程共享:堆 Heap、方法区(JDK8 起叫元空间 Metaspace)
│
▼
执行引擎
├─ 解释器(字节码逐条翻译,启动快)
├─ JIT 即时编译器(热点代码编译成机器码,执行快)
└─ 垃圾回收器 GC(自动回收堆里的死对象)
│
▼
本地方法接口 JNI → 本地方法库(操作系统能力)一句话总结:JVM 做三件事 —— 把 class 装进来(类加载)、把内存管起来(运行时数据区 + GC)、把字节码跑起来(解释器 + JIT)。
为什么要学 JVM? 不是为了背参数,而是为了回答线上三类问题:
| 线上现象 | 需要的 JVM 知识 |
|---|---|
| 服务突然 OOM、进程被打挂 | 内存区域划分 + OOM 四类成因 + 堆转储分析 |
| 接口 P99 抖动、每隔几分钟卡一下 | GC 类型 + STW 原理 + 回收器选型与参数调优 |
| CPU 100%、线程全在等待 | 栈结构 + 线程栈快照(jstack)+ 安全点分析 |
2. 运行时数据区:五个区域逐个拆解
这是 JVM 最高频的一道题。标准答法是先分线程私有 / 线程共享两大类,再逐区说"存什么 + 抛什么异常"。
text
线程私有(随线程生、随线程灭,不需要 GC)
├─ 程序计数器 PC Register → 当前线程执行到哪一行字节码
├─ 虚拟机栈 VM Stack → 栈帧(局部变量表/操作数栈/动态链接/返回地址)
└─ 本地方法栈 Native Stack → 执行 native 方法时的栈
线程共享(整个 JVM 一份,GC 的主战场)
├─ 堆 Heap → 所有对象实例、数组
└─ 方法区 Method Area → 类元信息、运行时常量池、静态变量(JDK8 挪到堆)
└─ JDK8 起用元空间 Metaspace 实现,位于本地内存
堆外(不属于运行时数据区,但会撑爆物理内存)
└─ 直接内存 Direct Memory → NIO 的 DirectByteBuffer、Netty 零拷贝① 程序计数器(PC Register)
存的是当前线程正在执行的字节码指令地址。它是唯一一个不会抛任何 OOM 异常的区域。
- 为什么要线程私有?因为 CPU 是时间片轮转的,线程被切走再切回来必须知道"我刚执行到哪",这叫线程恢复现场。
- 执行 Java 方法时记录字节码行号;执行 native 方法时值为 Undefined(native 方法不是字节码,JVM 管不着)。
- 空间极小(一个指针宽度),所以不会溢出。
② 虚拟机栈(VM Stack)
每个方法调用都会创建一个**栈帧(Stack Frame)**压入栈,方法返回则出栈。栈的生命周期 = 线程的生命周期。
栈帧的四个组成部分,必须能逐个说清:
| 栈帧组成 | 作用 |
|---|---|
| 局部变量表 | 存方法参数和局部变量,以变量槽 Slot 为单位(long/double 占 2 个,其余占 1 个);编译期就确定了大小 |
| 操作数栈 | 字节码指令的"工作台",比如 a+b 就是把两个值 push 进去,iadd 弹出相加再 push 结果 |
| 动态链接 | 指向运行时常量池中该方法所属类的引用,用于把符号引用转成直接引用 |
| 方法返回地址 | 方法正常返回(返回到调用者的 PC)或异常退出(查异常表)时用 |
两种异常,一定要能区分:
text
StackOverflowError → 栈深度超限(递归没出口 / 递归太深)
单个线程的栈深度 > -Xss 设定值
OutOfMemoryError → 栈扩展失败(线程开太多,每个线程都要分一块栈内存)
常见于"创建线程时 unable to create new native thread"易错点:
-Xss调大能让单线程递归更深,但会让能创建的线程数变少(总内存固定)。线上「unable to create new native thread」往往是-Xss配太大 + 线程池无界导致的。
③ 本地方法栈(Native Method Stack)
执行
native修饰的方法(如Object.hashCode()、Thread.start0())时使用的栈。HotSpot 虚拟机把它和虚拟机栈合二为一了,所以-Xss同时控制两者。
同样抛 StackOverflowError 和 OutOfMemoryError。
④ 堆(Heap)
JVM 管理的最大一块内存,几乎所有对象实例和数组都在这里分配。它是 GC 的主要工作区域,所以也叫「GC 堆」。
- JDK7 及之前:堆 = 新生代 + 老年代 + 永久代(PermGen)
- JDK8 及之后:堆 = 新生代 + 老年代,永久代被移除,类元信息挪到元空间(本地内存)
- 字符串常量池(StringTable)在 JDK7 就从永久代挪到了堆,静态变量在 JDK8 挪到了堆中的 Class 对象上
异常:java.lang.OutOfMemoryError: Java heap space
⑤ 方法区 / 元空间(Metaspace)
存类的元信息:类名、字段、方法字节码、访问修饰符、运行时常量池、JIT 编译后的代码缓存。
JDK8 为什么要用元空间替换永久代?三个理由:
| 永久代的问题 | 元空间的改进 |
|---|---|
大小由 -XX:MaxPermSize 固定,很难调对,动态加载类多了就 PermGen space OOM | 使用本地内存,默认上限就是物理内存,默认不设限 |
| 永久代的回收和老年代绑定,必须 Full GC 才能回收类 | 元空间按 ClassLoader 粒度管理,卸载更彻底 |
| 和 JRockit 虚拟机规格不一致,不利于合并 | 向 JRockit/J9 对齐,便于统一 |
异常:java.lang.OutOfMemoryError: Metaspace
元空间不设限 ≠ 不会 OOM。生产务必加
-XX:MaxMetaspaceSize=256m,否则动态代理/CGLib 疯狂生成类时会吃光物理内存导致机器被 OOM Killer 杀掉,比 JVM 自己抛 OOM 严重得多。
3. 对象的创建过程与内存布局
这道题是「new 一个对象发生了什么」的标准答案,也是理解 TLAB、指针压缩、锁升级的前置知识。
text
new User()
1. 类加载检查 → 常量池找 User 的符号引用,没加载过就先走类加载流程
2. 分配内存 → 两种方式:指针碰撞(内存规整)/ 空闲列表(内存有碎片)
并发安全:CAS 重试 或 TLAB(线程本地分配缓冲,默认开启)
3. 初始化零值 → 所有字段置 0/null/false,保证不赋值也能用
4. 设置对象头 → 写入 Mark Word(hashCode/GC 年龄/锁标志)和类型指针
5. 执行 <init> → 执行构造方法,按代码顺序赋值对象在堆中的内存布局(三部分):
| 区域 | 内容 | 大小(64 位) |
|---|---|---|
| 对象头 Header | Mark Word:hashCode、GC 分代年龄(4 bit)、锁状态标志、偏向线程 ID | 8 字节 |
| 类型指针 Klass Pointer:指向方法区的类元数据 | 开启压缩 4 字节 / 关闭 8 字节 | |
| 数组还多一个 数组长度 | 4 字节 | |
| 实例数据 Instance Data | 真正的字段值,按「相同宽度字段分配在一起」重排序优化 | 看字段 |
| 对齐填充 Padding | HotSpot 要求对象起始地址是 8 字节整数倍,不够就补齐 | 0~7 字节 |
GC 分代年龄只有 4 bit,所以最大值是 15 —— 这就是为什么
-XX:MaxTenuringThreshold不能超过 15,是面试高频追问点。
指针压缩 -XX:+UseCompressedOops(默认开启,堆 < 32G 时生效):
text
64 位系统下对象引用本来占 8 字节 → 压缩后占 4 字节
原理:对象地址按 8 字节对齐,所以低 3 位必然是 0,可以省掉不存
32 位能表示 2^32 个"对象槽",每槽 8 字节 → 可寻址 32GB
推论:堆设成 31G 比设成 33G 能装的对象更多!
超过 32G 压缩失效,所有引用变 8 字节,实际可用反而缩水4. 对象分配的完整流程:从 Eden 到老年代
text
new 对象
│
├─ 是不是大对象?(> -XX:PretenureSizeThreshold)
│ └─ 是 → 直接进老年代(避免在 Eden/Survivor 之间反复复制)
│
├─ 尝试栈上分配(逃逸分析通过 + 标量替换)→ 不进堆,方法结束随栈销毁
│
▼
Eden 区(优先走 TLAB,线程私有,无锁分配)
│ Eden 满 → 触发 Minor GC(Young GC)
▼
存活对象复制到 Survivor From(S0),年龄 +1
│ 每次 Minor GC,S0 ⇄ S1 来回复制,年龄累加
│
├─ 年龄 ≥ -XX:MaxTenuringThreshold(默认 15)→ 晋升老年代
├─ 动态年龄判定:Survivor 中同年龄对象总和 > Survivor 的 50%
│ → 该年龄及以上的直接晋升(不用等到 15)
└─ Survivor 放不下 → 直接晋升老年代
▼
老年代 Old Gen
│ 老年代满 → 触发 Major GC / Full GC
▼
还放不下 → OutOfMemoryError: Java heap space为什么 Eden : S0 : S1 = 8 : 1 : 1?
基于 IBM 的统计结论:大约 98% 的对象都是"朝生夕死"的。既然大部分对象在第一次 Minor GC 就会死,就不需要给 Survivor 留太大空间。8:1:1 意味着每次只有 10% 的新生代空间被"浪费"(闲置的那个 Survivor),比原始复制算法浪费 50% 好得多。
由 -XX:SurvivorRatio=8 控制(Eden : 单个 Survivor = 8 : 1)。
为什么要有两个 Survivor?
为了解决复制算法的内存碎片问题。Minor GC 时把 Eden + From 中存活的对象一次性复制到 To,复制完 Eden 和 From 直接整块清空,To 变成新的 From。全程不需要整理碎片,分配时只需指针碰撞。如果只有一个 Survivor,每次回收后 Survivor 内部会碎片化。
5. 三种 GC 基础算法
所有垃圾回收器都是这三种算法的组合,必须能画出"回收前 / 回收后"的内存示意。
① 标记-清除(Mark-Sweep)
text
回收前:[A][垃圾][B][垃圾][垃圾][C]
标记: ↑标记为垃圾 ↑ ↑
清除后:[A][空 ][B][空 ][空 ][C] ← 碎片化!- 优点:实现简单,不需要移动对象
- 缺点:内存碎片(想分配一个大对象时,总空闲够但没有连续空间,触发 Full GC)、标记和清除两个阶段效率都不高
- 代表:CMS 的并发清除阶段
② 标记-复制(Mark-Copy)
text
回收前:From[A][垃圾][B][垃圾] To[ 空 ]
复制后:From[ 空 ] To[A][B][ 空 ]
↑ 复制完 From 整块清空,角色互换- 优点:没有碎片,分配只需指针碰撞;只扫描存活对象,效率和存活率成正比
- 缺点:浪费一半空间(所以新生代优化成 8:1:1);存活率高时复制开销大
- 代表:新生代所有回收器(Serial / ParNew / Parallel Scavenge)
③ 标记-整理(Mark-Compact)
text
回收前:[A][垃圾][B][垃圾][垃圾][C]
整理后:[A][B][C][ 空 ] ← 存活对象向一端移动- 优点:无碎片 + 不浪费空间
- 缺点:移动对象要更新所有引用,必须 STW,成本高
- 代表:Serial Old / Parallel Old / G1 的整体行为
| 算法 | 碎片 | 空间利用率 | 移动对象 | 适用区域 |
|---|---|---|---|---|
| 标记-清除 | 有 | 高 | 否 | 老年代(CMS) |
| 标记-复制 | 无 | 50%(可优化到 90%) | 是 | 新生代(存活率低) |
| 标记-整理 | 无 | 高 | 是 | 老年代(存活率高) |
分代回收的本质:不是一种算法,而是根据对象存活特征,在不同区域用最合适的算法。新生代朝生夕死 → 复制;老年代存活率高 → 标记整理/清除。
6. 可达性分析与 GC Roots
为什么不用引用计数?
java
class Node { Node next; }
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a; // 循环引用
a = null; b = null;
// 引用计数法:两个对象计数都是 1,永远回收不掉 → 内存泄漏
// 可达性分析:从 GC Roots 出发都走不到它们 → 可以回收引用计数法实现简单、判定即时(Python/PHP 在用),但无法解决循环引用,且每次赋值都要维护计数器有性能开销。JVM 选择可达性分析(Tracing GC)。
可达性分析:以一系列 GC Roots 为起点向下搜索,走过的路径叫引用链。如果一个对象到 GC Roots 没有任何引用链相连,就判定为不可达,可以被回收。
GC Roots 有哪些?(面试必背,至少说出 5 类)
| GC Roots 类型 | 举例 |
|---|---|
| 虚拟机栈中局部变量表引用的对象 | 正在执行的方法里的局部变量、参数 |
| 方法区中静态属性引用的对象 | static User currentUser |
| 方法区中常量引用的对象 | static final String NAME、字符串常量池引用 |
| 本地方法栈中 JNI 引用的对象 | native 方法持有的对象 |
| JVM 内部引用 | 基本类型的 Class 对象、常驻异常对象(NPE/OOME)、系统类加载器 |
被同步锁 synchronized 持有的对象 | synchronized(lock) 中的 lock |
| 反映 JVM 内部情况的 JMXBean、JVMTI 回调、本地代码缓存 | — |
追问:为什么局部变量是 GC Root? 因为方法正在执行,栈帧还在栈上,这些对象显然"正在被使用",必须算活的。方法返回后栈帧出栈,这些引用自然消失。
对象的"两次标记"与 finalize():
text
第一次标记:可达性分析判定不可达
│
├─ 没覆写 finalize() 或已调用过 → 直接回收
└─ 覆写了 finalize() → 放入 F-Queue 队列
│ 由 Finalizer 线程(低优先级)执行 finalize()
├─ finalize() 里重新把 this 赋给某个 GC Root → 自我拯救,移出回收集合
└─ 没有自救 → 第二次标记,下次 GC 真正回收
finalize()在 JDK9 已被标记 @Deprecated,JDK18 废弃。原因:执行时机不确定、Finalizer 线程优先级低可能导致队列堆积 OOM、异常被忽略。生产要释放资源请用 try-with-resources 或 Cleaner。
7. 四种引用:强 > 软 > 弱 > 虚
text
强引用 Strong User u = new User();
只要引用在,GC 永不回收 → 宁可 OOM 也不回收
软引用 Soft SoftReference<byte[]> s = new SoftReference<>(new byte[1024]);
内存够不回收,**即将 OOM 前**回收 → 天然适合做缓存
弱引用 Weak WeakReference<User> w = new WeakReference<>(u);
只要发生 GC 就回收,不管内存够不够 → ThreadLocalMap 的 key
虚引用 Phantom PhantomReference<User> p = new PhantomReference<>(u, queue);
get() 永远返回 null,唯一作用是**对象被回收时收到通知**
→ DirectByteBuffer 用 Cleaner(虚引用) 释放堆外内存| 引用类型 | 被回收时机 | get() 能拿到对象 | 典型用途 |
|---|---|---|---|
| 强引用 | 永不(除非引用断开) | 是 | 普通对象 |
| 软引用 | 内存不足时 | 是(回收前) | 内存敏感缓存、图片缓存 |
| 弱引用 | 下次 GC | 是(回收前) | ThreadLocalMap、WeakHashMap、Guava Cache |
| 虚引用 | 任何时候都可能 | 永远 null | 堆外内存回收通知、资源清理追踪 |
配合 ReferenceQueue 的典型用法:
java
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
WeakReference<byte[]> ref = new WeakReference<>(new byte[1024 * 1024], queue);
System.gc();
Thread.sleep(100);
System.out.println(ref.get()); // null,对象已被回收
System.out.println(queue.poll() == ref); // true,引用对象自动入队
// → 由此可以感知"对象何时被回收",做后续清理(ThreadLocal 的 expungeStaleEntry 就是这个思路)面试高频:
ThreadLocal内存泄漏为什么发生?ThreadLocalMap的 key 是弱引用(ThreadLocal 对象可回收),但 value 是强引用。如果线程是线程池里的长生命周期线程,ThreadLocal 被回收后留下key=null, value=大对象的 Entry,value 一直被 Thread → ThreadLocalMap → Entry 链持有 → 泄漏。解法:用完必须remove()。
8. GC 的三种类型:Minor GC / Major GC / Full GC
这三个词在不同资料里定义混乱,面试时要先给出自己的定义口径再回答,显得特别专业。
| 名称 | 回收区域 | 触发条件 | STW 时长 |
|---|---|---|---|
| Minor GC / Young GC | 只回收新生代 | Eden 区满 | 短(几 ms 到几十 ms) |
| Major GC / Old GC | 只回收老年代 | 严格来说只有 CMS 有单独的老年代回收 | 较长 |
| Mixed GC | 新生代 + 部分老年代 | G1 独有,并发标记完成后进入混合回收 | 可控 |
| Full GC | 整堆 + 方法区 | 下面 6 种情况 | 最长(秒级,可能几十秒) |
Full GC 的 6 个触发条件(必背):
text
1. 手动调用 System.gc() / Runtime.getRuntime().gc()
→ 只是"建议",但 HotSpot 默认会真的执行 Full GC
→ 生产用 -XX:+DisableExplicitGC 禁掉(注意会影响 DirectByteBuffer 回收)
2. 老年代空间不足
→ 大对象直接进老年代放不下 / 晋升对象放不下
3. 方法区(元空间)空间不足
→ 动态生成类太多,元空间扩容失败
4. Minor GC 后晋升的对象大小 > 老年代剩余连续空间
→ 空间分配担保机制(HandlePromotionFailure)判定失败
5. CMS 的 concurrent mode failure
→ 并发清除还没做完,老年代就被填满了,退化成 Serial Old 单线程 Full GC
6. 堆中分配大对象,但找不到足够的连续空间(碎片导致)
→ 典型的 CMS 后期症状Full GC 为什么可怕? 它会 STW 整个应用线程,而且要扫描整个老年代 + 元空间。一个 8G 堆的 Full GC 轻松到 5~10 秒,上游调用方会大面积超时,熔断器打开,雪崩。线上调优的核心目标就是:尽量避免 Full GC,把 GC 都控制在 Young GC。
空间分配担保机制(JDK6 Update24 之后的逻辑):
text
Minor GC 前先检查:
老年代最大连续可用空间 > 新生代所有对象总大小?
是 → 安全,直接 Minor GC
否 → 看老年代连续空间 > 历次晋升到老年代的平均大小?
是 → 冒险做 Minor GC(可能失败)
失败 → 触发 Full GC
否 → 直接 Full GC9. 安全点(Safepoint)与 Stop The World
为什么要 STW? 可达性分析必须在一个**"内存快照"**上进行。如果分析过程中用户线程还在改引用关系,就会出现「把活对象当垃圾回收」(致命)或「把垃圾当活对象」(可容忍,叫浮动垃圾)。
线程不是想停就能停的,必须停在安全点:
text
安全点的位置(能让 JVM 准确知道所有引用在哪的位置)
├─ 方法调用
├─ 循环跳转(回边)
├─ 异常跳转
└─ 方法返回前
为什么不能随便停?因为 JVM 需要 OopMap(记录栈上哪些位置是引用),
OopMap 只在安全点生成,否则每条指令都记会占用巨量空间。怎么让线程跑到安全点?两种方案:
| 方案 | 做法 | HotSpot 是否采用 |
|---|---|---|
| 抢先式中断 | 先全部中断,发现线程不在安全点就恢复让它跑到安全点 | 现在几乎不用 |
| 主动式中断 | 设置一个中断标志位,线程在安全点轮询标志,为真就自己挂起 | 是 |
安全区域(Safe Region):线程处于 Sleep / Blocked 状态时跑不到安全点,就需要安全区域 —— 在这段代码里引用关系不会变化,GC 可以不管它。线程离开安全区域前要检查 GC 是否完成,没完成就等着。
线上真实坑:
for (int i = 0; i < 1_000_000_000; i++)这种 int 类型的可数循环(counted loop),JIT 会认为它执行很快,不在循环回边插入安全点轮询。结果 GC 要等这个循环跑完才能开始,表现为「GC 日志里 GC 耗时很短,但Total time for which application threads were stopped却有好几秒」。排查参数:
-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1,看TTSP(Time To Safepoint)。 解法:把int换成long(long 循环是 uncounted loop,会插入安全点),或拆小循环。
10. 类加载的五个阶段
类的生命周期:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。其中「验证、准备、解析」三步合称连接(Linking)。
text
加载 Loading
├─ 通过全限定名获取二进制字节流(文件/网络/动态代理/JAR)
├─ 把静态存储结构转为方法区的运行时数据结构
└─ 在堆中生成一个 java.lang.Class 对象作为访问入口
验证 Verification
├─ 文件格式验证:魔数 0xCAFEBABE、版本号是否支持
├─ 元数据验证:是否有父类、是否继承了 final 类、方法是否冲突
├─ 字节码验证:数据流和控制流分析,保证不会跳到方法体外
└─ 符号引用验证:解析阶段前,检查引用的类/字段/方法是否存在且可访问
准备 Preparation
├─ 为"类变量"(static)分配内存并设置**零值**
│ static int a = 10; → 此阶段 a = 0,赋值 10 在初始化阶段
└─ static final 常量(ConstantValue)直接赋真实值
static final int b = 10; → 此阶段 b = 10
解析 Resolution
└─ 把常量池里的**符号引用**(一串字符串描述)替换为**直接引用**(内存指针/偏移量)
类/接口解析、字段解析、方法解析、接口方法解析
初始化 Initialization
└─ 执行 <clinit>() 方法:static 变量赋值 + static 代码块,按源码顺序合并
JVM 保证 <clinit>() 在多线程下被**加锁同步**,只执行一次
→ 这就是"静态内部类单例"线程安全的底层保证<clinit>() 和 <init>() 必须能分清:
| 方法 | 含义 | 执行时机 | 执行次数 | 父类先后 |
|---|---|---|---|---|
<clinit>() | 类构造器 | 类初始化阶段 | 一次(类级别) | 父类 <clinit> 先执行 |
<init>() | 实例构造器 | new 对象时 | 每次 new 都执行 | 父类构造器先执行 |
类初始化的 6 种主动引用(会触发 <clinit>):
text
1. new / getstatic / putstatic / invokestatic 四条字节码指令
2. 反射调用:Class.forName("com.X")(注意第二个参数 initialize 可设 false)
3. 初始化子类时,父类还没初始化 → 先初始化父类
4. 虚拟机启动时,初始化主类(含 main 方法的类)
5. MethodHandle / VarHandle 解析结果为 REF_getStatic 等
6. 接口中定义了 default 方法,该接口的实现类初始化时,接口要先初始化三种被动引用(不触发初始化)—— 这是最经典的笔试陷阱:
java
// ① 通过子类引用父类的静态字段,只会初始化父类
class Parent { static int value = 1; static { System.out.println("Parent init"); } }
class Child extends Parent { static { System.out.println("Child init"); } }
System.out.println(Child.value); // 只打印 "Parent init",不打印 "Child init"
// ② 通过数组定义引用类,不会触发初始化
Parent[] arr = new Parent[10]; // 什么都不打印(只初始化了 [LParent 这个数组类)
// ③ 引用 static final 常量,在编译期已被放进调用方常量池
class Const { static final String NAME = "hi"; static { System.out.println("Const init"); } }
System.out.println(Const.NAME); // 什么都不打印!常量已被内联到调用处注意 ③ 的边界:只有编译期可确定的常量才会内联。如果写成
static final String NAME = UUID.randomUUID().toString();,编译期算不出来,就会触发初始化。
11. 类加载器与双亲委派机制
四种类加载器(JDK8 口径):
text
Bootstrap ClassLoader(启动类加载器)
├─ C++ 实现,是 JVM 的一部分,Java 里 getClassLoader() 返回 null
└─ 加载 JAVA_HOME/lib 下的核心类库(rt.jar、charsets.jar)
│ 父子关系(注意:是"组合"不是"继承")
▼
Extension ClassLoader(扩展类加载器)
├─ sun.misc.Launcher$ExtClassLoader,Java 实现
└─ 加载 JAVA_HOME/lib/ext 下的类,或 -Djava.ext.dirs 指定的目录
│
▼
Application ClassLoader(应用/系统类加载器)
├─ sun.misc.Launcher$AppClassLoader,ClassLoader.getSystemClassLoader() 返回它
└─ 加载 classpath 下的类(我们自己写的代码)
│
▼
Custom ClassLoader(自定义类加载器)
└─ 继承 ClassLoader,覆写 findClass(),用于热部署、加密 class、模块隔离JDK9+ 变化:引入模块化后,扩展类加载器被 Platform ClassLoader 取代,名字也从
$ExtClassLoader变成jdk.internal.loader.ClassLoaders$PlatformClassLoader,且 Bootstrap 不再返回 null 的实现细节有调整。面试提一句能加分。
双亲委派机制(Parents Delegation Model):
text
AppClassLoader 收到加载请求
│ 不自己加载,先委派给父加载器
▼
ExtClassLoader
│ 不自己加载,先委派给父加载器
▼
BootstrapClassLoader
│ 能加载?
├─ 能 → 返回 Class,结束
└─ 不能 → 退回给子加载器 ExtClassLoader 尝试
│ 能加载?
├─ 能 → 返回
└─ 不能 → 退回给 AppClassLoader 尝试
└─ 不能 → ClassNotFoundException源码只有十几行,建议背下来:
java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 先检查是否已经加载过(缓存)
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false); // 2. 委派给父加载器
} else {
c = findBootstrapClassOrNull(name); // 3. 父为 null 则走 Bootstrap
}
} catch (ClassNotFoundException e) {
// 父加载器加载失败,不抛出,继续往下走
}
if (c == null) {
c = findClass(name); // 4. 父加载器加载不了,自己来(自定义加载器覆写这里)
}
}
if (resolve) resolveClass(c);
return c;
}
}双亲委派的两大作用:
| 作用 | 说明 |
|---|---|
| 安全性 | 自己写一个 java.lang.String 放进 classpath,加载请求会被委派到 Bootstrap,最终加载的是 JDK 的 String,恶意类无法替换核心类(而且会报 SecurityException: Prohibited package name) |
| 唯一性 | 同一个类只会被加载一次。类的唯一性 = 全限定类名 + 加载它的 ClassLoader,父加载器加载过的类不会被子加载器重复加载 |
重要概念:两个相同全限定名的类,如果被不同的 ClassLoader 加载,JVM 认为它们是两个不同的类,
instanceof返回 false,强转会抛ClassCastException。这是热部署和模块隔离的理论基础,也是ClassCastException: com.A cannot be cast to com.A这种诡异报错的根因。
破坏双亲委派的三个经典场景:
text
① JDBC / SPI 机制 —— 线程上下文类加载器(TCCL)
问题:java.sql.DriverManager 由 Bootstrap 加载,但它要加载的
com.mysql.cj.jdbc.Driver 在 classpath 里,Bootstrap 看不到
解法:Thread.currentThread().getContextClassLoader() 默认是 AppClassLoader
父加载器"反向"委派给子加载器 → 打破了自上而下的单向委派
② Tomcat 的类加载器隔离
Common → Catalina / Shared → WebApp1 / WebApp2
每个 Web 应用一个 WebappClassLoader,**先自己找再委派父**(和标准流程相反)
目的:同一个 Tomcat 下两个应用可以用不同版本的 Spring,互不干扰
例外:javax.servlet.* 等规范包仍走标准委派,保证接口一致
③ OSGi / 热部署 / 热加载
类加载变成网状结构而非树状
热部署做法:丢弃旧的 ClassLoader,用新的 ClassLoader 重新加载
(类卸载条件:Class 对象不被引用 + 实例全被回收 + ClassLoader 被回收)追问:JDK9 的模块化会破坏双亲委派吗? 会调整。JDK9 中类加载先去模块路径找属于哪个模块,找到了就直接委派给对应模块的加载器,不再严格自底向上,这是一种"按需委派"。
✨ 高级深挖知识点
1. 三色标记法与并发标记的漏标问题
这是 CMS / G1 / ZGC 全部并发回收器的理论基础,答出这一节直接拉开档次。
text
白色 White → 未被访问过。标记结束后仍为白色 = 垃圾
灰色 Gray → 已被访问,但它的**成员引用还没全部扫描完**(在"待处理队列"里)
黑色 Black → 已被访问,且它的所有成员引用都扫描完了 = 存活,不用再看
扫描过程:
初始 GC Roots 置灰,其他全白
循环 从灰色集合取一个对象 → 把它的所有引用对象置灰 → 自己置黑
结束 灰色集合为空,白色对象全部回收并发标记的两个致命问题:
| 问题 | 现象 | 后果 |
|---|---|---|
| 多标(浮动垃圾) | 标记为黑色后对象变成了垃圾 | 可容忍,等下次 GC 回收 |
| 漏标 | 本该存活的对象被标成白色被回收 | 致命!程序直接崩溃 |
漏标发生的充要条件(Wilson, 1994 证明)—— 必须同时满足:
text
条件 1:赋值器插入了一条或多条 黑色对象 → 白色对象 的新引用
条件 2:赋值器删除了全部 灰色对象 → 该白色对象 的直接或间接引用
具体过程:
初始 A(黑) → B(灰) → C(白)
并发中 ① A.c = C (黑 → 白 新增引用,黑色不会再被扫描)
② B.c = null (灰 → 白 引用被删除,C 再也没有机会被扫到)
结果 C 仍是白色 → 被当成垃圾回收 → A.c 变成野指针,程序崩溃两种解法,分别对应 CMS 和 G1:
| 方案 | 破坏哪个条件 | 做法 | 使用者 |
|---|---|---|---|
| 增量更新 Incremental Update | 条件 1 | 黑色对象新增指向白色的引用时,把黑色对象重新变灰,重新扫描一次 | CMS |
| 原始快照 SATB(Snapshot At The Beginning) | 条件 2 | 灰色对象删除指向白色的引用时,把这条旧引用记录下来,并发标记结束后以这些旧引用为根重新扫描一遍 | G1 / Shenandoah |
两者都通过写屏障(Write Barrier)实现。增量更新用写后屏障,SATB 用写前屏障。
为什么 G1 选 SATB? G1 的 Region 数量多,增量更新把整个黑色对象重新扫描的代价在 G1 上更不可控;SATB 只需记录被删除的引用,配合 Region 粒度的 Remembered Set,重新标记阶段的 STW 更短、更可预测。代价是浮动垃圾更多(SATB 相当于"快照里活的就算活")。
2. 卡表、记忆集与写屏障:跨代引用怎么办
问题:Minor GC 只回收新生代,但老年代对象可能引用新生代对象(老 → 新 跨代引用)。如果不处理,就得扫描整个老年代来确认哪些新生代对象还活着 —— 那 Minor GC 就和 Full GC 一样慢了。
解法:记忆集(Remembered Set)+ 卡表(Card Table)
text
把老年代按 512 字节切成一个个"卡页(Card Page)"
用一个 byte 数组 CARD_TABLE[] 作为卡表,每个 byte 对应一个卡页
CARD_TABLE[idx] = 0 → 这个卡页里没有指向新生代的引用(clean)
CARD_TABLE[idx] = 1 → 这个卡页里有指向新生代的引用,叫"脏卡 Dirty Card"
Minor GC 时:GC Roots + 所有脏卡对应的老年代内存块 一起扫描
→ 扫描范围从"整个老年代"缩小到"少量脏卡"
卡表索引计算(一次位移,极快):
idx = 对象地址 >> 9 // 512 = 2^9写屏障(Write Barrier):怎么知道某个卡页变脏了?在引用类型字段赋值的那条字节码(putfield 等)前后,JVM 会插入一小段机器码:
text
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写前屏障:SATB 在这里记录旧引用
*field = new_value; // 真正的赋值
post_write_barrier(field, val); // 写后屏障:这里把卡页标脏
}两个工程优化点:
| 问题 | 现象 | 解法 |
|---|---|---|
| 伪共享 False Sharing | 多线程更新相邻卡表元素,它们在同一条 64 字节 CPU 缓存行里,互相失效 | -XX:+UseCondCardMark:先判断卡页是否已为脏,不脏才写 |
| G1 的 RSet 维护成本 | G1 每个 Region 都有自己的 RSet(哈希表),内存占用可达堆的 10%~20% | 用 Dirty Card Queue + Refinement 线程异步处理 |
3. 垃圾回收器全家桶与组合关系
text
新生代回收器 老年代回收器
Serial ───┬───────→ Serial Old (客户端模式默认,单线程 STW)
ParNew ───┼───────→ Serial Old (已废弃的组合)
ParNew ───┴───────→ CMS (JDK8 低延迟经典组合,JDK14 已移除 CMS)
Parallel Scavenge ───────→ Parallel Old (JDK8 默认,吞吐量优先)
整堆回收器(不分新老代地独立运行)
G1 JDK9 起默认,Region 化,兼顾吞吐与延迟
ZGC JDK11 实验 / JDK15 正式,染色指针,停顿 < 1ms(JDK16+ < 10ms 承诺)
Shenandoah Red Hat 开源,转发指针,并发整理
Epsilon 什么都不回收,用于性能基线测试| 回收器 | 算法 | 线程 | 关注点 | 适用场景 |
|---|---|---|---|---|
| Serial | 复制 | 单 | 简单 | 客户端、几十 MB 小堆 |
| ParNew | 复制 | 多 | 延迟 | 配合 CMS |
| Parallel Scavenge | 复制 | 多 | 吞吐量 | 后台批处理、报表 |
| Serial Old | 标记整理 | 单 | 简单 | CMS 失败时的后备方案 |
| Parallel Old | 标记整理 | 多 | 吞吐量 | JDK8 默认 |
| CMS | 标记清除 | 并发 | 延迟 | 老的 Web 服务(JDK8 及之前) |
| G1 | 复制 + 标记整理 | 并发 | 可预测停顿 | 大堆(6G+)、JDK9+ 默认 |
| ZGC | 标记整理(染色指针) | 并发 | 极低延迟 | 超大堆(TB 级)、JDK17+ |
吞吐量 vs 延迟:吞吐量 = 用户代码时间 / (用户代码时间 + GC 时间)。追求吞吐量就是让总 GC 时间占比最小(允许单次停顿久一点);追求延迟就是让单次停顿最短(允许 GC 总耗时更多,因为并发标记要和用户线程抢 CPU)。二者不可兼得,这是选型的根本依据。
4. CMS 深度剖析:四个阶段与三大缺陷
CMS(Concurrent Mark Sweep)= 以获取最短回收停顿时间为目标的老年代回收器。
text
① 初始标记 Initial Mark 【STW,但极快】
只标记 GC Roots 直接关联的对象,不往下追
JDK8 可用 -XX:+CMSParallelInitialMarkEnabled 开多线程并行
② 并发标记 Concurrent Mark 【与用户线程并发,最耗时】
从初始标记的对象出发,遍历整个对象图(三色标记)
期间用户线程在改引用 → 用"增量更新 + 写后屏障"把变脏的黑色对象重新置灰
③ 重新标记 Remark 【STW,比初始标记稍长】
修正并发标记期间因用户线程运行导致的标记变动
-XX:+CMSScavengeBeforeRemark:Remark 前先做一次 Minor GC
→ 减少新生代到老年代的跨代引用扫描量,这是 CMS 调优的关键一招
④ 并发清除 Concurrent Sweep 【与用户线程并发】
清理标记为垃圾的对象,不移动存活对象(所以产生碎片)
期间新产生的垃圾叫"浮动垃圾",留到下次回收CMS 的三大缺陷(面试必答):
| 缺陷 | 原因与后果 | 缓解手段 |
|---|---|---|
| ① 内存碎片 | 标记清除不移动对象,长期运行后老年代碎片化,明明有空间却分配不了大对象 → 提前 Full GC | -XX:+UseCMSCompactAtFullCollection(默认开,Full GC 时整理)-XX:CMSFullGCsBeforeCompaction=N(N 次 Full GC 后整理一次) |
| ② 并发开销 + 浮动垃圾 | 并发阶段 GC 线程占 CPU(默认 (CPU核数+3)/4),吞吐量下降约 20%;浮动垃圾要预留空间 | -XX:CMSInitiatingOccupancyFraction=70 提前触发必须配 -XX:+UseCMSInitiatingOccupancyOnly,否则 JVM 会动态调整,参数等于白写 |
| ③ Concurrent Mode Failure | 并发清除还没完,老年代就被填满了 → 退化成 Serial Old 单线程 Full GC,停顿从几十毫秒飙到十几秒 | 降低触发阈值、增大老年代、减少晋升速率 |
CMS 已在 JDK9 被标记废弃,JDK14 正式移除。但面试还在问,因为它是理解"并发 GC 为什么难"的最佳教材。遇到新项目,直接上 G1 或 ZGC。
5. G1 深度剖析:Region 化与可预测停顿
G1(Garbage First)的核心创新:把"分代"从物理隔离变成逻辑概念。
text
传统分代(物理连续)
[ Eden ][S0][S1][ Old Gen ]
问题:各代大小启动时就定了,调不动;回收必须整代回收
G1 的 Region 化(逻辑分代)
堆被切成 2048 个左右大小相等的 Region(1MB~32MB,必须是 2 的幂)
每个 Region 在不同时刻可以是不同角色:
[E][O][S][E][H][O][E][ ][O][E][S][H][H][O][E][ ] ...
E = Eden S = Survivor O = Old
H = Humongous(大对象,> Region 一半大小,连续多个 H Region 存放)
空白 = Available(未分配)
由 -XX:G1HeapRegionSize 控制 Region 大小,不设则按 堆大小/2048 自动算G1 的四个阶段:
text
① 初始标记 Initial Mark 【STW】
标记 GC Roots 直达对象 + 修改 TAMS 指针(Top at Mark Start)
关键优化:这一步**搭便车**在 Young GC 的 STW 里一起完成,几乎不额外停顿
② 并发标记 Concurrent Mark 【并发】
遍历整个对象图,用 SATB + 写前屏障 记录被删除的旧引用
③ 最终标记 Final Mark 【STW】
处理 SATB 记录下来的、并发阶段遗留的少量引用变更
④ 筛选回收 Live Data Counting and Evacuation 【STW,可并行】
★ G1 的灵魂:对每个 Region 统计"回收价值"= 可回收空间 / 预计回收耗时
按价值降序排列,根据 -XX:MaxGCPauseMillis 的预算,
从价值最高的开始选,选够为止 → 这个集合叫 CSet(Collection Set)
把 CSet 内存活对象复制到空 Region(复制算法,天然无碎片),原 Region 整块回收「Garbage First」这个名字的含义:优先回收垃圾最多、收益最高的 Region。这就是可预测停顿模型的实现方式 —— 不是"尽力而为",而是用停顿时间预算反向决定回收多少。
G1 的关键概念:
| 概念 | 作用 |
|---|---|
| CSet(Collection Set) | 本次回收选中的 Region 集合,由停顿预算决定大小 |
| RSet(Remembered Set) | 每个 Region 一份,记录"谁引用了我"(points-into 结构),避免全堆扫描 |
| Humongous Region | 存放超过 Region 一半大小的大对象,直接当老年代处理 |
| TAMS | 并发标记开始时的 Region 分配指针,之后新分配的对象默认存活 |
| Mixed GC | 并发标记完成后,回收 全部新生代 + 部分收益高的老年代 Region |
| Full GC(退化) | 复制时找不到空 Region(Evacuation Failure / to-space exhausted)→ 单线程 Serial Old 整堆回收 |
G1 vs CMS 核心对比表:
| 维度 | CMS | G1 |
|---|---|---|
| 作用范围 | 只管老年代(需配 ParNew) | 整堆(新生代 + 老年代一体) |
| 内存布局 | 物理连续的传统分代 | Region 化的逻辑分代 |
| 老年代算法 | 标记-清除(有碎片) | 复制 + 整理(无碎片) |
| 并发标记方案 | 增量更新(写后屏障) | SATB(写前屏障) |
| 停顿可控性 | 不可控,只能"尽量短" | 可设目标 -XX:MaxGCPauseMillis |
| 跨代引用结构 | 卡表(一份,全局) | RSet(每 Region 一份,内存开销大 10%~20%) |
| 适用堆大小 | < 6GB(更大就碎片/停顿失控) | 6GB ~ 数十 GB |
| 失败退化 | concurrent mode failure → Serial Old | to-space exhausted→ Serial Full GC |
| 版本状态 | JDK9 废弃,JDK14 移除 | JDK9 起默认 |
选型口诀:堆 < 4G 且追求吞吐 → Parallel;堆 4G~32G 且要求停顿可控 → G1;堆 > 32G 或要求停顿 < 10ms → ZGC。
6. ZGC 与 Shenandoah:染色指针和转发指针
ZGC 的目标:无论堆多大(8MB ~ 16TB),停顿都控制在 10ms 以内,且停顿时间不随堆大小增长。
核心技术:染色指针(Colored Pointers)
text
传统做法:把对象的"标记信息"存在对象头里(Mark Word 的 GC 年龄等)
→ 想知道一个对象是否被标记,必须去访问对象(可能缺页/缓存不命中)
ZGC 做法:把标记信息**存在指针本身**里
64 位指针,ZGC 只用低 44 位寻址(16TB),拿高位做标记:
[ 未使用 18 位 ][ Finalizable ][ Remapped ][ M1 ][ M0 ][ 44 位对象地址 ]
└── 这 4 位就是"颜色"
好处:
1. 对象被回收后,指针所在的那块虚拟内存可以立刻释放(不用等所有引用都更新完)
2. 不需要写屏障记录跨代引用,改用更轻量的**读屏障**
3. 标记信息和对象解耦,减少内存访问并发整理的难题与两种解法:
text
难题:并发移动对象时,用户线程可能正拿着旧地址访问 → 读到已被搬走的内存
ZGC 的解法:读屏障 + 自愈(Self-Healing)
用户线程每次读引用,读屏障检查指针颜色
颜色正确 → 直接用
颜色过期 → 查转发表找到新地址,**顺手把这个引用字段改成新地址**(自愈)
→ 代价:每次读引用都有开销(约 4%~15% 吞吐损失)
Shenandoah 的解法:Brooks 转发指针(Forwarding Pointer)
每个对象头前面多加一个指针槽,正常时指向自己,被移动后指向新副本
→ 访问对象必须先过一次这个指针(间接寻址)
→ 内存多占 8 字节/对象| 维度 | G1 | ZGC | Shenandoah |
|---|---|---|---|
| 停顿目标 | 可配置(默认 200ms) | < 10ms(不随堆增长) | < 10ms |
| 分代 | 有(逻辑) | JDK21 起支持分代 ZGC | 无(早期版本) |
| 核心技术 | SATB + RSet | 染色指针 + 读屏障 | 转发指针 + 读屏障 |
| 并发整理 | 否(整理需 STW) | 是 | 是 |
| 维护方 | Oracle | Oracle | Red Hat |
| 推荐版本 | JDK9+ | JDK17+(JDK21 分代版更佳) | JDK12+ |
ZGC 的局限:不支持压缩指针(染色指针占用了高位),所以小堆下内存开销反而更大;吞吐量比 G1 低约 5%~15%。所以不是所有服务都该上 ZGC,只有对延迟极端敏感(交易撮合、实时竞价)或堆特别大(> 32G)时才值得。
7. OOM 的六种场景与精确定位
面试说"OOM 四种"是起步,能说出六种 + 每种的报错原文 + 定位手段才算过关。
| 报错信息 | 发生区域 | 典型成因 |
|---|---|---|
java.lang.OutOfMemoryError: Java heap space | 堆 | 对象太多/内存泄漏/堆设太小/大 List 查全表 |
java.lang.StackOverflowError | 虚拟机栈 | 递归无出口、递归层数过深、循环依赖的 toString() |
java.lang.OutOfMemoryError: unable to create new native thread | 栈/OS | 线程数超限(线程池无界 / -Xss 太大 / OS 限制) |
java.lang.OutOfMemoryError: Metaspace | 元空间 | 动态代理/CGLib/脚本引擎疯狂生成类;ClassLoader 泄漏 |
java.lang.OutOfMemoryError: Direct buffer memory | 直接内存 | Netty/NIO 的 DirectByteBuffer 未释放;-XX:MaxDirectMemorySize 太小 |
java.lang.OutOfMemoryError: GC overhead limit exceeded | 堆 | 连续多次 Full GC,GC 时间占比 > 98% 且回收 < 2%(比 heap space 更早报,是"濒死"信号) |
java.lang.OutOfMemoryError: Requested array size exceeds VM limit | 堆 | 申请数组长度 > Integer.MAX_VALUE - 2 |
必配的"黑匣子"参数(生产环境一定要加上,否则 OOM 后无从查起):
bash
-XX:+HeapDumpOnOutOfMemoryError # OOM 时自动 dump 堆
-XX:HeapDumpPath=/data/logs/heap.hprof # dump 文件路径(注意磁盘空间要够堆大小)
-XX:+ExitOnOutOfMemoryError # OOM 后直接退出,交给 K8s 重启(避免半死不活)
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50M为什么要
ExitOnOutOfMemoryError? OOM 发生在某个业务线程时,JVM 不一定会退出 —— 进程还活着但大部分请求都失败,健康检查还通过,这叫**"僵尸实例"**,比直接挂掉危害更大。
8. 直接内存与堆外内存泄漏
java
// NIO 的直接内存分配
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024 * 100); // 100MB 堆外
// 对比
ByteBuffer heapBuf = ByteBuffer.allocate(1024 * 1024 * 100); // 100MB 堆内text
为什么要用直接内存?—— 少一次拷贝(零拷贝)
堆内 ByteBuffer 写 Socket:
Java 堆 → 【复制】→ 堆外临时缓冲 → 内核态 → 网卡
(因为 GC 会移动对象,内核拿到的地址可能失效,必须先复制到不会移动的堆外)
直接内存:
堆外 → 内核态 → 网卡 ← 省掉一次复制
代价:
① 分配和释放成本比堆内高(要走 OS 的 malloc)
② 不受 -Xmx 控制,由 -XX:MaxDirectMemorySize 控制(不设则默认等于 -Xmx)
③ 回收依赖 Cleaner(虚引用)—— 必须等 Full GC 回收 DirectByteBuffer 对象
才会触发 Cleaner 释放堆外内存最隐蔽的坑:
text
① 配了 -XX:+DisableExplicitGC 之后,DirectByteBuffer 的兜底机制失效
原因:Bits.reserveMemory() 在堆外不足时会主动 System.gc() 触发 Full GC
来回收 DirectByteBuffer 对象、释放堆外内存
禁用后这个自救机制没了 → Direct buffer memory OOM
解法:用 -XX:+ExplicitGCInvokesConcurrent 代替(让 System.gc() 走并发而非 Full GC)
② Netty 的 ByteBuf 忘记 release(),引用计数不归零 → 堆外内存一直不释放
排查:-Dio.netty.leakDetection.level=paranoid 开启泄漏检测
或用 Netty 的 PlatformDependent.usedDirectMemory() 监控
③ 容器里只看 -Xmx 不管堆外,被 K8s OOMKilled(exit code 137)
容器总内存 = 堆 + 元空间 + 线程栈 + 直接内存 + CodeCache + JVM 自身开销
经验公式:-Xmx 设为容器 limit 的 50%~70%,务必加 -XX:MaxDirectMemorySize
JDK10+ 可用 -XX:MaxRAMPercentage=70 让 JVM 自己按容器 limit 算容器环境必加:
-XX:+UseContainerSupport(JDK10+ 默认开)。JDK8u191 之前的 JVM 读不到 cgroup 限制,会按宿主机的 CPU 和内存来算默认堆大小和 GC 线程数 —— 容器 limit 是 2G,JVM 却按宿主机 128G 算出 32G 的默认堆,一跑就被 OOMKilled。
9. 逃逸分析:栈上分配、标量替换、锁消除
逃逸分析(Escape Analysis):JIT 编译器分析一个对象的作用域是否逃出了方法/线程。
text
不逃逸(NoEscape) 对象只在方法内用,没传出去 → 可栈上分配 + 标量替换
方法逃逸(ArgEscape) 对象被作为参数传给其他方法,但没赋给全局
全局逃逸(GlobalEscape)对象被赋给静态变量/返回值/传给其他线程 → 必须分配在堆上java
// ① 不逃逸 —— 可栈上分配
public int noEscape() {
Point p = new Point(1, 2); // p 的作用域只在本方法
return p.x + p.y;
}
// ② 全局逃逸 —— 必须在堆上
public Point globalEscape() {
return new Point(1, 2); // 对象被返回出去了
}基于逃逸分析的三大优化:
| 优化 | 原理 | 参数 |
|---|---|---|
| 栈上分配 | 不逃逸的对象在栈帧上分配,方法返回随栈帧销毁,完全不给 GC 添负担 | 依赖 -XX:+DoEscapeAnalysis(默认开) |
| 标量替换 Scalar Replacement | 更彻底:把对象拆散,Point p 直接变成两个局部变量 x、y,对象根本不存在 | -XX:+EliminateAllocations(默认开) |
| 锁消除 Lock Elision | 对象不会被其他线程访问,synchronized 直接去掉。典型:StringBuffer 在方法内局部使用时,它的同步方法会被消除 | -XX:+EliminateLocks(默认开) |
真相澄清:HotSpot 并没有真正实现"把完整对象分配在栈上",而是通过标量替换达到了等效效果。所以严格说,"栈上分配"是一个结果描述,不是一个独立的实现机制。这个点答出来是加分项。
验证逃逸分析的实验:
java
public class EscapeTest {
static class Point { int x, y; }
static void alloc() { Point p = new Point(); p.x = 1; p.y = 2; }
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100_000_000; i++) alloc();
System.out.println(System.currentTimeMillis() - start + " ms");
}
}
// 开启(默认): java -XX:+DoEscapeAnalysis EscapeTest → 约 5 ms,GC 次数 0
// 关闭: java -XX:-DoEscapeAnalysis EscapeTest → 约 1500 ms,大量 Minor GC注意:逃逸分析是 JIT(C2 编译器) 的优化,只在方法变成热点代码被编译后才生效。所以
-Xint(纯解释执行)下逃逸分析完全不起作用,这也是为什么上面的循环必须跑够次数才能看到差异。
10. JIT 即时编译:解释器与编译器的协作
text
为什么需要两套执行方式?
解释器:启动快(不用编译),但每条字节码都要翻译,执行慢
编译器:编译要花时间,但编译后的机器码执行极快
HotSpot 的"分层编译(Tiered Compilation)"
Level 0 解释执行
Level 1 C1 编译,不带 profiling(简单方法,直接编译到位)
Level 2 C1 编译,带基础 profiling
Level 3 C1 编译,带完整 profiling(收集热度数据)
Level 4 C2 编译,最高优化(内联、逃逸分析、循环展开、死代码消除)
热点探测:方法调用计数器 + 回边计数器
-XX:CompileThreshold 达到阈值 → 触发编译
C1 默认 1500 次,C2 默认 10000 次| 名称 | 全称 | 特点 |
|---|---|---|
| C1 | Client Compiler | 编译快、优化少,适合 GUI/短生命周期应用 |
| C2 | Server Compiler | 编译慢、优化激进,适合长时间运行的服务端应用 |
| Graal | Graal JIT | JDK10+ 实验性,Java 写的 JIT,优化能力更强 |
OSR(On-Stack Replacement,栈上替换):一个方法只调用了一次但里面有超长循环(比如
main方法里跑一个百万次循环),方法调用计数器永远到不了阈值。这时回边计数器会触发编译,并在循环执行过程中把栈帧从解释执行切换到编译执行,这就是 OSR。去优化(Deoptimization):C2 做的是基于假设的激进优化(比如"这个接口只有一个实现,可以直接内联")。如果运行时假设被打破(加载了第二个实现类),编译后的代码必须作废,退回解释执行再重新编译。这就是为什么 JMH 基准测试必须预热(warmup)。
11. JVM 常用参数全清单
堆与分代:
bash
-Xms2g # 初始堆大小(等价 -XX:InitialHeapSize)
-Xmx2g # 最大堆大小(等价 -XX:MaxHeapSize)
# 【生产铁律】-Xms = -Xmx,避免堆反复扩缩容引发 Full GC
-Xmn1g # 新生代大小(等价 -XX:NewSize + -XX:MaxNewSize)
-XX:NewRatio=2 # 老年代 : 新生代 = 2 : 1(新生代占堆的 1/3)
-XX:SurvivorRatio=8 # Eden : 单个 Survivor = 8 : 1
-XX:MaxTenuringThreshold=15 # 晋升老年代的年龄阈值(上限 15,因为只有 4 bit)
-XX:PretenureSizeThreshold=1m # 大对象直接进老年代的阈值(只对 Serial/ParNew 有效)
-Xss512k # 单个线程栈大小(默认 1M,调小能开更多线程)
-XX:MetaspaceSize=256m # 元空间初始/首次触发 GC 的阈值
-XX:MaxMetaspaceSize=256m # 元空间上限【生产必配,否则会吃光物理内存】
-XX:MaxDirectMemorySize=512m # 直接内存上限(默认等于 -Xmx)
-XX:+UseCompressedOops # 开启指针压缩(默认开,堆 < 32G 生效)
-XX:MaxRAMPercentage=70 # JDK10+ 容器友好:按容器 limit 的百分比设堆回收器选择:
bash
-XX:+UseSerialGC # Serial + Serial Old
-XX:+UseParallelGC # Parallel Scavenge + Parallel Old(JDK8 默认)
-XX:+UseParNewGC # ParNew(JDK8 需配合 CMS,JDK9 废弃)
-XX:+UseConcMarkSweepGC # CMS + ParNew(JDK14 已移除)
-XX:+UseG1GC # G1(JDK9+ 默认)
-XX:+UseZGC # ZGC(JDK15+ 正式)
-XX:+ZGenerational # 分代 ZGC(JDK21+)
-XX:+UseShenandoahGC # ShenandoahG1 专属调优参数:
bash
-XX:MaxGCPauseMillis=200 # 期望最大停顿(核心参数,默认 200ms)
-XX:G1HeapRegionSize=16m # Region 大小,1~32MB 且为 2 的幂
-XX:InitiatingHeapOccupancyPercent=45 # 整堆占用达 45% 启动并发标记周期(IHOP)
-XX:G1NewSizePercent=5 # 新生代最小占比(默认 5%)
-XX:G1MaxNewSizePercent=60 # 新生代最大占比(默认 60%)
-XX:G1ReservePercent=10 # 预留空间防止 to-space exhausted
-XX:ParallelGCThreads=8 # STW 阶段的并行 GC 线程数
-XX:ConcGCThreads=2 # 并发阶段的 GC 线程数(一般是 ParallelGCThreads/4)G1 调优的第一原则:别乱调。 G1 是自适应的,只设
-Xmx和-XX:MaxGCPauseMillis两个参数往往就是最优解。手动设-Xmn/-XX:NewRatio会禁用 G1 的自适应新生代调整,反而变差 —— 这是最常见的 G1 调优反模式。
CMS 专属参数:
bash
-XX:CMSInitiatingOccupancyFraction=70 # 老年代占 70% 触发 CMS
-XX:+UseCMSInitiatingOccupancyOnly # 【必配】只按上面的阈值,禁止 JVM 动态调整
-XX:+CMSScavengeBeforeRemark # Remark 前先做 Minor GC,大幅缩短 Remark 停顿
-XX:+CMSClassUnloadingEnabled # 允许 CMS 回收元空间中的类
-XX:+UseCMSCompactAtFullCollection # Full GC 时做碎片整理
-XX:CMSFullGCsBeforeCompaction=5 # 5 次 Full GC 后整理一次GC 日志与诊断:
bash
# JDK8 写法
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps
-XX:+PrintGCApplicationStoppedTime # 打印真实 STW 时间(含 TTSP,很重要)
-XX:+PrintTenuringDistribution # 打印晋升年龄分布,调 MaxTenuringThreshold 用
-XX:+PrintReferenceGC # 打印四种引用的处理耗时
-Xloggc:/data/logs/gc.log
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=50M
# JDK9+ 统一日志(推荐)
-Xlog:gc*,gc+heap=debug,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50M
# 故障现场
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/
-XX:+ExitOnOutOfMemoryError
-XX:ErrorFile=/data/logs/hs_err_pid%p.log # JVM 自身崩溃时的日志易混淆参数对:
-XX:MetaspaceSize不是"最小值",而是首次触发元空间 GC 的水位线。设太小会导致启动阶段频繁 Full GC,建议直接设成和MaxMetaspaceSize一样大。-Xmn和-XX:NewRatio同时设置时,-Xmn优先。-XX:+DisableExplicitGC会让System.gc()变成空操作,但也会破坏 DirectByteBuffer 的内存回收兜底,生产更推荐-XX:+ExplicitGCInvokesConcurrent。
12. GC 日志怎么读
Young GC 日志(JDK8 Parallel):
text
2026-10-24T10:15:32.123+0800: 3.456: [GC (Allocation Failure)
[PSYoungGen: 76800K->8704K(89600K)] 76800K->20480K(294400K), 0.0234567 secs]
[Times: user=0.08 sys=0.00, real=0.02 secs]
逐段拆解:
2026-10-24T10:15:32.123+0800 绝对时间(PrintGCDateStamps)
3.456 JVM 启动后的相对秒数(PrintGCTimeStamps)
GC (Allocation Failure) 触发原因:分配失败 → Eden 满了
PSYoungGen: 76800K->8704K(89600K)
新生代:回收前 → 回收后(新生代总容量)
→ 回收了 68M,剩 8.5M 存活
76800K->20480K(294400K) 整堆:回收前 → 回收后(堆总容量)
→ 整堆只降了 56M,说明有 12M 晋升到了老年代
0.0234567 secs 本次 GC 耗时
user=0.08 sys=0.00 real=0.02 用户态 CPU / 内核态 CPU / 墙上时钟
user > real 是正常的(多线程并行)三个关键推导技巧:
text
① 晋升量 = (新生代回收量) - (整堆回收量)
= (76800-8704) - (76800-20480) = 68096 - 56320 = 11776K ≈ 11.5M
→ 每次 Young GC 晋升 11.5M,这个值越大,老年代被填满越快
② 老年代占用 = 整堆占用 - 新生代占用
回收后老年代 = 20480 - 8704 = 11776K
③ real 远大于 user+sys 时 → 不是 GC 慢,是 CPU 被抢/磁盘 IO 阻塞/swap
→ 典型场景:容器 CPU limit 太小、GC 线程数按宿主机算导致过度切换、
GC 日志写在慢盘上把 GC 线程阻塞住了Full GC 日志:
text
[Full GC (Metadata GC Threshold)
[PSYoungGen: 8704K->0K(89600K)]
[ParOldGen: 180224K->92160K(204800K)] 188928K->92160K(294400K),
[Metaspace: 262144K->261120K(1312768K)], 2.3456789 secs]
关键看 GC 原因(括号里):
Allocation Failure → 堆空间不足
Metadata GC Threshold → 元空间不足【最容易被忽略的 Full GC 原因】
Ergonomics → JVM 自适应策略主动触发
System.gc() → 代码里手动调了
Heap Dump Initiated GC → jmap -dump 触发的
Last ditch collection → 最后的挣扎,下一步就是 OOM
Promotion Failed → 晋升失败(Survivor 和老年代都放不下)
Concurrent Mode Failure → CMS 并发清除失败,退化 Serial Old
To-space exhausted → G1 复制时没有空 RegionG1 日志关键行(JDK11+ 统一日志):
text
[3.456s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 512M->256M(2048M) 25.123ms
[8.901s][info][gc] GC(13) Pause Young (Concurrent Start) (G1 Humongous Allocation) ...
[9.100s][info][gc] GC(14) Concurrent Cycle
[12.34s][info][gc] GC(15) Pause Young (Mixed) (G1 Evacuation Pause) 1024M->512M(2048M) 48.5ms
[20.00s][info][gc] GC(16) Pause Full (G1 Compaction Pause) 2048M->1900M(2048M) 3200ms ← 危险!
看到 Pause Full 就说明 G1 退化了,必须查:
- to-space exhausted(预留空间不够 → 调大 -XX:G1ReservePercent)
- Humongous 对象太多(调大 -XX:G1HeapRegionSize)
- IHOP 太高导致并发标记启动太晚(调低 -XX:InitiatingHeapOccupancyPercent)日志分析工具:把 gc.log 丢给 GCEasy(gceasy.io) 或 GCViewer,几秒钟出吞吐量、停顿分布、内存泄漏趋势图,比肉眼看快一百倍。面试说出工具名是实战经验的直接体现。
13. 线上排障工具链与标准流程
JDK 自带命令(面试必须能脱口而出):
| 命令 | 作用 | 最常用的用法 |
|---|---|---|
jps | 列出 Java 进程 | jps -lvm |
jstat | 实时监控 GC 和内存 | jstat -gcutil <pid> 1000 10(每秒一次共 10 次) |
jmap | 内存映像/堆转储 | jmap -histo:live <pid> | head -30(对象直方图)jmap -dump:live,format=b,file=heap.hprof <pid> |
jstack | 线程栈快照 | jstack -l <pid> > stack.txt |
jinfo | 查看/动态修改 JVM 参数 | jinfo -flags <pid>、jinfo -flag +PrintGC <pid> |
jcmd | 瑞士军刀(推荐) | jcmd <pid> VM.flags、jcmd <pid> GC.heap_infojcmd <pid> Thread.print、jcmd <pid> GC.class_histogram |
jhsdb | JDK9+ 的调试工具 | jhsdb jmap --heap --pid <pid> |
jstat -gcutil 输出逐列解读:
text
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 85.71 62.15 45.32 96.21 92.55 152 1.823 2 0.456 2.279
S0/S1 两个 Survivor 的使用率(%)—— 正常应该一个 0 一个有值
E Eden 使用率
O 老年代使用率 ★ 最关键,持续上涨且 Full GC 后不下降 = 内存泄漏
M 元空间使用率
CCS 压缩类空间使用率
YGC Young GC 次数 YGCT Young GC 总耗时
FGC Full GC 次数 ★ FGCT Full GC 总耗时
GCT GC 总耗时
健康度判断:
平均 Young GC 耗时 = YGCT / YGC → 应 < 50ms
Full GC 频率 = FGC / 运行时长 → 理想是每天 0~1 次
吞吐量 = 1 - GCT / 运行总秒数 → 应 > 95%(最好 > 99%)第三方利器:
| 工具 | 定位 |
|---|---|
| Arthas(阿里) | 线上排障首选。dashboard 看全局、thread -n 3 找最忙线程、jad 反编译、watch 看方法入参返回值、trace 看方法耗时分布、heapdump 转储、vmtool --action getInstances 直接从堆里捞对象 |
| MAT(Eclipse) | 堆转储分析。核心功能:Leak Suspects 报告(自动推断泄漏嫌疑)、Dominator Tree(支配树,看谁撑着内存不放)、Histogram(对象直方图)、Path to GC Roots(查为什么回收不掉) |
| JProfiler / YourKit | 商业 Profiler,CPU/内存/锁 一体化 |
| async-profiler | 基于 perf,低开销火焰图,排 CPU 热点的最佳选择 |
| JFR + JMC | JDK 自带的飞行记录仪,开销 < 2%,可长期开着。jcmd <pid> JFR.start duration=60s filename=rec.jfr |
| VisualVM | 免费可视化,开发期够用 |
| GCEasy / GCViewer | GC 日志可视化分析 |
14. 三个线上实战排查 SOP
① CPU 飙到 100% 怎么查(背熟这五步):
text
1. top 找出占 CPU 最高的进程 PID
2. top -Hp <PID> 找出该进程内占 CPU 最高的线程 TID(十进制)
3. printf "%x\n" <TID> 把线程 ID 转成十六进制(jstack 里是 16 进制)
4. jstack <PID> | grep -A 30 <十六进制TID> 定位到具体的代码行
5. 看栈顶在干什么:
业务代码死循环 / 正则回溯 / 大对象序列化 → 改代码
栈顶全是 GC 线程(GC task thread) → 是 GC 问题,转去查 GC
大量线程 BLOCKED 在同一个锁上 → 锁竞争,查 synchronized 块
一行命令版(面试写这个很亮眼):
jstack $(pgrep -f myapp) | grep -A 20 $(printf "%x" $(ps -mp $(pgrep -f myapp) -o tid,%cpu | sort -k2 -nr | head -2 | tail -1 | awk '{print $1}'))
Arthas 版(最推荐):
thread -n 3 直接列出最忙的 3 个线程及其栈
thread -b 直接找出阻塞其他线程的那个线程② Full GC 频繁怎么查:
text
1. jstat -gcutil <pid> 1000 确认 FGC 增长速度、O 区在 Full GC 后是否下降
Full GC 后 O 降到很低 → 不是泄漏,是"晋升太快"或"堆太小"
Full GC 后 O 仍然很高 → 内存泄漏,走第 3 步
2. 看 GC 日志的 Full GC 原因:
Metadata GC Threshold → 元空间问题,查动态类生成(jcmd GC.class_stats)
System.gc() → 搜代码和依赖库里的显式调用
Allocation Failure → 老年代真的满了
Promotion Failed → Survivor 太小导致提前晋升,调 -Xmn / SurvivorRatio
3. jmap -histo:live <pid> | head -30 看哪类对象数量/字节数异常
4. jmap -dump:live,format=b,file=heap.hprof <pid> 然后用 MAT 分析
注意:jmap -dump 会 STW 且时间和堆大小成正比(8G 堆可能停 10s+)
线上务必先摘流量,或者用 -XX:+HeapDumpOnOutOfMemoryError 等自动 dump
5. MAT → Leak Suspects → Dominator Tree → Path to GC Roots (exclude weak refs)
找到那条"谁一直强引用着它"的链条③ 内存只增不减(泄漏)的高频根因清单:
| 根因 | 具体表现 |
|---|---|
| 静态集合无限增长 | static Map<String, Object> cache 只 put 不 evict |
ThreadLocal 未 remove | 线程池长生命周期线程 + value 是大对象 |
| 监听器/回调未反注册 | 注册了 EventListener 却没有 remove |
| 连接/流未关闭 | Connection/InputStream 没 close,底层 buffer 挂着 |
| 自定义 ClassLoader 泄漏 | 热部署/脚本引擎,旧 ClassLoader 被静态变量引用无法卸载 → Metaspace 持续涨 |
String.intern() 滥用 | 把海量不重复字符串放进常量池(JDK7+ 在堆里,撑大老年代) |
不当的 substring(JDK6) | JDK6 的 substring 共享原数组,小串持有大数组(JDK7 已修) |
| Netty ByteBuf 忘 release | 堆外内存泄漏,堆看起来正常但 RSS 一直涨 |
| 内部类隐式持有外部类 | 非静态内部类/匿名内部类持有 Outer.this,延长外部对象生命周期 |
| 队列无界 | LinkedBlockingQueue 不设容量,生产快于消费 → 堆积 OOM |
15. JVM 调优方法论:不要为了调优而调优
第一原则:调优的前提是有明确的性能指标和问题现象。 没有监控数据就调参数 = 盲人摸象。面试被问"你怎么做 JVM 调优",先说流程,再说参数。
text
标准流程(五步闭环)
1. 定目标 → 量化:P99 延迟 < 100ms?吞吐量 > 5000 QPS?Full GC < 1 次/天?
2. 收数据 → GC 日志 + jstat + APM(Prometheus/SkyWalking)+ 压测数据
3. 定瓶颈 → 是 GC 频率高?单次停顿长?还是根本不是 GC 问题(锁/IO/SQL)?
4. 改一个变量 → 一次只改一个参数,否则无法归因
5. 压测验证 → 对比改前改后的指标,没变好就回滚调优的优先级顺序(重要):
text
优先级 1 代码层面 ← 90% 的"GC 问题"其实是代码问题
查全表返回百万行、循环里 new 大对象、
String 拼接用 +、日志打印大对象、
无界队列/无界缓存、ThreadLocal 不清理
优先级 2 架构层面
加缓存减少对象创建、分页、流式处理、
大对象改用堆外/本地文件
优先级 3 堆大小 ← 最有效的"参数调优"手段
-Xms = -Xmx,大小设为"稳定时老年代占用 × 3~4"
优先级 4 回收器选型
选对回收器比调十个参数都有效
优先级 5 细节参数 ← 收益最小,风险最大
SurvivorRatio / MaxTenuringThreshold / 各种阈值容量规划的经验公式:
text
堆大小 ≈ 稳定态老年代占用 × 3 ~ 4
新生代大小 ≈ 让 Young GC 频率落在 10 秒 ~ 1 分钟一次
Survivor ≈ 保证一次 Young GC 的存活对象能装下(不触发提前晋升)
元空间 ≈ 启动稳定后的占用 × 1.5,上限必须设
容器 -Xmx ≈ 容器 memory limit × 50% ~ 70%(留足堆外空间)一套可直接抄的生产 G1 参数(8C16G 容器,JDK11+):
bash
java \
-Xms8g -Xmx8g \
-XX:MaxMetaspaceSize=512m \
-XX:MaxDirectMemorySize=1g \
-Xss512k \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:+ParallelRefProcEnabled \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseStringDeduplication \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/logs/ \
-XX:+ExitOnOutOfMemoryError \
-XX:ErrorFile=/data/logs/hs_err_pid%p.log \
-Xlog:gc*,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50M \
-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai \
-jar app.jar
-XX:+ParallelRefProcEnabled为什么要加? 默认情况下,GC 的 Reference Processing(处理软/弱/虚引用)是单线程的。如果应用里有大量WeakHashMap/软引用缓存/Netty 的 Cleaner,这一步会成为 GC 停顿的大头。开了之后用多线程处理,很多服务开了这一个参数 GC 停顿就降一半。-XX:+PrintReferenceGC可以验证这一阶段的耗时。
16. class 文件结构与三种"常量池":面试最容易混的概念
前面讲类加载时提到"把常量池里的符号引用替换为直接引用",但常量池到底长什么样、有几个,这是绝大多数人答不清的地方。
class 文件的十项结构(严格按顺序排列,无分隔符):
ClassFile {
u4 magic; // 魔数 0xCAFEBABE,不是 .class 后缀决定的
u2 minor_version; // 次版本号
u2 major_version; // 主版本号:52=JDK8, 61=JDK17, 65=JDK21
u2 constant_pool_count; // 常量池容量,注意从 1 开始计数(0 表示"不引用任何常量")
cp_info constant_pool[]; // 常量池 ★ 占整个 class 文件的 60% 以上
u2 access_flags; // public/final/abstract/interface/annotation...
u2 this_class; // 指向常量池里的本类全限定名
u2 super_class; // 父类(只有 Object 这里是 0)
u2 interfaces_count; u2 interfaces[];
u2 fields_count; field_info fields[]; // 字段表(不含方法内局部变量)
u2 methods_count; method_info methods[]; // 方法表,字节码在 Code 属性里
u2 attributes_count; attribute_info attributes[]; // SourceFile/Signature/BootstrapMethods...
}为什么
major_version高了就跑不起来? JVM 只向下兼容不向上兼容。用 JDK17 编译(61)拿 JDK8(52)跑,验证阶段的"文件格式验证"直接抛UnsupportedClassVersionError,这是类加载的验证阶段失败,不是运行时错误。排查口诀:报的数字减 44 就是 JDK 版本(61-44=17)。
三种常量池,必须分清:
| 名称 | 位置 | 内容 | 生命周期 |
|---|---|---|---|
| Class 常量池(静态常量池) | class 文件里 | 字面量(数字、字符串字面量)+ 符号引用(类/字段/方法的全限定名与描述符) | 文件里的静态数据,不占运行时内存 |
| 运行时常量池 | 方法区 / 元空间,每个类一份 | 类加载后由 Class 常量池解析而来,符号引用会被逐步替换为直接引用 | 跟类一起卸载 |
| 字符串常量池(StringTable) | 堆(JDK7 起),全局唯一一份 | 字符串对象的引用,本质是一个固定大小的 HashTable | 跟堆一起 |
加粗要点: Class 常量池是文件里的静态表,运行时常量池是每个类一份的运行时镜像,字符串常量池是全 JVM 唯一的一张 HashTable。
intern()操作的是第三个,类加载"解析阶段"操作的是第二个,javap -v看到的是第一个。
常量池的 17 种 tag(只需记住最常用的 7 种):
| tag 类型 | javap 里的写法 | 说明 |
|---|---|---|
CONSTANT_Utf8 | Utf8 | 所有名字的最终存储形式,最多 65535 字节 |
CONSTANT_Integer/Float/Long/Double | Integer 等 | 字面量;注意 Long/Double 占两个常量池槽位 |
CONSTANT_String | String | 指向一个 Utf8,是字符串字面量的入口 |
CONSTANT_Class | Class | 类的符号引用 |
CONSTANT_Fieldref | Fieldref | 字段符号引用 = Class + NameAndType |
CONSTANT_Methodref / InterfaceMethodref | Methodref | 方法符号引用 |
CONSTANT_NameAndType | NameAndType | 名字:描述符,如 add:(II)I |
CONSTANT_InvokeDynamic + MethodHandle + MethodType | — | JDK7 为动态语言新增,Lambda 就靠它 |
方法描述符怎么读(面试常手写):
基本类型缩写:B=byte C=char D=double F=float I=int J=long S=short Z=boolean V=void
对象类型: Ljava/lang/String; 数组:[ 前缀,[[I 表示 int[][]
int add(int a, long b) → (IJ)I
String[] split(String s, int limit) → (Ljava/lang/String;I)[Ljava/lang/String;
void main(String[] args) → ([Ljava/lang/String;)V加粗要点: 方法签名不含返回值,但方法描述符含。这就是"为什么 Java 不能靠返回值类型重载"的答案 —— 源码层面签名相同会编译报错;但字节码层面描述符不同就是两个方法,所以泛型擦除后的桥接方法
Object get()和String get()能共存在同一个 class 里。
17. 字节码执行引擎:操作数栈的真实执行过程 + 五条方法调用指令
基础部分说过"操作数栈是字节码的工作台",这里把它跑一遍。看一段最简单的代码:
java
public int calc() {
int a = 10;
int b = 20;
return (a + b) * 10;
}javap -v -p Demo.class 得到的字节码(stack=2, locals=3):
0: bipush 10 // 把常量 10 压入操作数栈 栈:[10]
2: istore_1 // 弹出存入局部变量表 slot1(a) 栈:[] locals: [this, 10]
3: bipush 20 // 栈:[20]
5: istore_2 // 栈:[] locals: [this, 10, 20]
6: iload_1 // 读 slot1 压栈 栈:[10]
7: iload_2 // 栈:[10, 20]
8: iadd // 弹两个相加,结果压回 栈:[30]
9: bipush 10 // 栈:[30, 10]
11: imul // 栈:[300]
12: ireturn // 弹出栈顶作为返回值返回给调用者栈帧加粗要点: JVM 是基于栈的指令集(指令不带操作数地址,靠栈传递),而 x86 是基于寄存器的。基于栈的好处是平台无关、指令紧凑(一字节就够);代价是指令条数多、频繁读写栈导致内存访问多,所以才需要 JIT 把热点字节码编译成寄存器化的机器码。这就是"为什么解释执行慢"的本质答案。
locals 和 stack 两个数字的意义:
| javap 字段 | 含义 | 和哪个 JVM 概念对应 |
|---|---|---|
stack=2 | 操作数栈最大深度,编译期就确定 | 栈帧里操作数栈的大小 |
locals=3 | 局部变量表槽位数(slot),实例方法 slot0 固定是 this | 局部变量表大小,long/double 占 2 个 slot |
args_size=1 | 入参个数(实例方法含隐式 this) | — |
slot 是可以复用的:两个作用域不重叠的局部变量会共用一个 slot。这带来一个真实后果 —— 一个大对象引用放在已经出了作用域但 slot 未被复用的位置上,它就还是 GC Root,不会被回收。所以老代码里会看到
bigObj = null;这种"手动置空"写法,在 slot 未复用的场景下确实有用。
五条方法调用指令(面试高频必背):
| 指令 | 调用什么 | 分派时机 | 典型场景 |
|---|---|---|---|
invokestatic | 静态方法 | 编译期确定 | Math.max() |
invokespecial | 构造器 <init>、私有方法、super.xxx() | 编译期确定 | new User()、super.toString() |
invokevirtual | 普通实例方法 | 运行期查 vtable | user.getName() |
invokeinterface | 接口方法 | 运行期查 itable | List.add() |
invokedynamic | 由引导方法在运行期决定 | 运行期,且可缓存后变成常量 | Lambda、方法引用、字符串拼接(JDK9+) |
加粗要点: 前两条(
invokestatic/invokespecial)加上final方法,被称为非虚方法,在类加载的解析阶段就能把符号引用变成直接引用;后三条是虚方法,必须等到运行期才知道调哪个实现 —— 这就是解析(静态)与分派(动态)的分界线。
为什么接口调用比类调用稍慢? invokevirtual 在 vtable 里是固定索引,一次偏移寻址即可;invokeinterface 因为一个类可以实现多个接口,接口方法在不同实现类里的 vtable 索引不固定,必须搜索 itable。不过 JIT 会用内联缓存(Inline Cache)优化:记住上次的实际类型,下次类型相同就直接跳过查找。所以单态(只有一种实现)时两者性能几乎一致,多态分支多时接口调用才会明显变慢,这也解释了"为什么 if-else 多的巨型方法 JIT 优化不动"。
18. 静态分派 vs 动态分派:重载在编译期定,重写在运行期定
这是 JVM 里最容易混淆的一对概念,而且面试官特别爱用代码题考。先把两个术语的边界划清:
| 静态分派 | 动态分派 | |
|---|---|---|
| 依据 | 静态类型(声明类型) | 实际类型(运行时真正指向的对象类型) |
| 确定时机 | 编译期,javac 就把目标方法写死进常量池 | 运行期,JVM 查 vtable |
| 对应语法 | 方法重载 overload | 方法重写 override |
| 字节码体现 | 常量池里 Methodref 已指向确定的描述符 | 同一条 invokevirtual,实际入口不同 |
❌ 典型错误理解:以为传进去的是子类对象就会调子类重载版本
java
static class Human {}
static class Man extends Human {}
static class Woman extends Human {}
public void say(Human h) { System.out.println("human"); }
public void say(Man m) { System.out.println("man"); }
public void say(Woman w) { System.out.println("woman"); }
public static void main(String[] args) {
Human man = new Man(); // 静态类型 Human,实际类型 Man
Human woman = new Woman();
Demo d = new Demo();
d.say(man); // 输出 human ← 不是 man!
d.say(woman); // 输出 human ← 不是 woman!
}原因:重载的目标方法由 javac 根据【静态类型】选定,
man的静态类型是Human,所以编译出来的字节码就是死的invokevirtual say:(LHuman;)V,运行期不会再改。静态类型在编译期可知且永不改变,实际类型要到运行期才确定 —— 这句话是这类题的万能答案。
✅ 正确理解:重写才是运行期决定的
java
static class Human { void say() { System.out.println("human"); } }
static class Man extends Human { @Override void say() { System.out.println("man"); } }
Human h = new Man();
h.say(); // 输出 man ← 动态分派生效
// 字节码只有一条:invokevirtual Human.say:()V
// 运行期从 h 的对象头 Klass Pointer 找到 Man 的 vtable,取到 Man.say 的入口invokevirtual 运行期的四步(背下来就能答动态分派原理):
① 读操作数栈栈顶的 this 引用 → 通过对象头的 Klass Pointer 找到【实际类型】C
② 在 C 的方法表 vtable 中按【简单名称 + 描述符】查找匹配方法
找到且访问权限通过 → 返回该方法的直接引用
③ 没找到 → 沿继承链向父类重复 ②
④ 一直到 Object 还没找到 → 抛 AbstractMethodErrorvtable(虚方法表)是怎么建的? 在类加载的"链接-准备"阶段之后,JVM 为每个类建一张 vtable:
Human 的 vtable Man 的 vtable
┌───────────────────────┐ ┌───────────────────────┐
│ 0: Object.hashCode │ │ 0: Object.hashCode │ ← 没重写,与父类指向同一入口
│ 1: Object.toString │ │ 1: Man.toString │ ← 重写了,指向自己
│ 2: Human.say │ │ 2: Man.say │ ← 重写了,索引位置【不变】
└───────────────────────┘ └───────────────────────┘
索引 2 相同 ⇒ 调用点可以只记一个固定索引,无需重新搜索加粗要点: 子类 vtable 中重写方法的索引与父类完全一致,这是动态分派能做到"一次偏移寻址"的关键,也是面向对象多态在底层高效的原因。没重写的方法槽位直接复用父类的入口地址,不占额外内存。
两个延伸结论(容易被追问):
- 只有方法有多态,字段没有。字段访问用
getfield且在编译期按静态类型解析,所以父子类同名字段时((Human)man).field拿到的是父类字段 —— 这就是"字段不参与多态,永远不要在子类定义同名字段"的原因。 - 私有方法、static 方法、构造器、final 方法不进 vtable,因为它们不可能被重写,直接走
invokespecial/invokestatic,JIT 也更容易内联。这也解释了为什么给方法加final偶尔能带来性能提升(消除虚调用,便于内联)。
19. invokedynamic 与 Lambda:为什么 Lambda 不是匿名内部类
先破一个最常见的误解:很多人说"Lambda 就是匿名内部类的语法糖",这是错的。两者的字节码完全不同:
| 匿名内部类 | Lambda | |
|---|---|---|
| 编译产物 | 多生成一个 class 文件 Outer$1.class | 不生成额外 class 文件,只生成一个私有静态方法 lambda$main$0 |
| 调用指令 | new + invokespecial(每次创建新对象) | invokedynamic |
| 实例数量 | 每次执行都 new 一个 | 无捕获的 Lambda 全局只有一个实例(被缓存) |
| 对元空间影响 | 固定的类,启动时加载 | 运行期由 LambdaMetafactory 动态生成类,走匿名类加载 |
this 指向 | 内部类自己 | 外层类(Lambda 没有自己的作用域) |
invokedynamic 的执行流程(JDK7 引入,为动态语言准备,最终被 Lambda 用上):
① 字节码里只有一条:invokedynamic #2, 0 → 指向常量池的 CONSTANT_InvokeDynamic
② 该常量项指向 class 文件的 BootstrapMethods 属性,里面写着【引导方法】
通常是 LambdaMetafactory.metafactory(...)
③ 第一次执行该指令时,JVM 调用引导方法,由它在运行期【动态生成】一个
实现了目标函数式接口的类(Outer$$Lambda$1),内部直接调 lambda$main$0
④ 引导方法返回一个 CallSite 对象,里面是 MethodHandle(方法句柄)
⑤ JVM 把这个 CallSite 和该调用点【永久绑定】——后续执行不再走引导方法,
相当于这条指令被"链接"成了一个常量,性能接近直接调用对应的 javap 片段长这样:
// Runnable r = () -> System.out.println("hi");
0: invokedynamic #2, 0 // InterfaceMethod java/lang/Runnable.run:()Ljava/lang/Runnable;
5: astore_1
BootstrapMethods:
0: #26 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:(...)
Method arguments:
#27 ()V
#28 REF_invokeStatic Demo.lambda$main$0:()V ← 真正的方法体在这里
#27 ()V
private static void lambda$main$0(); ← javac 生成的私有静态方法加粗要点: Lambda 的方法体被 javac 抽成了一个私有静态方法(捕获了
this则是私有实例方法),invokedynamic只负责在运行期生成一个"胶水类"把函数式接口方法桥接到它。所以 Lambda 省掉了匿名内部类的 class 文件,但代价是首次执行有引导方法的开销,这也是"大量使用 Lambda 会让应用冷启动变慢"的真实原因(Spring Boot 启动慢的贡献项之一)。
有无变量捕获的巨大差异(性能优化点):
java
// ① 无捕获 —— 全局单实例,不产生垃圾
list.forEach(x -> System.out.println(x));
// ② 捕获了外部变量 —— 每次调用都要 new 一个胶水类实例(捕获值作为构造参数)
int base = 10;
list.forEach(x -> System.out.println(x + base));
// ③ 方法引用,无捕获 —— 最优,连 lambda$ 方法都不用生成
list.forEach(System.out::println);在每秒百万次的热路径上,②这种带捕获的 Lambda 会制造大量短命对象,推高 Young GC 频率。排查手段:async-profiler 的
-e alloc分配火焰图里会看到大量Outer$$Lambda$N的分配。优化方式是把 Lambda 提到循环外当成字段缓存,或者改成无捕获形式。
JDK9+ 的字符串拼接也换成了 invokedynamic:
JDK8 : "a" + b + "c" → 编译成 new StringBuilder().append().append().toString()
JDK9+: "a" + b + "c" → invokedynamic makeConcatWithConstants
由 StringConcatFactory 运行期生成最优拼接代码加粗要点: 这解释了两个现象 —— ① JDK9 以后"循环外拼接字符串用 StringBuilder 更快"的老经验部分失效了(单条表达式拼接已被优化,但循环内拼接仍然必须手写 StringBuilder,因为每次迭代都是一条独立的 indy 调用);② 升级 JDK9+ 后首次字符串拼接会有额外的启动开销,极端追求启动速度的场景可以用
-Djava.lang.invoke.stringConcat=BC_SB退回老策略。
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
题 1:说一下 JVM 的内存分区,每个区域存什么、会抛什么异常?
精简口述答案(90 秒版)
JVM 运行时数据区分线程私有和线程共享两大类。
线程私有三块:程序计数器存当前执行的字节码地址,用于线程切换后恢复现场,是唯一不抛 OOM 的区域;虚拟机栈每个方法调用压一个栈帧,栈帧里有局部变量表、操作数栈、动态链接和返回地址,栈太深抛
StackOverflowError,线程开太多抛 OOM;本地方法栈执行 native 方法用,HotSpot 把它和虚拟机栈合并了。线程共享两块:堆存所有对象实例,是 GC 主战场,分新生代和老年代,抛
Java heap space;方法区存类元信息和运行时常量池,JDK8 之后用元空间实现,放在本地内存,抛Metaspace。另外还有直接内存,不属于运行时数据区但会占物理内存,NIO 和 Netty 在用,抛
Direct buffer memory。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 为什么程序计数器不会 OOM? | 只存一个指令地址(一个指针宽度),大小固定且极小,不需要动态扩展 |
| 执行 native 方法时 PC 的值是什么? | Undefined,因为 native 方法不是字节码,JVM 不负责计数 |
| JDK8 为什么移除永久代? | ① 永久代大小固定难调,动态类多了必 OOM ② 回收必须靠 Full GC,效率低 ③ 向 JRockit 规格对齐 |
| 字符串常量池在哪? | JDK6 在永久代,JDK7 起移到堆。移动原因:永久代空间小且回收不及时,intern()容易 OOM;移到堆后可以被 Minor GC 回收 |
| 静态变量存在哪? | JDK7 之前在永久代,JDK8 起静态变量随 Class 对象存在堆中(Class 对象本身一直在堆里) |
| 元空间不设上限,是不是就不会 OOM? | 恰恰更危险。不设限会吃光物理内存,被 Linux OOM Killer 直接 kill -9,连 heap dump 都来不及生成。生产必配 -XX:MaxMetaspaceSize |
-Xss 设大一点是不是更好? | 不是。总内存固定,-Xss 越大能创建的线程越少。线上 unable to create new native thread 常见原因就是 -Xss 配太大 + 线程池无界 |
| 栈上的局部变量表能存对象吗? | 只存对象引用(reference),对象本体在堆里。除非逃逸分析通过做了标量替换,此时字段被打散成局部变量 |
✅ 项目关联话术
之前负责的一个报表导出服务,上线后偶发
Java heap space。排查发现是导出接口用List一次性查了全表三十多万行再转 Excel,单次请求就吃掉 1.5G 堆。当时做了三层处理:代码层改成游标分页 + SXSSF 流式写出,内存占用从 1.5G 降到 80M;参数层把
-Xms/-Xmx统一设成 4G 避免扩容抖动,并补齐了HeapDumpOnOutOfMemoryError和ExitOnOutOfMemoryError;监控层把 jstat 的 O 区使用率和 FGC 次数接到 Prometheus 并配了告警。这件事让我理解了一个原则:OOM 九成是代码问题,不是参数问题,参数只能延缓,不能治本。
题 2:怎么判断一个对象是垃圾?说一下四种引用的区别
精简口述答案(90 秒版)
JVM 用可达性分析而不是引用计数。因为引用计数虽然判定即时,但无法解决循环引用,两个互相引用的对象计数永远不为 0,会内存泄漏。
可达性分析以一组 GC Roots 为起点向下遍历,能走到的就是活的,走不到的判定为不可达。GC Roots 主要包括:虚拟机栈里的局部变量、方法区的静态属性和常量、本地方法栈的 JNI 引用、被 synchronized 持有的锁对象,以及 JVM 内部引用比如基本类型的 Class 对象和常驻异常对象。
四种引用强度从强到弱是:强引用宁可 OOM 也不回收;软引用内存不足时才回收,适合做缓存;弱引用只要发生 GC 就回收,
ThreadLocalMap的 key 和WeakHashMap在用;虚引用get()永远返回 null,唯一作用是配合ReferenceQueue在对象被回收时收到通知,DirectByteBuffer的 Cleaner 就是靠它释放堆外内存的。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 不可达了就一定被回收吗? | 不一定。有两次标记:第一次标记不可达后,如果覆写了 finalize() 就放进 F-Queue,由低优先级的 Finalizer 线程执行,对象可以在 finalize() 里把 this 赋给 GC Root 自我拯救一次(只能救一次) |
为什么不推荐 finalize()? | 执行时机完全不确定、Finalizer 线程优先级低可能导致 F-Queue 堆积 OOM、里面抛的异常被静默忽略。JDK9 已废弃,改用 try-with-resources 或 Cleaner |
| 软引用一定会在 OOM 前被回收吗? | HotSpot 会按 -XX:SoftRefLRUPolicyMSPerMB 计算存活时间(默认 1000ms/MB 空闲堆),空闲堆越大活得越久。但在抛 OOM 之前一定会清理掉所有软引用 |
| ThreadLocal 为什么会内存泄漏? | ThreadLocalMap 的 Entry key 是弱引用、value 是强引用。ThreadLocal 对象被回收后留下 key=null, value=大对象 的 Entry,而 value 被 Thread → ThreadLocalMap → Entry 强引用链持有。线程池里的核心线程永不销毁 → 永久泄漏。解法:finally 里 remove() |
| 那为什么 key 要设计成弱引用? | 是一种补救而非根治。如果 key 是强引用,ThreadLocal 对象本身也回收不掉,泄漏更严重。弱引用至少让 set/get/remove 时的 expungeStaleEntry() 有机会清理掉 key=null 的 Entry |
| 虚引用怎么实现"回收通知"? | 构造时必须传 ReferenceQueue。对象被回收时,JVM 把这个 Reference 对象放入队列,业务代码 poll() 到就知道对象没了,可以做后续清理。DirectByteBuffer 的 Cleaner 继承 PhantomReference,回调里调用 unsafe.freeMemory() |
| 可达性分析必须 STW 吗? | 枚举 GC Roots 这一步必须 STW(否则根集合会变),这是初始标记阶段。后续的遍历对象图可以并发(CMS/G1 的并发标记),代价是要用增量更新或 SATB 处理漏标 |
✅ 项目关联话术
我们有个风控服务,用线程池处理规则计算,每个请求通过
ThreadLocal透传上下文。上线两周后发现老年代缓慢上涨,重启就好,典型的内存泄漏特征。用
jmap -dump导出堆,在 MAT 里看 Dominator Tree,发现ThreadPoolExecutor下面挂着 200 个Thread,每个Thread的threadLocals里都有key=null的 Entry,value 是几 MB 的规则上下文对象,加起来两个多 G。根因就是线程池的核心线程永不销毁 + ThreadLocal 用完没 remove。修复方案是把上下文清理放到拦截器的
finally里强制remove(),并在代码规范里加了一条"ThreadLocal 必须成对出现 set/remove",同时在 CI 里加了 SonarQube 规则做静态检查。
题 3:CMS 和 G1 的区别?什么情况下会触发 Full GC?
精简口述答案(90 秒版)
CMS 是只管老年代的并发回收器,必须配 ParNew 管新生代,用的是标记清除算法,分四个阶段:初始标记 STW、并发标记、重新标记 STW、并发清除。它的三大问题是内存碎片、CPU 竞争导致吞吐下降、以及并发清除期间老年代被填满会退化成单线程 Serial Old,停顿从几十毫秒飙到十几秒。
G1 是整堆回收器,把堆切成两千多个大小相等的 Region,分代变成逻辑概念,一个 Region 在不同时刻可以当 Eden、Survivor 或 Old。它的核心是可预测停顿模型:统计每个 Region 的回收价值,按
MaxGCPauseMillis的时间预算从价值最高的开始挑进 CSet,所以叫 Garbage First。回收用复制算法所以天然无碎片。另外两者处理并发标记漏标的方案不同:CMS 用增量更新 + 写后屏障,G1 用 SATB 原始快照 + 写前屏障。
Full GC 的触发:
System.gc()、老年代空间不足、元空间不足、Minor GC 晋升量超过老年代剩余连续空间(空间分配担保失败)、CMS 的 concurrent mode failure、G1 的 to-space exhausted。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 为什么 G1 用 SATB 而不是增量更新? | 增量更新要把整个黑色对象重新置灰再扫一遍,在 Region 数量多的 G1 上代价不可控;SATB 只需记录"被删除的旧引用",最终标记阶段的停顿更短更可预测。代价是浮动垃圾更多 |
| G1 的 RSet 和卡表什么关系? | 卡表是全局一份的 points-out 结构(我指向了谁);G1 的 RSet 是每个 Region 一份的 points-into 结构(谁指向了我),底层仍用卡表实现。RSet 内存开销可达堆的 10%~20%,这是 G1 的主要成本 |
| G1 一定比 CMS 好吗? | 不一定。小堆(< 4~6G)下 CMS 的停顿往往更小,因为 G1 的 RSet 维护和写屏障有固定开销。G1 的优势在大堆、无碎片、停顿可控 |
| G1 会 Full GC 吗? | 会,而且是单线程的 Serial Full GC(JDK10 才改成并行),极其慢。触发原因:复制存活对象时没有空 Region(to-space exhausted / Evacuation Failure)、Humongous 分配失败、并发标记启动太晚 |
| 怎么避免 G1 退化 Full GC? | ① 调低 -XX:InitiatingHeapOccupancyPercent(让并发标记早点开始)② 调大 -XX:G1ReservePercent(默认 10%,预留空间)③ 调大 -XX:G1HeapRegionSize 减少 Humongous 对象 ④ 不要手动设 -Xmn,它会禁用自适应 |
| Minor GC 和 Full GC 哪个更可怕? | Full GC。Minor GC 只扫新生代且存活率低(复制算法效率和存活对象数成正比),通常几毫秒到几十毫秒;Full GC 要扫整堆 + 元空间,8G 堆轻松几秒,上游全超时 |
| 什么是 Major GC?和 Full GC 一样吗? | 概念混乱点。严格说 Major GC 只回收老年代(只有 CMS 有这种独立的老年代回收),Full GC 回收整堆 + 方法区。面试时先声明自己的口径再答,不要被绕进去 |
| G1 的核心调优参数有几个? | 理想状态只需两个:-Xmx 和 -XX:MaxGCPauseMillis。手动设 -Xmn、-XX:NewRatio、-XX:SurvivorRatio 会关掉 G1 的自适应新生代调整,是最常见的反模式 |
✅ 项目关联话术
我们的订单查询服务原来是 JDK8 + ParNew + CMS,堆 6G。线上症状是每两三天出现一次 10 秒以上的停顿,监控上表现为接口 P99 突刺、上游熔断。
拿 gc.log 丢进 GCEasy,看到那次长停顿的日志里写着
concurrent mode failure,后面跟着[Full GC (Allocation Failure) ... 9.8 secs]。再往前翻,老年代在 Full GC 后只回收到 70%,说明是碎片化导致大对象分配不到连续空间。做了两步:先把
CMSInitiatingOccupancyFraction从默认的动态调整改成固定 68% 并加上UseCMSInitiatingOccupancyOnly,让 CMS 提前启动,长停顿频率从三天一次降到两周一次;然后趁升级 JDK11 的窗口直接换成 G1,只配了-Xms8g -Xmx8g -XX:MaxGCPauseMillis=200,P99 从 800ms 降到 120ms,再没出现过秒级停顿。这个过程让我明白:CMS 的碎片问题是算法层面的,调参只能缓解,换回收器才是根治。
题 4:说一下类加载的过程和双亲委派机制,什么情况下会破坏它?
精简口述答案(90 秒版)
类的生命周期是加载、验证、准备、解析、初始化、使用、卸载,中间三步合称连接。
加载是通过全限定名拿到字节流并在堆里生成 Class 对象;验证做文件格式、元数据、字节码、符号引用四类校验;准备给 static 变量分配内存并设零值(注意
static final的编译期常量在这一步就赋真实值了);解析把常量池的符号引用替换成直接引用;初始化执行<clinit>(),也就是 static 变量赋值和 static 代码块,JVM 保证它加锁且只执行一次,这就是静态内部类单例线程安全的底层原因。双亲委派是:类加载请求先向上委派给父加载器,父加载器加载不了才由自己加载。顺序是 App → Ext → Bootstrap,然后反向回落。作用有两个:安全性,自己写的
java.lang.String不可能替换掉 JDK 的;唯一性,类只会被加载一次,而且类的唯一标识是全限定名 + ClassLoader 实例。破坏双亲委派的三个场景:SPI/JDBC 用线程上下文类加载器反向委派;Tomcat 的 WebappClassLoader 先自己找再委派父,实现应用间隔离;OSGi 和热部署把加载器做成网状结构。
连环追问链
| 追问 | 答题要点 |
|---|---|
| "双亲"这个翻译准确吗? | 不准确。原文是 Parents Delegation Model,这里的 parent 是单个父加载器(组合关系,不是继承),应该叫"父委派"。而且 ExtClassLoader 和 AppClassLoader 之间是组合不是继承,它们都直接继承 URLClassLoader |
| 自定义类加载器要覆写哪个方法? | 覆写 findClass(),不要覆写 loadClass()。因为双亲委派逻辑写在 loadClass() 里,覆写它就破坏了委派;findClass() 是在"父加载器都加载不了"之后才被调用的钩子 |
| 怎么主动破坏双亲委派? | 覆写 loadClass() 不调用 super.loadClass(),直接自己 findClass。Tomcat 就是这么做的(但对 javax.servlet.* 等规范包仍走标准委派) |
| 为什么 JDBC 需要线程上下文类加载器? | DriverManager 在 rt.jar 里由 Bootstrap 加载,但它要实例化的 com.mysql.cj.jdbc.Driver 在 classpath 里,Bootstrap 根本看不到。所以用 Thread.currentThread().getContextClassLoader() 拿到 AppClassLoader 来加载,形成反向委派 |
| 类什么时候会被卸载? | 三个条件同时满足:① 该类的所有实例都被回收 ② 加载它的 ClassLoader 被回收 ③ 它的 Class 对象没有被任何地方引用(不能通过反射访问)。所以 JDK 自带的类基本永不卸载(Bootstrap 不会被回收) |
Class.forName() 和 ClassLoader.loadClass() 区别? | Class.forName() 默认执行初始化(跑 <clinit>),loadClass() 只加载不初始化。这就是为什么老代码注册 JDBC 驱动必须用 Class.forName(驱动的 static 块里做注册);Spring 的 BeanFactory 用 loadClass 做懒加载 |
| 三种被动引用不触发初始化,分别是? | ① 子类访问父类静态字段(只初始化父类)② 通过数组定义引用类 new Parent[10](只初始化数组类型)③ 访问 static final 编译期常量(已内联到调用方常量池) |
| 为什么静态内部类单例是线程安全的? | 因为 JVM 保证 <clinit>() 的执行有类初始化锁,多线程同时触发时只有一个能执行,其他阻塞等待。这是 JVM 层面的保证,不需要写任何 synchronized |
✅ 项目关联话术
做过一个规则引擎,支持运营在后台写 Groovy 脚本动态改风控规则,不用发版。
一开始的实现是每次规则变更就
new GroovyClassLoader()加载新脚本。上线一周后发现 Metaspace 从 200M 涨到了 900M 并且触发了 Full GC,日志里 Full GC 原因写的是Metadata GC Threshold。用
jcmd <pid> GC.class_stats一看,同一个规则类被加载了上千份,每份一个独立的 ClassLoader。根因是我们用一个静态 Map 缓存了编译后的脚本实例,强引用链让旧的 ClassLoader 永远回收不掉,而 ClassLoader 不回收 → 它加载的类就不能卸载 → Metaspace 只涨不降。修复方案:改成按脚本内容 MD5 做 key 缓存(内容不变就复用同一个 ClassLoader)+ 用
WeakReference持有实例 + 配上-XX:MaxMetaspaceSize=512m做兜底。这次排查让我彻底理解了**"类卸载的三个条件"和"ClassLoader 泄漏"**不是八股文,是真会出事的。
题 5:线上服务 Full GC 频繁,你怎么一步步定位?
精简口述答案(90 秒版)
我的排查路径是先确认现象、再分类归因、最后定位到代码。
第一步用
jstat -gcutil <pid> 1000实时看,关键看两个指标:FGC 的增长速度和老年代 O 在 Full GC 之后是否降下来。如果降到很低,说明不是泄漏,是"晋升太快"或者"堆太小";如果 Full GC 后 O 还在 80% 以上,就是内存泄漏。第二步看 GC 日志里 Full GC 的触发原因。
Metadata GC Threshold说明是元空间问题,要查动态类生成;System.gc()就去搜代码和三方库;Promotion Failed说明 Survivor 太小导致提前晋升;Allocation Failure才是老年代真满了。第三步
jmap -histo:live <pid> | head -30看对象直方图,哪类对象的数量或字节数明显异常。第四步
jmap -dump:live,format=b,file=heap.hprof导出堆,用 MAT 分析,看 Leak Suspects 报告和 Dominator Tree,对可疑对象右键 Path to GC Roots → exclude weak/soft references,找出那条"谁一直强引用着它"的链。最后结合代码定位到具体的静态集合、未关闭资源或未清理的 ThreadLocal。
连环追问链
| 追问 | 答题要点 |
|---|---|
jmap -dump 可以在线上随便执行吗? | 不行。jmap -dump 会触发 STW,耗时和堆大小正相关(8G 堆可能停 10 秒以上),而且 :live 参数还会先做一次 Full GC。线上应该:① 先从负载均衡摘掉这个节点 ② 或依赖 -XX:+HeapDumpOnOutOfMemoryError 自动 dump ③ 或用 jhsdb jmap 对 core dump 离线分析 |
| 堆 dump 文件太大打不开怎么办? | ① MAT 调大 MemoryAnalyzer.ini 里的 -Xmx ② 用 MAT 的命令行模式 ParseHeapDump.sh 在服务器上生成索引和报告,只把报告下载回来 ③ 用 jmap -histo 先缩小范围,不一定非要 dump |
| 为什么要 exclude weak/soft references? | 弱引用和软引用不会阻止回收,如果不排除,MAT 会把这些本来可以被回收的路径也列出来,干扰判断。真正的泄漏一定是强引用链造成的 |
| Full GC 后老年代降下来了,但还是频繁,怎么办? | 说明不是泄漏而是对象晋升速率太高。查:① 新生代是不是太小(Young GC 太频繁,对象没来得及死就被晋升)② MaxTenuringThreshold 是不是太小 ③ Survivor 是不是太小触发动态年龄判定 ④ 代码里是不是在造大对象直接进老年代 |
| 怎么判断"晋升速率"? | 从 GC 日志推导:晋升量 = 新生代回收量 − 整堆回收量。用 -XX:+PrintTenuringDistribution 能直接看到各年龄的对象大小分布 |
| 内存泄漏和内存溢出的区别? | 泄漏(Leak)是不再使用的对象无法被回收,是原因;溢出(OOM)是内存不够用抛异常,是结果。泄漏累积到一定程度必然溢出,但溢出不一定是泄漏导致(也可能单次申请太大) |
| 如果不让用 jmap/jstack 呢? | 用 Arthas:dashboard 看全局内存和 GC、memory 看各区详情、heapdump 转储、vmtool --action getInstances --className X --limit 10 直接从堆里捞对象看内容。或者长期开着 JFR(开销 < 2%),事后用 JMC 回放分析 |
✅ 项目关联话术
有次大促前压测,网关服务跑了 20 分钟就开始每分钟一次 Full GC,吞吐量掉了一半。
jstat -gcutil看到 O 区在 Full GC 后仍维持在 85%,判定为泄漏。jmap -histo:live第一行就很可疑:byte[]有 180 万个实例占 2.1G。dump 下来用 MAT 看 Dominator Tree,发现一个自己写的LocalCache静态ConcurrentHashMap撑着 2G,里面缓存了每个请求的完整响应体。根因是这个缓存只 put 没有过期和淘汰策略,开发时只测了几十个 key,压测时 key 是请求参数的组合,基数爆炸。
修复用 Caffeine 替换,配了
maximumSize(10000)和expireAfterWrite(5, MINUTES)。顺手还做了两件事:把-XX:+HeapDumpOnOutOfMemoryError加进了所有服务的启动模板;在代码评审清单里加了一条"任何缓存/队列/集合必须有容量上界"。
题 6:说说三色标记,并发标记为什么会漏标,怎么解决?
精简口述答案(90 秒版)
三色标记是并发可达性分析的理论模型:白色表示还没访问过,标记结束仍为白色的就是垃圾;灰色表示自己被访问过但成员引用还没扫完,在待处理队列里;黑色表示自己和所有成员引用都扫完了,确定存活。
并发标记时用户线程还在改引用,会产生两个问题。多标是把已经变成垃圾的对象标成了黑色,产生"浮动垃圾",这个可以容忍,下次 GC 回收就行。漏标是把本该存活的对象留成了白色然后回收掉,这个是致命的,会直接导致程序崩溃。
漏标的发生需要同时满足两个条件:一是赋值器插入了一条黑色对象指向白色对象的新引用(黑色不会再被扫描),二是删除了所有灰色对象到该白色对象的引用(白色再也没机会被扫到)。
所以解法就是破坏其中任一个条件:增量更新破坏第一个 —— 黑色对象新增指向白色的引用时,把黑色对象重新变灰重新扫描,CMS 用这个;原始快照 SATB 破坏第二个 —— 删除引用时把旧引用记录下来,标记结束后以这些旧引用为根再扫一遍,G1 用这个。两者都通过写屏障实现,增量更新用写后屏障,SATB 用写前屏障。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 写屏障是什么?和内存屏障一样吗? | 完全不一样。GC 的写屏障是 JVM 在引用类型字段赋值的字节码前后插入一小段机器码做记录(AOP 式的切面);内存屏障(Memory Barrier)是 CPU 指令,用于禁止指令重排和刷新缓存,解决可见性/有序性 |
| 增量更新和 SATB 哪个浮动垃圾多? | SATB 更多。SATB 的语义是"标记开始时活着的就算活着",期间死掉的对象这一轮不会被回收。增量更新是重新扫一遍,判定更精确但停顿可能更长 |
| 为什么 CMS 的 Remark 阶段要 STW? | 增量更新把变脏的黑色对象记下来了,但重新扫描这些对象时不能再被用户线程干扰,否则会无限循环。所以必须 STW 一次性修正完。这也是 CMS 四阶段里第二个 STW 点 |
-XX:+CMSScavengeBeforeRemark 为什么能缩短停顿? | Remark 要扫描"新生代到老年代的跨代引用"。先做一次 Minor GC 把新生代里的死对象清掉,需要扫描的跨代引用就少很多,Remark 的停顿可以降一个量级。但代价是多了一次 Minor GC 的停顿 |
| ZGC 为什么不需要写屏障? | ZGC 把标记信息放在指针的高位(染色指针)而不是对象头,且用读屏障在读引用时检查并修正(自愈)。读屏障的开销比写屏障大(读远多于写),换来的是并发整理能力 |
| 并发整理为什么比并发标记难? | 标记只是"读"对象图,不改内存;整理要移动对象,移动过程中用户线程可能正拿着旧地址访问。解法要么像 ZGC 用读屏障 + 转发表自愈,要么像 Shenandoah 给每个对象加转发指针做间接寻址 |
✅ 项目关联话术
这个知识点我是在一次 G1 调优中真正理解的。当时服务从 CMS 换到 G1 后,Young GC 停顿从 30ms 降到了 15ms,但吞吐量反而掉了约 8%。
一开始以为是参数问题,后来查到 G1 的 SATB 写前屏障 + RSet 维护本身是有固定成本的 —— 每次引用赋值都要走屏障代码,而我们这个服务是对象引用操作极密集的图计算场景。
最后的结论是:停顿变短和吞吐下降是同一个机制的两面,不是哪个参数配错了。我们接受了这个 trade-off,因为业务对 P99 延迟比对吞吐更敏感。后来又加了
-XX:+ParallelRefProcEnabled(我们有大量软引用缓存),把引用处理从单线程改成并行,把这 8% 的损失补回了一半。
题 7:有哪几种 OOM?分别怎么排查?
精简口述答案(90 秒版)
按内存区域分,最常见的是四类。
堆溢出,报
Java heap space,原因是对象太多活太久,排查靠 dump + MAT 找强引用链。栈溢出,报
StackOverflowError,绝大多数是递归没有正确的终止条件,少数是单帧太大或-Xss太小;如果是创建线程时报unable to create new native thread,那是线程数太多,和-Xss、系统ulimit -u、容器 pid limit 都有关。元空间溢出,报
Metaspace,典型场景是动态代理/CGLib/Groovy 脚本/反射大量生成类,或者应用反复热部署导致 ClassLoader 泄漏。直接内存溢出,报
Direct buffer memory,常见于 NIO、Netty 的ByteBuf没 release,特点是堆 dump 里看不出问题,要靠 NMT(-XX:NativeMemoryTracking=detail)或pmap看 RSS 和堆的差值。另外还有两个容易忽略的:
GC overhead limit exceeded(GC 占用超 98% 时间却回收不到 2% 内存,本质还是堆不够或泄漏)和Requested array size exceeds VM limit(申请的数组长度超过Integer.MAX_VALUE附近的限制)。
连环追问链
| 追问 | 答题要点 |
|---|---|
StackOverflowError 和 OutOfMemoryError 都是 Error,能 catch 吗? | 语法上能 catch,StackOverflowError catch 住后栈已经回退,程序通常还能继续跑;但 OutOfMemoryError catch 住基本没意义,因为连创建异常处理所需的对象都可能失败,而且已经有泄漏在持续恶化。生产上正确做法是配 -XX:+ExitOnOutOfMemoryError 让进程直接退出,由 K8s 重启 |
| 容器里进程被 OOMKilled,但没有 Java 的 OOM 日志,为什么? | 这是 cgroup 层面的 OOM,不是 JVM 的。JVM 的总内存 = 堆 + 元空间 + 线程栈 × 线程数 + 直接内存 + 代码缓存 + GC 自身结构,只配 -Xmx 等于堆,但容器 limit 管的是全部。堆配到容器 limit 的 50%~70% 比较安全,或用 -XX:MaxRAMPercentage=70 |
JDK 8 早期版本在容器里 -Xmx 不配会怎样? | 它读的是宿主机的物理内存(通过 /proc/meminfo),默认堆 = 物理内存 1/4。宿主机 128G 的话默认堆就是 32G,必然 OOMKilled。JDK 8u191+ 和 JDK 10+ 默认开启 -XX:+UseContainerSupport 才会读 cgroup 的 limit |
| 怎么确认是直接内存泄漏? | ① top 看进程 RES 远大于 -Xmx + 元空间的和 ② 开 -XX:NativeMemoryTracking=summary 后 jcmd <pid> VM.native_memory summary 看 Internal/Other 区 ③ Netty 应用开 -Dio.netty.leakDetection.level=paranoid 让它主动报告未 release 的 ByteBuf |
为什么设置 -XX:+DisableExplicitGC 可能引发直接内存 OOM? | DirectByteBuffer 的内存靠 Cleaner(虚引用)在对象被回收时释放,而 JDK 内部在直接内存不足时会主动调 System.gc() 来触发这个回收。禁用显式 GC 后这条路断了,直接内存就只能等 Full GC 自然发生。正确做法是改用 -XX:+ExplicitGCInvokesConcurrent |
元空间 OOM 了,加大 MaxMetaspaceSize 能解决吗? | 只能延缓。如果是动态生成类没有上界(比如每个请求 new 一个 GroovyClassLoader),加多少都会满。必须找到"谁在无限造类",让 ClassLoader 可被回收或复用 |
GC overhead limit exceeded 能关掉吗? | 可以用 -XX:-UseGCOverheadLimit 关掉,但强烈不建议 —— 关掉之后不是不 OOM 了,而是从"快速失败"变成"长时间假死",服务表现为完全无响应但进程还活着,对监控和排查更不友好 |
✅ 项目关联话术
我们有个风控规则引擎用 Groovy 做动态规则,上线三天后开始报
java.lang.OutOfMemoryError: Metaspace。现象很有特征:堆内存一直很健康,元空间却持续单向上涨,
jstat -gc看MU从 80M 涨到 512M 封顶。用
jmap -clstats看类加载器统计,发现有三万多个GroovyClassLoader实例。根因是每次规则执行都new GroovyShell().evaluate(script),每次都创建新的 ClassLoader 和新的 Class,而这些 ClassLoader 被规则执行上下文的静态 Map 强引用着,永远无法卸载。修复分两层:代码层用
Map<规则MD5, Script>缓存编译后的 Script 对象,相同规则只编译一次;兜底层给元空间加了-XX:MaxMetaspaceSize=512m显式上界 +-XX:MetaspaceSize=256m避免启动期频繁扩容 GC,并对MU/MC比例配了告警。这件事之后我在团队分享里讲了"类卸载的三个条件",现在我们所有涉及动态类生成的地方都会先问一句"这个 ClassLoader 什么时候能被回收"。
题 8:说一次你的 JVM 调优经历,参数是怎么定的?
精简口述答案(90 秒版)
我的原则是先有指标再调参,不凭感觉改。
第一步定目标。调优只有三个方向:延迟、吞吐、内存占用,三者不可能同时最优,必须让业务方明确哪个优先。我们这个服务是 C 端接口,目标定为 P99 < 100ms,GC 停顿贡献不超过 20ms。
第二步建立基线。接入 GC 日志 + Prometheus 的 JVM 监控,拿到调优前的 Young GC 频率、平均停顿、Full GC 次数、晋升速率。
第三步才是改参数,而且一次只改一个变量,改完压测观察,有效才保留。
第四步确认收益和代价,比如换 G1 后停顿降了但吞吐掉了,要确认这个代价能接受。
最后是固化:把参数写进启动模板、把关键 GC 指标加进告警。
我始终认为绝大多数所谓 GC 问题的根因在代码里,参数只能缓解。所以调参之前我一定先看一遍有没有大对象、没上界的缓存、没关的资源。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 一台 8C16G 的机器,你怎么配? | 堆 -Xms8g -Xmx8g 固定避免动态扩容;回收器用 G1 配 -XX:MaxGCPauseMillis=100;元空间 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m;GC 日志 -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=50M;兜底 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump。预留约 6G 给元空间、线程栈、直接内存和系统 |
为什么 -Xms 要等于 -Xmx? | ① 避免运行期堆扩容/缩容带来的额外 Full GC 和停顿 ② 容器环境内存是独占的,没有"省着给别人用"的必要 ③ 让堆使用率曲线更平稳,便于监控和容量判断 |
MaxGCPauseMillis 设成 10ms 会怎样? | G1 为了满足目标会自动缩小新生代,导致 Young GC 更频繁、晋升更快、老年代更容易满,最终反而触发 Mixed GC 甚至 Full GC,整体吞吐大幅下降。这个值是软目标,一般不要低于 50ms |
G1 下还需要配 -Xmn 吗? | 不要配。G1 的新生代大小是动态自适应的(受 G1NewSizePercent/G1MaxNewSizePercent 约束),显式配 -Xmn 会让自适应失效,等于废掉了 G1 的核心能力之一 |
| 怎么量化调优效果? | 看四组数据对比:① GC 总停顿时间占运行时间的比例(GC 吞吐率,目标 > 99%)② Young GC 的 P99 停顿 ③ Full GC 次数(理想是 0)④ 业务侧接口的 P99/P999 延迟。只有业务指标改善了才算调优成功 |
| 什么情况下你会选择不调优? | ① Full GC 频率极低(比如一周一次)且停顿在业务容忍范围内 ② 瓶颈明显不在 GC(CPU 打满在业务计算、DB 慢查询)③ 收益不确定但改动风险高。调优是手段不是目的 |
| 现在新项目你默认选什么回收器? | JDK 8 用 G1(8G 以上堆)或 Parallel(小堆、批处理);JDK 11+ 默认 G1 不动;JDK 17+ 如果对延迟极度敏感且堆大,直接上 ZGC(-XX:+UseZGC),它已经转正且支持分代(JDK 21 的 -XX:+ZGenerational) |
✅ 项目关联话术
我接手过一个商品详情聚合服务,QPS 约 3000,JDK 8 + 默认的 Parallel 回收器,堆 4G。问题是接口 P99 有明显毛刺,每隔几秒跳到 300ms 以上。
建立基线后发现毛刺和 Young GC 完全对齐:Young GC 每 2 秒一次,每次停顿 180ms 左右。进一步看日志,
-XX:+PrintTenuringDistribution显示大量对象在年龄 1 就被晋升,Survivor 区(只有 128M)明显不够。我做了三步,每步单独压测:
- 先改代码:
jmap -histo发现HashMap和String[]异常多,查到是聚合逻辑里每个商品都 new 了一个带全量字段的大 Map。改成按需字段 + 对象复用,对象分配速率直接降了 40%,这一步贡献最大。- 再调分代:堆提到 6G,
-Xmn给到 2.5G,Survivor 比例从默认调为-XX:SurvivorRatio=6,晋升率显著下降。- 最后换 G1:
-XX:+UseG1GC -XX:MaxGCPauseMillis=100,去掉-Xmn让它自适应。最终 P99 从 300ms+ 降到 85ms,Full GC 从每天 2 次变成 0 次,吞吐有约 3% 的下降但业务完全可接受。
这次经历让我最大的收获是:第一步的代码优化效果超过后面两步调参的总和。所以现在别人找我看 GC 问题,我第一句话一定是"先把对象直方图发我看看"。
题 9:JVM 是怎么知道该调用哪个方法的?说说字节码、分派和 invokedynamic
精简口述答案(90 秒版)
分三层回答。
第一层,class 文件。 字节码由魔数
0xCAFEBABE开头,主体是常量池,占文件六成以上,里面存字面量和符号引用(类名、方法名加描述符)。javap -v能看到这些结构,也能看到每个方法的Code属性和stack(操作数栈最大深度)、locals(局部变量表槽位数)。第二层,执行引擎。 JVM 是基于栈的指令集,比如
a+b就是iload_1、iload_2、iadd三条指令在操作数栈上完成。方法调用有五条指令:invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic。前两条加上final方法属于非虚方法,在类加载的解析阶段就把符号引用换成了直接引用,叫做静态分派,典型是重载,javac 编译期按静态类型就定死了。第三层,运行期的动态分派。
invokevirtual执行时先通过对象头的 Klass Pointer 找到实际类型,再查这个类的虚方法表 vtable,按名称加描述符找到入口,找不到就沿父类往上找。子类重写方法的 vtable 索引和父类完全一致,所以只是一次偏移寻址,这是多态高效的底层原因。最后补一句体现深度的:Lambda 不是匿名内部类的语法糖,它走
invokedynamic加LambdaMetafactory,运行期动态生成胶水类桥接到一个私有静态方法lambda$xxx$0,不产生额外 class 文件;无捕获的 Lambda 全局只有一个实例。
连环追问链
| 追问 | 答题要点 |
|---|---|
| 为什么 Java 不能根据返回值类型重载? | 方法签名不含返回值,但方法描述符含。源码层面签名相同 javac 直接报错;字节码层面 get()I 和 get()Ljava/lang/String; 其实是两个方法,所以泛型桥接方法才能共存 |
重载题:Human h = new Man(); say(h) 调哪个? | 调 say(Human)。重载由 javac 按静态类型决定,编译完就是死的 invokevirtual say:(LHuman;)V,运行期不会再选。静态类型编译期可知,实际类型运行期才定 |
| 静态分派和动态分派的依据分别是什么? | 静态分派看静态类型(编译期),对应重载;动态分派看实际类型(运行期),对应重写。分界线是"非虚方法走解析、虚方法走分派" |
| vtable 什么时候建、怎么保证高效? | 在类加载链接阶段建好,每个类一张。重写方法沿用父类的槽位索引,未重写的直接指向父类入口,所以调用点只需一次偏移寻址,无需遍历查找 |
| 私有方法和 static 方法会被重写吗? | 不会。它们走 invokespecial/invokestatic,不进 vtable,也不可能有多态。给方法加 final 能消除虚调用、便于 JIT 内联,这才是"final 提升性能"的真实原因 |
| 字段为什么没有多态? | 字段访问用 getfield,编译期按静态类型解析,不走 vtable。父子类同名字段时 ((Human)man).field 拿到的是父类字段,所以永远不要在子类定义同名字段 |
| 接口调用为什么比类调用慢? | invokevirtual 是 vtable 固定索引一次寻址;invokeinterface 因一个类可实现多接口,索引不固定,要搜索 itable。但 JIT 有内联缓存,单态时几乎无差,多态分支多时才明显变慢 |
| Lambda 到底生成了什么? | 不生成 class 文件,javac 把方法体抽成私有静态方法 lambda$main$0,字节码一条 invokedynamic 指向 BootstrapMethods 里的 LambdaMetafactory.metafactory。首次执行调引导方法生成胶水类并返回 CallSite,之后该调用点长期绑定,不再走引导 |
| 用 Lambda 有性能坑吗? | 有。有变量捕获的 Lambda 每次执行都要 new 一个胶水实例,热路径上会制造大量短命对象推高 Young GC(async-profiler -e alloc 能看到 $$Lambda$N 分配);无捕获的全局单实例,方法引用最优 |
| JDK9 的字符串拼接改了什么? | + 从 StringBuilder 链改成 invokedynamic makeConcatWithConstants,由 StringConcatFactory 运行期生成最优代码。但循环内拼接仍必须手写 StringBuilder |
UnsupportedClassVersionError 属于哪个阶段? | 属于类加载的验证阶段(文件格式验证),不是运行时异常。口诀:major_version - 44 = JDK 版本,61 是 JDK17、52 是 JDK8 |
✅ 项目关联话术(按真实经历替换)
之前接手一个脚本规则引擎,用户可以上传 Groovy 脚本,在服务里动态编译执行。用 JDK17 编译出来的 Artifact 丢到线上 JDK8 容器里,启动直接报
UnsupportedClassVersionError: 61,当时第一反应以为是打包插件问题,其实是主版本号不兼容,JVM 只向下兼容。后来统一了编译的maven.compiler.release和运行镜像版本。另一次排查接口 P99 毛刺,async-profiler 的分配火焰图里发现每秒有几百万个
OrderQueryService$$Lambda$14对象。查代码是个高频回调里写了list.forEach(x -> handle(x, userId)),因为捕获了userId,每次调用都 new 一个胶水类实例。改成把 Lambda 提到成员变量、用无捕获形式后,Young GC 频率从 8 秒一次降到 20 秒一次。这两件事让我把"javap -v 看一眼"变成了习惯。现在改完热点方法我会先反编译确认没多出意外的
new和装箱,比看源码靠谱。
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:手写四种 OOM 的复现代码
三个必记点
- 每种 OOM 要配对应的 JVM 参数才能快速复现,否则可能跑几十分钟。
- 堆 OOM 一定要用集合持有引用,否则对象创建完立刻变垃圾,永远不会 OOM。
- 栈溢出是
StackOverflowError(Error 不是 OOM),但"创建线程失败"报的是OutOfMemoryError,两者都和栈有关但原因完全不同。
java
/**
* 1. 堆溢出
* VM args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* 报错: java.lang.OutOfMemoryError: Java heap space
*/
public class HeapOOM {
static class OOMObject {
// 让每个对象占点空间,加快复现
private final byte[] payload = new byte[1024];
}
public static void main(String[] args) {
// 关键:必须用集合持有强引用,否则对象立刻可回收
List<OOMObject> list = new ArrayList<>();
int count = 0;
while (true) {
list.add(new OOMObject());
if (++count % 1000 == 0) {
System.out.println("created = " + count);
}
}
}
}
/**
* 2. 栈溢出(递归无终止)
* VM args: -Xss256k
* 报错: java.lang.StackOverflowError
*/
public class StackSOF {
private int depth = 0;
private void recursive() {
depth++;
recursive(); // 没有终止条件
}
public static void main(String[] args) {
StackSOF sof = new StackSOF();
try {
sof.recursive();
} catch (StackOverflowError e) {
// StackOverflowError 是可以 catch 的,栈已回退
System.out.println("stack depth = " + sof.depth);
}
}
}
/**
* 3. 元空间溢出(CGLib 无限生成代理类)
* VM args: -XX:MaxMetaspaceSize=32m
* 报错: java.lang.OutOfMemoryError: Metaspace
*/
public class MetaspaceOOM {
static class Bean {}
public static void main(String[] args) {
int count = 0;
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Bean.class);
// 关闭缓存,强制每次生成新的 Class
enhancer.setUseCache(false);
enhancer.setCallback(
(MethodInterceptor) (obj, method, as, proxy) -> proxy.invokeSuper(obj, as));
enhancer.create();
if (++count % 100 == 0) {
System.out.println("classes = " + count);
}
}
}
}
/**
* 4. 直接内存溢出
* VM args: -Xmx20m -XX:MaxDirectMemorySize=10m
* 报错: java.lang.OutOfMemoryError: Direct buffer memory
* 特点:堆 dump 里完全看不出异常,这是它最难排查的地方
*/
public class DirectOOM {
public static void main(String[] args) {
List<ByteBuffer> hold = new ArrayList<>();
while (true) {
// allocateDirect 走的是堆外内存,不受 -Xmx 限制
hold.add(ByteBuffer.allocateDirect(1024 * 1024));
System.out.println("allocated MB = " + hold.size());
}
}
}边界 Case 自测清单
| 场景 | 预期表现 |
|---|---|
HeapOOM 把 list.add 去掉 | 永远不会 OOM,对象创建后立刻成垃圾,Young GC 就回收了 |
HeapOOM 把 List 换成 WeakHashMap 的 key | 也不会 OOM,弱引用在内存不足前就会被回收 |
StackSOF 加大 -Xss 到 2m | 递归深度会显著变大(几千 → 几万),但仍会溢出 |
StackSOF 改成创建大量线程 | 报 OutOfMemoryError: unable to create new native thread,且可能直接把操作系统拖死,务必在测试机跑 |
MetaspaceOOM 把 setUseCache(false) 去掉 | CGLib 会复用已生成的 Class,不会 OOM |
DirectOOM 不配 MaxDirectMemorySize | 默认等于 -Xmx,复现变慢;配了才能快速触发 |
DirectOOM 加上 -XX:+DisableExplicitGC | 更快 OOM,因为 JDK 内部触发 Cleaner 回收的 System.gc() 被禁用了 |
四个程序都加 -XX:+HeapDumpOnOutOfMemoryError | 只有 堆 OOM 和元空间 OOM 会生成有价值的 dump,直接内存 OOM 的 dump 看不出问题 |
变种拓展题
- 写一个程序,证明
String.intern()在 JDK 6 和 JDK 7+ 的行为差异(字符串常量池从永久代搬到了堆)。 - 写一个程序,用
-XX:+PrintGCDetails观察一个 4MB 的byte[]在-XX:PretenureSizeThreshold=3m下是否直接进入老年代。 - 写一个程序,验证
-XX:MaxTenuringThreshold=1和默认值 15 时,对象晋升行为的差异。
题 2:手写自定义类加载器 + 实现类的热替换
三个必记点
- 重写
findClass而不是loadClass,这样能保留双亲委派;只有确实要破坏委派时才重写loadClass。 - 同一个类被不同 ClassLoader 加载,是两个完全不同的类,
instanceof为 false,强转会抛ClassCastException。 - 热替换的本质是换一个新的 ClassLoader,旧的 ClassLoader 连同它加载的所有类一起等待被回收。
java
/**
* 自定义文件类加载器:从指定目录加载 .class 文件
*/
public class FileClassLoader extends ClassLoader {
private final String rootDir;
public FileClassLoader(String rootDir, ClassLoader parent) {
super(parent); // 指定父加载器,维持双亲委派
this.rootDir = rootDir;
}
/**
* 只重写 findClass:父加载器找不到时才会走到这里
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytes = loadClassBytes(name);
if (bytes == null) {
throw new ClassNotFoundException(name);
}
// defineClass 是 final 的,负责把字节数组变成 Class 对象
return defineClass(name, bytes, 0, bytes.length);
}
private byte[] loadClassBytes(String name) {
// com.demo.Hello -> rootDir/com/demo/Hello.class
String path = rootDir + File.separator
+ name.replace('.', File.separatorChar) + ".class";
File file = new File(path);
if (!file.exists()) {
return null;
}
try (InputStream in = new FileInputStream(file);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = in.read(buf)) != -1) {
out.write(buf, 0, len);
}
return out.toByteArray();
} catch (IOException e) {
return null;
}
}
}
/**
* 热替换演示:每次重新加载都 new 一个新的 ClassLoader
*/
public class HotSwapDemo {
public static void main(String[] args) throws Exception {
String dir = "/tmp/classes";
String className = "com.demo.Rule";
for (int i = 0; i < 3; i++) {
// 关键:每一轮都创建全新的 ClassLoader
FileClassLoader loader =
new FileClassLoader(dir, HotSwapDemo.class.getClassLoader());
Class<?> clazz = loader.loadClass(className);
Object instance = clazz.getDeclaredConstructor().newInstance();
Method m = clazz.getMethod("execute");
System.out.println("round " + i + " -> " + m.invoke(instance));
System.out.println(" loader = " + clazz.getClassLoader());
System.out.println(" hashCode = " + System.identityHashCode(clazz));
// 此时修改并重新编译 Rule.java,下一轮就会加载到新版本
TimeUnit.SECONDS.sleep(10);
}
}
}边界 Case 自测清单
| 场景 | 预期表现 |
|---|---|
两轮加载的 clazz 用 == 比较 | false,identityHashCode 不同,是两个独立的 Class 对象 |
| 把加载到的对象强转为主程序里 import 的同名类 | 抛 ClassCastException,这是自定义类加载器最经典的坑 |
把 com.demo.Rule 也放在 classpath 下 | 加载不到新版本,因为双亲委派会让 AppClassLoader 先加载成功 |
尝试加载 java.lang.String | 永远由 BootstrapClassLoader 加载;若自己写一个 java.lang.Hack 则抛 SecurityException: Prohibited package name |
把 loader 变量存进一个 static List | 旧 ClassLoader 永远不被回收,元空间持续上涨,这就是热部署内存泄漏的根因 |
new FileClassLoader(dir, null) | 父加载器变成 Bootstrap,java.lang.Object 还能加载到,但三方 jar 全找不到 |
同一个 loader 实例重复 loadClass 同一个类 | 第二次直接从 findLoadedClass 缓存返回,不会重新读文件,所以热替换必须换 loader |
变种拓展题
- 给
FileClassLoader加上字节码加密解密:class 文件落盘前做异或加密,loadClassBytes里解密后再defineClass。 - 实现一个破坏双亲委派的 loader(重写
loadClass,自己目录优先、找不到才委派给父加载器),模拟 Tomcat 的 WebAppClassLoader。 - 用
Instrumentation+retransformClasses实现真正的类重定义(不换 ClassLoader,但只能改方法体,不能增删字段方法)。
题 3:手写基于软引用的本地缓存 + 引用队列清理
三个必记点
- 软引用在内存不足时才回收,弱引用只要 GC 就回收,做缓存用软引用,做"附加数据/不影响生命周期"用弱引用。
- 引用对象本身也占内存,
SoftReference被清空后这个壳还在 Map 里,必须靠ReferenceQueue清理,否则泄漏的是 key 和壳。 - 继承
SoftReference把 key 带上,否则从队列里拿到被回收的引用时,不知道该删 Map 里哪个 key。
java
/**
* 软引用缓存:内存充足时命中缓存,内存紧张时自动让 GC 回收 value
*/
public class SoftCache<K, V> {
/** 带 key 的软引用,便于清理时反查 */
private static class Entry<K, V> extends SoftReference<V> {
final K key;
Entry(K key, V value, ReferenceQueue<? super V> queue) {
super(value, queue);
this.key = key;
}
}
private final Map<K, Entry<K, V>> map = new ConcurrentHashMap<>();
private final ReferenceQueue<V> queue = new ReferenceQueue<>();
public void put(K key, V value) {
expungeStaleEntries();
map.put(key, new Entry<>(key, value, queue));
}
public V get(K key) {
expungeStaleEntries();
Entry<K, V> e = map.get(key);
if (e == null) {
return null;
}
V v = e.get();
if (v == null) {
// value 已被回收,顺手清掉这个空壳
map.remove(key, e);
}
return v;
}
/**
* 核心:从引用队列里取出已被 GC 清空的引用,删除对应的 Map 条目
*/
@SuppressWarnings("unchecked")
private void expungeStaleEntries() {
Entry<K, V> stale;
while ((stale = (Entry<K, V>) queue.poll()) != null) {
// 用两参数 remove,避免误删期间被重新 put 的新值
map.remove(stale.key, stale);
}
}
public int size() {
expungeStaleEntries();
return map.size();
}
}边界 Case 自测清单
| 场景 | 预期表现 |
|---|---|
不实现 expungeStaleEntries | value 被回收了,但 key 和空壳 Entry 永久留在 Map 里,内存持续增长 |
把 SoftReference 换成 WeakReference | 一次 Young GC 缓存就全空,命中率接近 0,不适合做缓存 |
-Xmx20m 下连续 put 大对象 | 软引用会在 OOM 前被批量回收,size() 骤降,但不会抛 OOM |
用 map.remove(stale.key)(单参数) | 存在并发风险:A 线程刚 put 了新值,B 线程清理旧壳时会误删新值 |
| 多线程并发 put/get | ConcurrentHashMap 保证结构安全,但 expungeStaleEntries 可能被多个线程同时执行,queue.poll() 本身线程安全,可接受 |
| value 持有对 key 的强引用 | 缓存永远不会被回收,因为 Map 的 key 强引用着 Entry,Entry 的 value 又强引用 key,形成回路 |
JVM 配了 -XX:SoftRefLRUPolicyMSPerMB=0 | 软引用在每次 GC 时都被回收,行为退化成弱引用 |
变种拓展题
- 改造成 LRU + 软引用双层:热点数据用强引用的 LinkedHashMap 保底,冷数据降级为软引用。
- 实现一个
WeakHashMap的简化版,理解它为什么是 key 弱引用而不是 value 弱引用,以及 value 强引用 key 时的经典泄漏。 - 用虚引用
PhantomReference+Cleaner实现一个堆外内存自动释放的 Buffer 包装类,对比finalize()的弊端。
题 4:手写一个会内存泄漏的 ThreadLocal 示例并修复
三个必记点
ThreadLocalMap的 key 是弱引用,value 是强引用,key 被回收后形成null -> value的无效条目。- 泄漏的根源是线程长期存活,线程池里的核心线程永不销毁,
ThreadLocalMap就一直在。 remove()必须写在finally里,只靠set(null)不行,那只是把 value 置空,Entry 本身还在。
java
public class ThreadLocalLeakDemo {
/** 每个 Context 持有 1MB,放大泄漏效果 */
static class Context {
byte[] payload = new byte[1024 * 1024];
String traceId;
Context(String traceId) { this.traceId = traceId; }
}
// 注意:这里刻意不声明为 static final,模拟"用完就丢引用"的错误写法
// VM args: -Xmx64m -XX:+PrintGCDetails
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(4);
// ❌ 泄漏版本
for (int i = 0; i < 100; i++) {
final int idx = i;
pool.submit(() -> {
ThreadLocal<Context> holder = new ThreadLocal<>();
holder.set(new Context("trace-" + idx));
doBusiness(holder.get());
// 方法结束,holder 这个强引用没了
// -> ThreadLocalMap 里的 key(弱引用) 下次 GC 被回收变成 null
// -> 但 value(Context, 1MB) 被 Entry 强引用,线程不死就不释放
});
}
Thread.sleep(3000);
System.gc();
System.out.println("泄漏版本执行完毕,观察老年代占用");
pool.shutdown();
}
private static void doBusiness(Context ctx) {
// 业务逻辑
}
}
/**
* ✅ 正确版本:static final + try-finally remove
*/
public class ThreadLocalCorrect {
// 1. static final:ThreadLocal 对象本身全局唯一,不随方法结束失效
private static final ThreadLocal<Context> CONTEXT = new ThreadLocal<>();
static class Context {
String traceId;
Context(String traceId) { this.traceId = traceId; }
}
public static void handle(String traceId) {
CONTEXT.set(new Context(traceId));
try {
doBusiness();
} finally {
// 2. 必须 remove,这是线程池场景下的硬性要求
CONTEXT.remove();
}
}
private static void doBusiness() {
Context ctx = CONTEXT.get();
System.out.println(Thread.currentThread().getName() + " -> " + ctx.traceId);
}
}边界 Case 自测清单
| 场景 | 预期表现 |
|---|---|
泄漏版本跑 100 次,-Xmx64m | 老年代持续上涨,4 个核心线程各挂着若干个 1MB 的 Context,最终 OOM |
把线程池换成 newCachedThreadPool 且线程会超时回收 | 泄漏会自愈,线程销毁时整个 ThreadLocalMap 一起被回收 |
只写 CONTEXT.set(null) 不写 remove() | value 置空了,但 Entry 还在 table 数组里,key 为 null 的脏条目累积 |
remove() 不放在 finally 里,业务抛异常 | remove 被跳过,异常路径下必然泄漏,这是最常见的线上写法缺陷 |
父线程 set 后子线程 get() | 取不到。要跨线程传递需用 InheritableThreadLocal,但线程池场景下它也不可靠(线程复用时不会重新继承),要用阿里的 TransmittableThreadLocal |
| 同一线程对同一 ThreadLocal 连续 set 两次 | 覆盖旧值,旧 value 失去引用可被回收,不会累积 |
| 大量不同的 ThreadLocal 实例 + 长生命周期线程 | ThreadLocalMap 的 table 数组不断扩容,哈希冲突用线性探测,性能和内存双下降 |
变种拓展题
- 阅读
ThreadLocalMap.set()源码,说明replaceStaleEntry和expungeStaleEntry做了什么"被动清理"。 - 对比
ThreadLocalMap的线性探测和HashMap的链地址法,解释为什么 ThreadLocal 选线性探测。 - 实现一个简化版
TransmittableThreadLocal,在任务提交时快照父线程的上下文,在任务执行前后做 replay 和 restore。
题 5:手写对象晋升观察实验 + GC 日志逐行解读
三个必记点
-XX:+PrintTenuringDistribution是看晋升问题的唯一直观手段,能看到每个年龄段的对象大小和动态年龄阈值。- 动态年龄判定:Survivor 中相同年龄对象总和超过 Survivor 空间一半时,大于或等于该年龄的对象直接晋升,不等
MaxTenuringThreshold。 - 大对象直接进老年代由
-XX:PretenureSizeThreshold控制,但它只对 Serial 和 ParNew 生效,Parallel Scavenge 不认这个参数。
java
/**
* 对象晋升观察实验
* VM args:
* -Xms40m -Xmx40m -Xmn20m -XX:SurvivorRatio=8
* -XX:+UseSerialGC
* -XX:MaxTenuringThreshold=15
* -XX:+PrintGCDetails -XX:+PrintTenuringDistribution
*
* 分代布局:Eden=16m, S0=2m, S1=2m, Old=20m
*/
public class TenuringDemo {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
// 两个 256KB 的对象,总和 512KB 未超过 Survivor(2MB) 的一半
byte[] a1 = new byte[_1MB / 4];
byte[] a2 = new byte[_1MB / 4];
// 触发第一次 Young GC:a1/a2 存活,age 变成 1,进入 Survivor
byte[] big1 = new byte[4 * _1MB];
big1 = null;
// 触发第二次 Young GC:a1/a2 age 变成 2
byte[] big2 = new byte[4 * _1MB];
big2 = null;
byte[] big3 = new byte[4 * _1MB];
big3 = null;
System.out.println("done");
// 把 a1/a2 改成 1MB 各一个(总和 2MB >= Survivor 的一半)
// 再跑一次,观察它们是否在 age=1 就被直接晋升到老年代
}
}典型 Young GC 日志逐段解读
text
2026-10-24T14:32:10.123+0800: 15.456:
[GC (Allocation Failure)
Desired survivor size 1048576 bytes, new threshold 2 (max 15)
- age 1: 524304 bytes, 524304 total
- age 2: 524312 bytes, 1048616 total
[PSYoungGen: 16384K->1024K(18432K)]
17408K->2048K(38912K), 0.0085431 secs
]
[Times: user=0.03 sys=0.00, real=0.01 secs]| 片段 | 含义 |
|---|---|
2026-10-24T14:32:10.123+0800 | GC 发生的绝对时间,由 -XX:+PrintGCDateStamps 输出,排障时必加 |
15.456 | JVM 启动后的相对秒数,用来算 GC 间隔和频率 |
GC (Allocation Failure) | GC 表示 Young GC(Full GC 才是全堆);括号里是触发原因,Allocation Failure 指 Eden 分配失败 |
Desired survivor size 1048576 | 期望的 Survivor 使用量 = Survivor 总大小 × TargetSurvivorRatio(默认50%) |
new threshold 2 (max 15) | 动态年龄阈值被调整为 2,说明 Survivor 压力大,对象提前晋升。这是晋升问题最关键的一行 |
- age 1: 524304 bytes | 年龄 1 的对象占 512KB;累计 total 超过 Desired survivor size 时阈值就会被压低 |
PSYoungGen: 16384K->1024K(18432K) | 新生代回收前 16MB → 回收后 1MB,括号内 18MB 是新生代总容量(Eden + 1 个 Survivor) |
17408K->2048K(38912K) | 整堆回收前 17MB → 回收后 2MB,括号内 38MB 是堆总容量 |
0.0085431 secs | 本次 GC 的停顿时间,Young GC 超过 50ms 就要关注 |
user=0.03 sys=0.00 real=0.01 | user 是多线程 CPU 时间总和,real 是墙钟时间。real 远大于 user 要警惕:可能是 CPU 被抢、磁盘 IO 阻塞(日志写盘)或发生了 swap |
推导技巧
- 晋升量 = 新生代回收量 − 整堆回收量 =
(16384-1024) - (17408-2048)= 0K,本次没有晋升。 - Young GC 频率 = 两条日志的相对时间差;对象分配速率 = Eden 大小 ÷ Young GC 间隔。
- 老年代增长速率 = 相邻两次 Young GC 后的整堆占用差值,用它能预估多久会触发一次 Full GC。
边界 Case 自测清单
| 场景 | 预期表现 |
|---|---|
把 a1/a2 改成各 1MB(总和 2MB) | 触发动态年龄判定,日志里 new threshold 1,两个对象在 age=1 就晋升 |
把 -XX:MaxTenuringThreshold 改成 1 | 对象经历一次 Young GC 就晋升,老年代增长明显加快 |
加上 -XX:PretenureSizeThreshold=3m + UseSerialGC | 4MB 的 big1 直接分配到老年代,不经过 Eden,Young GC 次数减少 |
同样参数但换成 -XX:+UseParallelGC | PretenureSizeThreshold 失效,大对象仍然先进 Eden |
把 -XX:SurvivorRatio 从 8 改成 2 | Survivor 变大(各 5MB),动态年龄判定更难触发,晋升减少,但 Eden 变小导致 Young GC 更频繁 |
| 换成 G1 后跑同样代码 | 看不到 PSYoungGen 这种分代名,日志变成 Region 粒度;-Xmn 应该去掉 |
JDK 11+ 用 -Xlog:gc*:stdout:time,uptime,level,tags | 统一日志格式,输出带 [gc,heap]、[gc,phases] 等 tag,筛选更方便 |
变种拓展题
- 写一个脚本解析 GC 日志,统计每分钟的 GC 次数、总停顿时间、GC 吞吐率(或直接用 GCViewer / GCEasy 对比自己的计算结果)。
- 同一段代码分别用 Serial、Parallel、CMS、G1 跑一遍,对比总停顿时间和总运行时间,验证"吞吐优先"和"延迟优先"的差异。
- 用
-XX:+PrintReferenceGC观察软/弱/虚引用的处理耗时,验证-XX:+ParallelRefProcEnabled的效果。
题 6:用 javap -v 反编译验证重载、重写、Lambda 与字符串拼接
三个必记点
- 一切关于"编译期还是运行期"的争论,
javap -v一跑就有答案,不要靠背。 - 常用命令:
javap -v -p Demo.class(-v看常量池与字节码,-p含私有成员),加-c只看字节码,-l看行号表。 - 编译时务必加
-g保留局部变量名,否则LocalVariableTable为空。
实验代码
java
// Dispatch.java —— 一个文件覆盖四个验证点
import java.util.function.Supplier;
public class Dispatch {
/* ① 验证静态分派(重载) */
static class Human {
void say() { System.out.println("human.say"); }
}
static class Man extends Human {
@Override void say() { System.out.println("man.say"); }
}
static void greet(Human h) { System.out.println("greet(Human)"); }
static void greet(Man m) { System.out.println("greet(Man)"); }
/* ② 验证操作数栈 */
int calc(int a, int b) { return (a + b) * 10; }
/* ③ 验证动态分派 */
void dynamic(Human h) { h.say(); }
/* ④ 验证 Lambda 与字符串拼接 */
Supplier<String> noCapture() { return () -> "const"; }
Supplier<String> withCapture(int n) { return () -> "n=" + n; }
public static void main(String[] args) {
Human man = new Man(); // 静态类型 Human,实际类型 Man
greet(man); // 预期输出 greet(Human)
new Dispatch().dynamic(man); // 预期输出 man.say
}
}执行与观察步骤
bash
javac -g Dispatch.java
javap -v -p Dispatch.class > out.txt # 完整结构
javap -c -p Dispatch.class # 只看字节码,更清爽
# ① 在 main 里找这一行,确认重载是编译期定死的:
# invokestatic Dispatch.greet:(LDispatch$Human;)V ← 不是 (LDispatch$Man;)V
# ② 在 calc 里确认操作数栈序列:
# iload_1 / iload_2 / iadd / bipush 10 / imul / ireturn
# 方法头部还能看到 stack=2, locals=3, args_size=3
# ③ 在 dynamic 里确认动态分派只有一条虚调用指令:
# invokevirtual Dispatch$Human.say:()V ← 运行期查 vtable 找到 Man.say
# ④ 在 noCapture / withCapture 里确认 Lambda 走 indy:
# invokedynamic #N, 0 // InterfaceMethod get:()...
# 文件末尾的 BootstrapMethods 指向 LambdaMetafactory.metafactory
# 私有静态方法 lambda$noCapture$0 / lambda$withCapture$1 才是真正的方法体
# ⑤ 看 withCapture 的描述符差异(关键!):
# lambda$withCapture$1(I) ← 捕获的 n 作为参数传入,说明每次都要构造实例
# ⑥ 对比字符串拼接(同一份源码换 JDK 编译):
# JDK8 → new StringBuilder / append / toString
# JDK9+ → invokedynamic makeConcatWithConstants运行输出(证明分派结论)
text
greet(Human) ← 重载按【静态类型】选,编译期已定
man.say ← 重写按【实际类型】选,运行期查 vtable边界 Case 自测清单
| 场景 | 预期表现 |
|---|---|
把 greet(man) 改成 greet((Man) man) | 字节码变成 invokestatic greet:(LDispatch$Man;)V,输出 greet(Man)。强转改变的是静态类型 |
给 Man.say() 加 final 后反编译 | 调用指令仍是 invokevirtual(指令不变),但该方法不再可重写,JIT 可直接内联。说明"非虚方法"是语义概念,不一定体现在指令上 |
把 Human.say() 改成 private | dynamic() 里直接编译报错;若改成本类内调用,指令变为 invokespecial,不进 vtable |
把 Human 换成 interface | 调用指令从 invokevirtual 变成 invokeinterface,查 itable 而非固定索引 |
用 javap -c 看 withCapture 的返回 | 能看到 iload_1 先把捕获值压栈再 invokedynamic,印证捕获值通过构造参数传入胶水类 |
| 把 Lambda 改成匿名内部类 | 编译目录里多出 Dispatch$1.class,字节码变成 new + invokespecial,不再有 BootstrapMethods |
把 Lambda 改成方法引用 String::valueOf | 仍是 invokedynamic,但不再生成 lambda$xxx$N 私有方法,引导方法直接指向目标方法句柄 |
不加 -g 编译后再反编译 | LocalVariableTable 消失,变量名显示为 slot1/slot2,排障时很难读 |
| 用 JDK17 编译后用 JDK8 运行 | 启动即抛 UnsupportedClassVersionError: ... version 61.0,属于验证阶段失败;javap -v 头部的 major version: 61 可提前确认 |
在循环里写 s += i 后反编译(JDK9+) | 每次迭代都是一条独立的 invokedynamic,仍然是 O(n²) 的拼接,证明"循环内必须手写 StringBuilder" |
变种拓展题
- 写一个父子类带同名字段
name的例子,反编译确认字段访问用getfield且按静态类型解析,验证"字段没有多态"。 - 用
javap -v找到<clinit>和<init>,确认static变量赋值和static块被合并进<clinit>、实例字段初始化被合并进<init>,且顺序与源码书写顺序一致。 - 写一个返回
List<String>的方法并被子类用ArrayList<String>协变返回,反编译找到 桥接方法(ACC_BRIDGE, ACC_SYNTHETIC),验证"描述符不同即为两个方法"。 - 用
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining跑一段热点循环,观察final方法与虚方法的内联差异。 - 用
-XX:+PrintCompilation配合-Xint(纯解释)和默认模式各跑一次,对比耗时,直观感受 JIT 的收益。
⚠️ 当日踩坑实录
| ❌ 坑 | ✅ 解决方案 |
|---|---|
以为"JVM 内存 = 堆",容器 limit 和 -Xmx 配成一样大,结果频繁 OOMKilled 且没有任何 Java OOM 日志 | 总内存 = 堆 + 元空间 + 线程栈×线程数 + 直接内存 + CodeCache + GC 结构。堆配到 limit 的 50%~70%,或用 -XX:MaxRAMPercentage=70 |
JDK 8 早期版本在容器里不配 -Xmx,默认堆读到了宿主机内存的 1/4 | 升级到 8u191+ 或 JDK 10+(默认开 -XX:+UseContainerSupport),并显式配 -Xmx |
| 以为方法区 / 永久代就是元空间,答"元空间在堆里" | 方法区是规范,永久代/元空间是实现。永久代在堆里受 -Xmx 限制,元空间在本地内存里 |
以为 new 的对象一定在堆上 | 开启逃逸分析后,未逃逸的小对象可能栈上分配 + 标量替换,根本没有对象实体 |
以为 String s = new String("a") 创建了一个对象 | 创建两个:常量池里的 "a"(若首次出现)+ 堆上的 String 实例 |
对象头的 GC 年龄字段只有 4 bit,却把 MaxTenuringThreshold 配成 20 | 上限是 15,配大了会被静默截断,参数等于没生效 |
以为对象年龄到 MaxTenuringThreshold 才晋升 | 还有动态年龄判定:同龄对象总和超 Survivor 一半,>= 该年龄全部晋升 |
| 以为两个 Survivor 是"为了多存一点" | 是为了复制算法无碎片:From 和 To 交替,每次把存活对象紧凑复制到空的那个 |
把 -XX:SurvivorRatio 理解成 Survivor 占新生代的比例 | 它是 Eden : 单个 Survivor 的比值,默认 8 表示 8:1:1 |
| 堆内存超过 32G 后,发现实际可用对象数反而变少 | 超过 32G 会关闭指针压缩,引用从 4 字节变 8 字节。要么压到 31G 以内,要么一次性配到 40G 以上 |
| 以为引用计数法能解决循环引用 | 不能,这正是 JVM 不用引用计数而用可达性分析的核心原因 |
| 以为"没有引用指向的对象"立刻被回收 | 还要经过两次标记,若重写了 finalize() 还有一次自救机会(但 finalize 已废弃,不要依赖) |
把 finalize() 当资源释放钩子用 | 执行时机不确定、优先级低、异常被吞、可能导致对象复活。用 try-with-resources 或 Cleaner |
用 WeakHashMap 做缓存,发现数据总是丢 | 弱引用每次 GC 都回收。做缓存要用软引用或直接上 Caffeine |
用 SoftReference 做缓存但不清理 ReferenceQueue | value 回收了,key 和引用壳永久留在 Map 里,泄漏依然存在 |
ThreadLocal 只 set 不 remove,在线程池里跑 | key 是弱引用被回收后留下 null -> value 脏条目,核心线程不死就不释放。remove() 必须写在 finally |
remove() 写在了 try 块末尾而不是 finally | 业务抛异常时被跳过,异常路径必然泄漏 |
在线程池里用 InheritableThreadLocal 传递上下文 | 线程复用时不会重新继承,拿到的是上一个任务的值。用 TransmittableThreadLocal |
| 以为 Full GC 就是"老年代满了" | 还有:元空间不足、System.gc()、空间分配担保失败、CMS concurrent mode failure、晋升失败、jmap -histo:live |
代码里或三方库里调了 System.gc() | 配 -XX:+DisableExplicitGC 禁用,或 -XX:+ExplicitGCInvokesConcurrent 让它走并发收集 |
配了 -XX:+DisableExplicitGC,结果 NIO 服务报 Direct buffer memory | DirectByteBuffer 依赖 System.gc() 触发 Cleaner 释放。改用 -XX:+ExplicitGCInvokesConcurrent |
| 直接内存泄漏时反复 dump 堆,怎么也找不到问题 | 堆 dump 看不到堆外内存。用 NMT(jcmd VM.native_memory)、pmap 看 RSS 差值、Netty 开 leakDetection.level=paranoid |
CMS 频繁出现 concurrent mode failure,停顿飙到几秒 | 并发标记期间老年代被填满,退化为 Serial Old 单线程 Full GC。调低 CMSInitiatingOccupancyFraction(如 70)并配 +UseCMSInitiatingOccupancyOnly |
只配了 CMSInitiatingOccupancyFraction 没配 UseCMSInitiatingOccupancyOnly | JVM 只把它当首次触发阈值,之后会自行动态调整,参数形同失效 |
CMS 跑久了报 promotion failed | 老年代碎片化导致大对象放不下。配 -XX:+UseCMSCompactAtFullCollection + CMSFullGCsBeforeCompaction=0,长期方案是换 G1 |
换到 G1 后还保留着 -Xmn 或 CMS 的参数 | -Xmn 会破坏 G1 新生代自适应;CMS 参数在 G1 下无效甚至启动报错。换回收器要清空旧参数重新配 |
G1 的 MaxGCPauseMillis 配成 10ms,以为停顿一定更短 | 这是软目标,配太小会导致新生代被压得极小 → Young GC 极频繁 → 晋升加快 → 反而触发 Mixed/Full GC |
| 大对象频繁分配,G1 下出现 Humongous 相关的 Full GC | 超过 Region 一半的对象走 Humongous 分配。调大 -XX:G1HeapRegionSize,或从代码上消除大对象 |
| 以为并发标记只会多标不会漏标 | 漏标需同时满足"黑对象新增白引用"和"灰对象删除该白引用"。增量更新(CMS)和 SATB(G1)分别破坏其中一个 |
| 把 GC 的写屏障和 CPU 的内存屏障混为一谈 | 写屏障是 JVM 在引用赋值处插入的记录代码;内存屏障是 CPU 指令,解决可见性和重排序 |
线上随手执行 jmap -dump:live 导致服务抖动 | :live 会先触发 Full GC,且 dump 全程 STW。先摘流量,或依赖 HeapDumpOnOutOfMemoryError 自动 dump |
MAT 里看 Path to GC Roots 时没有排除弱/软引用 | 这些路径不阻止回收,会干扰判断。必须选 exclude weak/soft references |
用 jstat 看到 FGC 次数在涨就断定是泄漏 | 还要看 Full GC 后 O 是否降下来:降下来是晋升过快/堆太小,不降才是泄漏 |
| GC 日志不配滚动,单文件涨到几个 G 把磁盘写满 | 配 -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=50M,JDK 9+ 用 -Xlog:gc*:file=gc.log::filecount=5,filesize=50M |
GC 日志里 real 时间远大于 user 时间,却只盯着 GC 参数调 | 大概率是环境问题:CPU 被其他进程抢占、日志盘 IO 慢、发生了 swap。先查机器再调 JVM |
以为元空间 OOM 加大 MaxMetaspaceSize 就解决了 | 若动态类生成没有上界,加多少都会满。必须解决 ClassLoader 无法卸载的问题 |
| 自定义类加载器加载的类,强转成主程序的同名类 | 抛 ClassCastException。类的唯一性由"全限定名 + 类加载器"共同决定 |
| 热部署时把旧的 ClassLoader 存进了静态集合 | 旧 loader 和它加载的所有类永不卸载,元空间持续上涨。类卸载要求:实例全无、Class 无引用、ClassLoader 可回收 |
以为 -Xss 设大一点线程就能多开 | 恰好相反:-Xss 越大单线程占用越多,能开的线程数越少。线程数还受 ulimit -u 和容器 pid limit 限制 |
用 catch (OutOfMemoryError e) 想让服务"扛过去" | 泄漏仍在恶化,服务进入假死。配 -XX:+ExitOnOutOfMemoryError 让进程快速退出由编排系统重启 |
用 -XX:-UseGCOverheadLimit 关掉 GC overhead limit exceeded | 从"快速失败"变成"长时间无响应",排障更难。应该去解决内存问题本身 |
-Xms 和 -Xmx 配得不一样,运行期出现莫名的停顿 | 堆动态扩容/缩容会带来额外 Full GC。生产环境两者必须相等 |
| 一次性改了五六个 JVM 参数然后压测 | 效果无法归因。一次只改一个变量,有效才保留 |
| 遇到 GC 问题第一反应是调参数 | 绝大多数根因在代码里:大对象、无上界缓存、未关资源。先看 jmap -histo 再谈调参 |
| 以为 Lambda 是匿名内部类的语法糖 | 匿名内部类多生成一个 class 文件、每次 new;Lambda 走 invokedynamic,不生成 class 文件,方法体被抽成 lambda$xxx$N 私有方法。无捕获的全局单实例 |
| 在热路径里写捕获了外部变量的 Lambda | 有捕获的 Lambda 每次执行都 new 一个胶水类实例,高频调用下制造大量短命对象推高 Young GC。改为无捕获形式或把 Lambda 提到成员变量缓存 |
| 以为重载会在运行期按实际类型选 | 重载是静态分派,javac 按静态类型在编译期定死。Human h = new Man(); greet(h) 调的一定是 greet(Human) |
| 以为 class 常量池就是字符串常量池 | 三个不同的东西:Class 常量池在文件里存符号引用、运行时常量池在元空间每类一份、字符串常量池在堆里全局一份(JDK7+) |
| 以为方法签名包含返回值 | 签名不含返回值,方法描述符含。这是"不能按返回值重载"的根因,也是泛型桥接方法能共存于同一个 class 的原因 |
| 以为父子类同名字段也有多态 | 字段用 getfield,编译期按静态类型解析,不进 vtable。((Human) man).field 拿到的是父类字段,永远不要在子类定义同名字段 |
升级 JDK 后只换运行镜像,没改 maven.compiler.release | 高版本编译的 class 在低版本 JVM 上直接抛 UnsupportedClassVersionError,属于类加载验证阶段失败。major_version - 44 就是 JDK 版本 |
在循环里用 s += i 拼字符串,以为 JDK9 优化过就没事 | JDK9+ 的 invokedynamic 优化只针对单条表达式,循环里每次迭代都是独立调用,复杂度仍是 O(n²)。循环内必须手写 StringBuilder |
反编译排查时忘了加 -g 编译 | LocalVariableTable 为空,变量名只剩 slot1/slot2,javap 输出几乎没法读 |
📌 当日复盘任务
- [ ] 不看笔记,画出 JVM 运行时数据区五个部分,并标注哪些线程私有、哪些线程共享、各自抛什么异常
- [ ] 说清方法区、永久代、元空间三者的关系,以及 JDK 8 为什么要移除永久代(三个理由)
- [ ] 完整复述对象创建的 5 个步骤,并说明 TLAB 和 CAS + 失败重试分别解决什么问题
- [ ] 画出对象内存布局三部分,说明 Mark Word 在不同锁状态下的含义,以及 GC 年龄为什么上限是 15
- [ ] 解释指针压缩的原理,以及为什么堆超过 32G 后反而"变小"
- [ ] 完整描述对象分配流程:大对象判定 → 栈上分配 → TLAB → Eden → Survivor → 晋升
- [ ] 说清 8:1:1 的含义,以及为什么需要两个 Survivor
- [ ] 手画动态年龄判定的触发条件,并用 GC 日志中的
new threshold验证 - [ ] 对比标记清除、复制、标记整理三种算法的优缺点和适用区域
- [ ] 列出 GC Roots 的 7 类来源,解释为什么静态变量算 Root 而成员变量不算
- [ ] 对比强、软、弱、虚四种引用的回收时机和典型应用场景
- [ ] 列出 Full GC 的 6 个触发条件,并解释什么是空间分配担保
- [ ] 解释安全点、安全区域、OopMap、主动式中断,以及 counted loop 导致 TTSP 过长的坑
- [ ] 完整说出类加载的 5 个阶段,区分
<clinit>和<init>的执行时机与顺序 - [ ] 列举 6 种主动引用和 3 种被动引用,能当场判断给定代码是否触发初始化
- [ ] 手写
ClassLoader.loadClass的双亲委派伪代码,说明两大作用和三个破坏场景 - [ ] 用三色标记解释漏标的两个必要条件,对比增量更新和 SATB 的实现差异
- [ ] 解释卡表、记忆集、写屏障的协作关系,以及卡表的伪共享问题和
UseCondCardMark - [ ] 画出七种垃圾回收器的组合关系图,说明哪些已废弃、哪些是各 JDK 版本的默认值
- [ ] 复述 CMS 的四个阶段,指出两个 STW 点和三大缺陷
- [ ] 复述 G1 的四个阶段,解释 Region、CSet、RSet、Humongous、TAMS 各是什么
- [ ] 对比 G1 和 CMS 的七个维度差异,说出回收器选型的判断依据
- [ ] 解释 ZGC 染色指针和 Shenandoah 转发指针,说明"并发整理"难在哪里
- [ ] 默写四种 OOM 的报错信息、复现参数和排查手段
- [ ] 说清容器环境下 OOMKilled 和 Java OOM 的区别,以及堆该配多大
- [ ] 解释逃逸分析的三项优化(栈上分配、标量替换、锁消除)并设计验证实验
- [ ] 默写一份生产可用的 JVM 参数清单(8C16G + G1),并解释每一项的作用
- [ ] 逐段解读一份真实的 Young GC 和 Full GC 日志,推导晋升量和对象分配速率
- [ ] 默写
jstat -gcutil每一列的含义,说明看到哪些组合该怀疑什么问题 - [ ] 复述 CPU 100% 排查五步法和 Full GC 频繁排查流程
- [ ] 列出内存泄漏的十大根因,并对照自己项目逐条自查
- [ ] 复述调优方法论的五步闭环,以及"延迟/吞吐/内存"的优先级权衡
- [ ] 跑通本日全部 6 道手撕代码题,所有边界 Case 自测清单逐条验证
- [ ] 用 MAT 分析一份自己制造的泄漏 dump,走完 Leak Suspects → Dominator Tree → Path to GC Roots
- [ ] 把自己项目的 JVM 启动参数拉出来逐项 review,补齐 GC 日志和
HeapDumpOnOutOfMemoryError - [ ] 默写 class 文件的十项结构,说清 Class 常量池 / 运行时常量池 / 字符串常量池三者的位置与区别
- [ ] 手写
int add(int, long)和void main(String[])的方法描述符,并解释"为什么不能按返回值重载" - [ ] 用一段
(a+b)*10的代码推演操作数栈的每一步,说明stack/locals/args_size的含义 - [ ] 默写五条方法调用指令及其分派时机,区分非虚方法(解析)和虚方法(分派)
- [ ] 写出"重载按静态类型、重写按实际类型"的经典代码题,并说出
invokevirtual运行期查找的四步 - [ ] 画出父子类 vtable 对照图,解释为什么重写方法的索引不变、为什么字段没有多态
- [ ] 完整复述
invokedynamic+LambdaMetafactory的执行流程,说清 Lambda 和匿名内部类的五点差异 - [ ] 用
javap -v -p实际反编译一次,亲手验证分派结论、BootstrapMethods和 JDK9+ 的字符串拼接变化 - [ ] 准备 2 个自己的真实调优案例,按"现象 → 指标 → 归因 → 措施 → 收益"结构讲清楚