JVM

JVM 核心原理:内存、GC、类加载与故障排查

从运行时数据区、对象分配和垃圾回收到类加载、内存泄漏与线上排障,系统复习 JVM。

难度:深入更新:2026-07-16

一、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 高

  1. 系统工具定位高 CPU 线程;
  2. 将线程 ID 转为十六进制;
  3. jstack / jcmd Thread.print 对应 Java 栈;
  4. 连续采样,区分死循环、频繁 GC、锁自旋和热点计算;
  5. 必要时使用 JFR 做低侵入分析。

内存持续增长

  1. 区分 Java 堆、Metaspace、Direct Memory、线程栈和 Native Memory;
  2. 查看 GC 后存活量是否持续上升;
  3. 安全条件下生成 heap dump;
  4. 使用 MAT 分析 Dominator Tree 和 GC Roots 保留路径;
  5. 对比多个时间点,而不是只看一次快照;
  6. 修复引用链,再决定是否调整容量。

GC 停顿高

关注:

  • 分配速率与晋升速率;
  • GC 后存活集大小;
  • 大对象与 Humongous 分配;
  • Mixed GC 效果;
  • safepoint 原因;
  • 容器 CPU 限额和内存限制;
  • 日志、网络或锁是否让应用误以为是 GC 停顿。

先测量,再调参数。盲目增大堆可能降低 GC 频率,却增加单次停顿和故障恢复成本。

十二、常见误区

  1. JMM 不是 JVM 堆、栈、方法区划分;
  2. 元空间不受 -Xmx 直接控制;
  3. Young GC 不等于整个应用完全无停顿;
  4. Full GC 不一定只由老年代满触发;
  5. System.gc() 是请求,不是语言层面的绝对保证;
  6. 对象不可达才是回收前提,不是“方法结束就立即回收”;
  7. 堆内存正常不代表进程内存正常;
  8. 低停顿收集器仍需付出 CPU、内存或吞吐成本。

十三、面试回答主线

JVM 内存问题首先要区分逻辑区域和 HotSpot 实现:对象主要在堆,线程调用状态在虚拟机栈,类元数据在现代 HotSpot 的本地内存 Metaspace。GC 从 Roots 做可达性分析,现代收集器根据吞吐、停顿和内存开销做不同取舍。线上排障不能看到 OOM 就调大 Xmx,而应先区分堆、元空间、直接内存和线程,再结合 GC 日志、线程栈、JFR 与 heap dump 定位保留路径和增长原因。

参考资料

DISCUSSION

评论与讨论

留下你的想法