JVM 核心原理:内存、GC、类加载与故障排查
从运行时数据区、对象分配和垃圾回收到类加载、内存泄漏与线上排障,系统复习 JVM。
一、JVM 运行时数据区
JVM 规范关注逻辑区域,HotSpot 决定具体实现。常见区域包括:
| 区域 | 是否线程共享 | 主要内容 |
|---|---|---|
| 程序计数器 | 否 | 当前线程执行位置 |
| 虚拟机栈 | 否 | 栈帧、局部变量表、操作数栈、返回信息 |
| 本地方法栈 | 否 | Native 方法调用 |
| 堆 | 是 | 对象和数组,是 GC 主要管理区域 |
| 方法区 | 是 | 类元数据、运行时常量池、方法信息等规范概念 |
方法区与元空间
方法区是 JVM 规范定义的逻辑区域;Metaspace 是 HotSpot 对类元数据存储的一种实现。JDK 8 起永久代被移除,类元数据主要放在本地内存中的 Metaspace,而不是受 -Xmx 直接限制。
因此:
方法区不等于永久代,也不等于元空间;后两者是不同历史阶段的 HotSpot 实现。
二、运行时常量池与字符串常量池
Class 文件常量池保存字面量和符号引用。类加载后,对应信息进入每个类的运行时常量池。
字符串常量池用于复用字符串对象,在现代 HotSpot 中字符串对象本身位于 Java 堆。不要把“运行时常量池”和“字符串常量池”当成同一个东西。
String a = "abc";
String b = "abc";
String c = new String("abc");
通常:
a == b // true,引用同一池中字符串
a == c // false,c 是显式创建的新对象
a.equals(c) // true,内容相同
new String("abc") 涉及池中字面量对象和新建 String 对象,但“总共创建几个对象”取决于池中字符串是否此前已经存在,不能脱离上下文固定回答。
三、对象是如何创建的
典型过程:
检查类是否已加载
↓
为对象分配内存
↓
把实例字段初始化为零值
↓
设置对象头
↓
执行构造方法
TLAB
多线程在共享堆上分配对象会产生竞争。HotSpot 可给线程在 Eden 中分配 TLAB,线程在自己的缓冲区内通过指针碰撞快速分配;空间不足时再申请新 TLAB 或走慢路径。
TLAB 解决的是分配竞争,不代表对象属于线程私有。对象仍位于堆中,并可能被其他线程引用。
对象布局
典型对象由以下部分组成:
- 对象头:Mark Word、类型指针等;
- 实例数据;
- 对齐填充。
对象大小会受到压缩类指针、字段排列和对齐规则影响,不能只把字段字节数简单相加。
四、可达性分析与 GC Roots
JVM 从 GC Roots 出发遍历引用图,无法到达的对象才可能被回收。常见 Root 包括:
- 栈帧局部变量引用;
- 已加载类的静态字段引用;
- JNI 引用;
- 活跃线程及 JVM 内部引用;
- 同步监视器持有的对象等。
引用计数不能处理循环引用,因此主流 JVM 使用可达性分析。
四种引用
- 强引用:只要可达就不会回收;
- 软引用:内存压力下可能回收,不适合现代缓存的精确容量管理;
- 弱引用:发生 GC 时容易被回收,常用于不阻止对象存活的关联关系;
- 虚引用:不提供普通对象访问,配合 ReferenceQueue 感知回收与管理堆外资源。
五、分代与垃圾回收术语
分代假设认为大多数对象朝生夕死,少量对象长期存活。传统分代堆通常包括 Eden、Survivor 和 Old。
- Young GC / Minor GC:主要回收年轻代;
- Major GC:语义在不同资料和收集器中不完全统一,通常指老年代相关收集;
- Full GC:通常涉及整个堆以及类卸载等更广范围工作。
面试时不要把 Major GC 和 Full GC 永远画等号,应说明术语依赖具体收集器和日志。
常见触发因素
- Eden 分配失败触发年轻代收集;
- 老年代空间或晋升担保不足;
- 大对象、Humongous Object 分配压力;
- 元空间达到阈值,尝试类卸载;
- 显式
System.gc()请求; - 收集器并发周期跟不上分配速度。
六、垃圾回收算法
标记—清除
标记存活对象后清理垃圾,速度直接,但会产生碎片。
标记—复制
把存活对象复制到另一块区域,回收后空间连续,适合存活率低的年轻代;代价是复制和额外空间。
标记—整理
标记后把存活对象向一端移动,减少碎片,适合存活率较高区域,但移动成本更高。
现代收集器往往组合使用,不应把一个收集器简单等同于一种算法。
七、常见收集器
Serial / Parallel
Serial 结构简单,适合小堆或资源受限场景。Parallel 以吞吐优先,使用多线程完成 Stop-The-World 收集。
G1
G1 把堆划分为多个 Region,年轻代和老年代是 Region 的逻辑集合。它通过并发标记、转移存活对象和优先回收垃圾收益高的 Region,努力满足停顿目标。
需要关注:
- Young GC 与 Mixed GC;
- Humongous Object;
- Remembered Set 与跨 Region 引用;
- 并发标记周期是否能跟上分配;
MaxGCPauseMillis是目标,不是硬保证。
ZGC
ZGC 面向低延迟,绝大部分工作与应用并发执行,停顿通常与堆大小弱相关。低停顿不是“零成本”,会消耗额外 CPU、内存和吞吐。
CMS
CMS 是历史上常见的低停顿老年代收集器,存在内存碎片、并发失败等问题,已从现代 JDK 中移除。面试可以讲原理和历史影响,但不要把它当作当前默认选择。
八、类加载机制
类的生命周期通常包括:
加载
→ 验证
→ 准备
→ 解析
→ 初始化
→ 使用
→ 卸载
准备与初始化区别
准备阶段为静态字段分配存储并设置默认值;初始化阶段执行 <clinit>,应用源代码中的静态字段赋值和静态代码块在这里生效。
static int value = 10;
准备阶段通常先得到 0,初始化阶段再赋为 10。编译期常量的处理可能有所不同。
双亲委派
类加载器先把加载请求委托给父加载器,父加载器无法完成时再由自己尝试。价值包括:
- 避免核心类重复加载;
- 保证类身份和安全边界;
- 提高基础类复用。
SPI、模块化容器、热部署等场景可能使用线程上下文类加载器或自定义策略,但这是有控制地改变委派路径,不等于双亲委派完全失效。
类什么时候能卸载
通常需要同时满足:
- 该类的所有实例不可达;
- 对应 Class 对象不可达;
- 定义它的 ClassLoader 不可达。
频繁动态生成类、CGLIB 代理或脚本类却让 ClassLoader 长期存活,可能导致 Metaspace 增长。
九、ThreadLocal 为什么会内存泄漏
ThreadLocalMap 的 Entry 对 ThreadLocal Key 是弱引用,对 Value 是强引用:
Thread → ThreadLocalMap → Entry → Value
↘ weak Key
如果 ThreadLocal 对象被回收,Key 可能变成 null,但只要线程长期存活,Value 仍可能通过线程链路被强引用。线程池线程尤其容易长期存在。
规范用法:
try {
threadLocal.set(value);
// 使用
} finally {
threadLocal.remove();
}
ThreadLocalMap 会在部分操作时清理陈旧 Entry,但不能依赖这种机会式清理替代 remove()。
十、常见 OOM 与定位思路
Java heap space
可能是内存泄漏,也可能只是堆容量不足或流量突增。看对象数量、保留路径、增长趋势和 GC 后存活量。
GC overhead limit exceeded
大量时间用于 GC,却只能回收很少空间。通常说明存活集过大、堆过小或存在泄漏。
Metaspace
关注动态类生成、ClassLoader 数量和卸载情况。普通 Bean 属性复制主要创建堆对象,并不会因为“拷贝字段”直接占用大量 Metaspace;只有持续生成和加载新类才会明显推动类元数据增长。
Direct buffer memory
关注 NIO DirectByteBuffer、网络框架池化内存、显式清理和 MaxDirectMemorySize。堆使用正常不代表进程没有内存压力。
unable to create native thread
可能是线程数过多、单线程栈过大、进程限制或系统内存不足。此时调大堆反而可能进一步挤压本地内存。
十一、线上排障流程
CPU 高
- 系统工具定位高 CPU 线程;
- 将线程 ID 转为十六进制;
- 用
jstack/jcmd Thread.print对应 Java 栈; - 连续采样,区分死循环、频繁 GC、锁自旋和热点计算;
- 必要时使用 JFR 做低侵入分析。
内存持续增长
- 区分 Java 堆、Metaspace、Direct Memory、线程栈和 Native Memory;
- 查看 GC 后存活量是否持续上升;
- 安全条件下生成 heap dump;
- 使用 MAT 分析 Dominator Tree 和 GC Roots 保留路径;
- 对比多个时间点,而不是只看一次快照;
- 修复引用链,再决定是否调整容量。
GC 停顿高
关注:
- 分配速率与晋升速率;
- GC 后存活集大小;
- 大对象与 Humongous 分配;
- Mixed GC 效果;
- safepoint 原因;
- 容器 CPU 限额和内存限制;
- 日志、网络或锁是否让应用误以为是 GC 停顿。
先测量,再调参数。盲目增大堆可能降低 GC 频率,却增加单次停顿和故障恢复成本。
十二、常见误区
- JMM 不是 JVM 堆、栈、方法区划分;
- 元空间不受
-Xmx直接控制; - Young GC 不等于整个应用完全无停顿;
- Full GC 不一定只由老年代满触发;
System.gc()是请求,不是语言层面的绝对保证;- 对象不可达才是回收前提,不是“方法结束就立即回收”;
- 堆内存正常不代表进程内存正常;
- 低停顿收集器仍需付出 CPU、内存或吞吐成本。
十三、面试回答主线
JVM 内存问题首先要区分逻辑区域和 HotSpot 实现:对象主要在堆,线程调用状态在虚拟机栈,类元数据在现代 HotSpot 的本地内存 Metaspace。GC 从 Roots 做可达性分析,现代收集器根据吞吐、停顿和内存开销做不同取舍。线上排障不能看到 OOM 就调大 Xmx,而应先区分堆、元空间、直接内存和线程,再结合 GC 日志、线程栈、JFR 与 heap dump 定位保留路径和增长原因。
评论与讨论
回复 :
留下你的想法