JVM 原理与实践:从运行时数据区到线上故障排查

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录


引言:为什么还要学习JVM?

JVM(Java Virtual Machine,Java 虚拟机) 是执行 Java 字节码的运行时引擎。你写的 `.java` 先被编译成 `.class`,再由 JVM 加载、解释或即时编译为本地机器码并执行。

很多人写了几年业务代码,对 JVM 仍停留在「有个垃圾回收」------直到线上出现:

  • `OutOfMemoryError: Java heap space`

  • 接口 P99 突然飙高(频繁 Full GC)

  • CPU 打满却找不到热点业务逻辑

这时,堆、栈、元空间、GC 日志、`jstack`/`jmap` 不再是面试题,而是排障工具。

学习 JVM 的价值可以概括为三点:

  1. 建立正确心智模型 :知道对象、类元数据、线程栈各自在哪,避免「栈上存对象」「方法区就是堆」这类概念混淆。

  2. 具备性能分析能力: 能看懂 GC 日志,能解释 Minor GC / Full GC,能判断该调参数还是改代码。

  3. 缩短故障定位时间:OOM、泄漏、线程死锁、CPU 飙高,有一套可复用的排查路径。

下文按「架构 → 类加载 → 内存 → GC → JIT/参数 → 排查工具 → 案例」展开。


提示:以下是本篇文章正文内容,下面案例可供参考

一、JVM整体架构概率

可以把一次Java程序运行粗分为三层:

┌─────────────────────────────────────────────────────────┐

│ 类加载子系统(Class Loading Subsystem) │

│ Bootstrap / Platform(Ext) / App / Custom ClassLoader │

└───────────────────────────┬─────────────────────────────┘

│ 加载字节码

▼

┌─────────────────────────────────────────────────────────┐

│ 运行时数据区(Runtime Data Areas) │

│ PC | 虚拟机栈 | 本地方法栈 | 堆 | 方法区/元空间 | 常量池 │

│ (另:直接内存 / Direct Memory,属本地内存) │

└───────────────────────────┬─────────────────────────────┘

│ 执行引擎驱动

▼

┌─────────────────────────────────────────────────────────┐

│ 执行引擎(Execution Engine) │

│ 解释器 Interpreter + JIT(C1/C2 或 Graal) │

│ + 垃圾收集器 GC │

└─────────────────────────────────────────────────────────┘

规范 vs 实现

二、从源码到运行:一次完整旅程

.java --javac--> .class --类加载--> 方法区/元空间 + Class 对象

--new--> 堆中对象

--调用--> 栈帧入栈,PC 指向字节码

--热点--> JIT 编译为本地代码(CodeCache)

java 复制代码
String s = new String("abc");

1.字面量"abc":若字符串常量池中尚无,则再池中放入对应引用(JDK 7+ 字符串常量池在堆中)。

2.new String(...):在堆上再创建一个String实例。

3.局部变量s:存在当前线程虚拟机栈 的栈帧局部变量表里,存的是引用,不是对象本身。

因此:池中可能有一个"abc",堆上还有一个new 出来的实例------这是面试常考点,也是理解【栈存引用、堆存对象)的最佳例子。

三、类加载机制

3.1类的生命周期

类从进入JVM到离开,经历:

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载

其中【验证 + 准备 + 解析】合称连接(Linking)。

3.2 类加载器与双亲委派

类加载器(ClassLoader)负责把字节码【变成】JVM可识别的类。

Bootstrap ClassLoader(启动类加载器,C++ 实现,Java 侧为 null)

▲

Platform ClassLoader(Java 9+;Java 8 为 Extension ClassLoader)

▲

Application ClassLoader(应用/系统类加载器,加载 classpath)

▲

Custom ClassLoader(自定义)

**双亲委派模型(Parents Delegation Model):**子加载器收到加载请求,先委派给父亲加载器;父亲加载器无法完成时,子加载器才自己加载。

作用:

**1.安全:**防止自定义`java.lang.String` 覆盖核心类。

2.唯一性:同一类由同一加载器加载,避免重复与类型混乱。

实践建议:自定义加载器时,优先重写`findClass()`,而不是随意覆盖`loadClass()`,以免破坏双亲委派只有SPI、热部署、模块隔离等场景才考虑【打破】委派。

3.3 自定义类加载器的典型场景

  • 从网络/数据库/加密文件加载字节码(插件化)。
  • 热部署:同一类名用不同 ClassLoader 实现隔离与卸载。
  • 框架SPI:如JDBC驱动加载时需打破双亲委派,由应用加载器实现类。
java 复制代码
package Exercise.jvm;
public class DiskClassLoader extends ClassLoader{
private  final  String classDir;
public DiskClassLoader(String classDir){
this.classDir = classDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    //仅再父加载器都找不到才会走到这里(若未破环双亲委派)
    byte[] bytes = readClassFile(name); // 从 classDir 读取 .class
    return defineClass(name, bytes, 0, bytes.length);
}

private byte[] readClassFile(String name) throws ClassNotFoundException {
    //省略:按name 拼路径、读文件;失败抛ClassNotFoundException
    throw  new ClassNotFoundException(name);
}
}

面试延申:【同一个`.class` 被两个自定义 ClassLoader各加载一次,是不是同一个类?】------不是.JVM用【类加载器+全限定名】判定类的唯一性,跨加载器无法直接强转。

四、JVM运行时数据区

先牢记一张表:

4.1 程序计数器

多线程靠时间片切换。每个线程必须记住【下一条要执行哪条字节码】,因此PC必须线程私有。

它体积小,实现简单,几乎不会成为故障源,却是理解线程切换的关键拼图。

4.2 Java虚拟机栈与栈帧

每个方法调用对应一个栈帧(Stack Frame):

┌──────────────────────┐

│ 局部变量表 │ ← 基本类型 / 对象引用

│ 操作数栈 │ ← 计算的「草稿纸」

│ 动态链接 │ ← 指向运行时常量池中方法引用

│ 方法出口 │ ← 返回后回到哪里

└──────────────────────┘

栈帧过大(局部变量极多)或调用过深(无限递归),会触发`StackOverflowError`。

4.3 堆:对象的【大仓库】

从垃圾回收视角(传统分带收集器,如Parallel),堆常划分为:

┌──────────────── Young Generation ────────────────┐ ┌── Old ──┐

│ Eden (约 8) │ Survivor0 (1) │ Survivor1 (1) │ │ Tenured │

└──────────────────────────────────────────────────┘ └─────────┘

  • 新对象优先进 Eden;Eden 满 →*Minor GC(Young GC)

  • 多次 GC 仍存活(年龄达阈值,默认常为 15,可用 `-XX:MaxTenuringThreshold` 调整)→ 晋升老年代

  • 大对象可能直接进老年代(或 G1 的 Humongous Region),避免在新生代反复复制。

**常见误区:**元空间不是堆的一部分;堆OOM和MetaspaceOOM是两类问题。

4.4 方法区与元空间

-**方法区:**规范层逻辑区域。

  • 永久代(PermGen) :Java 7 及以前 HotSpot 实现,大小受 `-XX:MaxPermSize` 限制,易因动态类过多而 OOM。

  • 元空间(Metaspace) :Java 8+ 使用本地内存存放类元数据,默认随本地内存增长,可用 `-XX:MaxMetaspaceSize` 限制。

运行时常量池属于方法区概念的一部分:字符串常量池再JDK 7后迁到堆,不要和【永久代里的字符串】混为一谈。

4.5 直接内存

不属于运行时数据区五块,但生产中极常见(Netty,NIO)。它不受`-Xmx` 直接约束,却占用进程物理内存;只盯堆、忽略 Direct Memory,是排查「进程 RSS 很大但堆不大」时的常见盲区。

五、对象创建于内存分配

HotSpot 中 `new` 一个对象,大致步骤:

1**.类加载检查:**常量池能否解析到类符号引用;类是否已初始化。

2.**分配内存:**按对象大小在堆上划出一块空间(指针碰撞/空闲列表;并发下用TLAB等优化)。

3.**零值初始化:**实例字段置零值(不含对象头)。

4**.设置对象头**:类型指针、哈希、GC年龄、锁状态等。

5.执行 `<init>`:构造方法按程序员意图完成初始化。

java 复制代码
package Exercise.jvm;
public class ObjectAllocDemo {
private int id;
private String name;
public ObjectAllocDemo(int id, String name) {
    this.id = id;
    this.name = name;
}

public static void main(String[] args) {
    // 1) 确保 ObjectAllocDemo 已加载并初始化
    // 2) 在堆(通常 Eden/TLAB)分配对象内存并写对象头
    // 3) 执行构造:id、name 赋值;name 引用指向堆中 String
    ObjectAllocDemo demo = new ObjectAllocDemo(1,"jvm");
    System.out.println(demo.id + ":" +demo.name);
}
}

**分配路径速记:**小对象 → TLAB/Eden;熬过多次 Young GC → 老年代;超大对象 → 老年代或 G1 Humongous。

六、垃圾回收机制

6.1 如何判断对象存活?

引用计数: 引用+1/-1,为0可回收。无法很好处理循环引用,主流JVM不用它做主要判定。

可达性分析(Reachability Analysis): 从 GC Roots出发沿引用链遍历,不可达则判为可回收。

常见 GC Roots 包括:

  • 虚拟机栈局部变量表中的引用

  • 方法区静态属性、常量引用

  • JNI 引用

  • 被同步锁持有的对象等

四种引用(由强到弱)

对象真正死亡前还可能经历 `finalize()`(已不推荐依赖),现代代码应优先用 `try-with-resources`、Cleaner 等机制。

6.2 常见垃圾回收算法

6.3 Minor / Major / Full GC

**实践建议:**关注 Full GC 频率与停顿;Young GC 频繁未必是坏事,但若伴随大量晋升,要查对象是否「偶然变老」。

七、常见垃圾收集器

下表以HotSpot 为主,标记版本背景(以常见发行版行为为准,具体以自己使用的JDK文档为准)

怎么选:

  • 通用服务、几 GB~几十 GB 堆:优先G1 ,设合理 `-XX:MaxGCPauseMillis`。

  • 超大堆、对尾延迟极敏感:评估 ZGC(Java 17/21 更稳妥)。

  • 纯吞吐、可接受较长停顿:Parallel 仍有价值。

  • 新项目不要再规划 CMS。

八、JIT编译与性能优化基础

JVM 先用解释器 快速启动,对热点方法用JIT(Just-In-Time) 编译为本地代码,缓存在 CodeCache。

HotSpot 分层编译常见分工:

- C1(Client Compiler): 更快编译,优化较少

- C2(Server Compiler):更慢编译,优化更激进

对开发者的实践含义:

  1. 基准测试要预热 :第一次执行往往偏慢,别拿冷启动当稳态性能。

  2. 避免极端写法破坏优化 :过度反射、巨型方法、错误的异常控制流,可能影响内联与编译质量。

  3. 关注 CodeCache:动态生成大量代码时,可能出现编译关闭、性能回落(可结合 `jcmd` / 相关参数排查)。

九、常见 JVM 参数(附示例)

以下参数在 HotSpot 中广泛使用;具体默认值随 JDK 版本与 GC 变化,上线前请用 `java -XX:+PrintFlagsFinal` 核对。

bash 复制代码
# 堆:初始与最大(生产常设相等,减少运行时扩容抖动)
-Xms2g -Xmx2g
线程栈大小(过大会限制可创建线程数)
-Xss512k
元空间上限(防类元数据无限膨胀)
-XX:MaxMetaspaceSize=256m
选择 G1,并给出停顿目标(目标非硬保证)
-XX:+UseG1GC -XX:MaxGCPauseMillis=200
OOM 时自动 dump,便于事后用 MAT 分析
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
GC 日志(JDK 9+ 统一日志框架示例)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags

十、常见问题与排查思路

10.1 OutOfMemoryError

排查骨架:开 HeapDump → MAT 看 Dominator Tree / Leak Suspects → 区分「真需要更大堆」还是「引用没放开」。

10.2 StackOverflowError

优先查无限递归或过深调用,其次才是调大 `-Xss`。能改成迭代就别靠堆栈硬扛。

10.3 频繁 Full GC

看 GC 日志:老年代是否持续上涨?是否存在晋升失败?元空间是否频繁触发?再决定是扩容、换 GC,还是改缓存/对象生命周期。

10.4 内存泄漏 vs 内存溢出

  • 泄漏: 用完的对象仍被引用,GC 收不走,可用内存缓慢被蚕食。

  • **溢出:**申请时内存不够,抛 OOM。泄漏发展到最后常常表现为溢出。

10.5 CPU 过高

`top` 找进程 → `top -H` 找线程 → 线程 ID 转十六进制 → `jstack` 对齐 nid → 看是否死循环、激烈锁竞争、或 GC 线程占满(后者要回到 GC 问题)。

十一、监控与诊断工具

11.1 命令行(至少会这几条)

bash 复制代码
# 1) 列出 Java 进程
jps -lvm
2) 查看 GC / 堆概况(pid 换成实际进程号)
jstat -gcutil <pid> 1000 10
输出中 S0/S1/E/O/M 等为各区使用百分比;YGC/YGCT、FGC/FGCT 为 Young/Full GC 次数与耗时
3) 堆转储(注意:live 会先触发 GC;生产慎用,优先低峰或用更轻量方案)
jmap -dump:format=b,file=heap.hprof <pid>
4) 线程快照(死锁、阻塞首选)
jstack -l <pid> > thread.txt
5) 现代统一入口:堆信息示例
jcmd <pid> GC.heap_info
jcmd <pid> Thread.print

读 `jstat -gcutil` 的直觉:`O`(老年代)持续升高且 `FGC` 频繁增加,要警惕晋升过多或泄漏;`E` 频繁归零但业务 RT 正常,可能只是 Young GC 活跃。

11.2 GUI / 增强工具

  • VisualVM / JConsole: 本地观察堆、线程、CPU,适合开发与预发。

  • MAT: 分析 `hprof`,找大对象与泄漏链。

  • Arthas:线上动态观测、反编译、方法耗时、线程、dashboard,改动小、见效快。

十二、实战案例:静态集合导致的堆溢出

现象

批量任务运行数小时后偶发:

java.lang.OutOfMemoryError: Java heap space

重启后短期内正常,说明不像「堆固定过小到立刻炸」,更像内存随时间累积。

复现与简化模型

java 复制代码
package Exercise.jvm;
import java.util.ArrayList;
import java.util.List;
/**
演示:静态集合长期持有对象 → GC 无法回收 → 最终堆溢出。
可用较小堆复现:
java -Xms32m -Xmx32m -XX:+HeapDumpOnOutOfMemoryError StaticLeakDemo
*/
public class StaticLeakDemo {
//    生命周期几乎等于整个JVM进程
private  static  final List<byte[]> CACHE = new ArrayList<>();
public static void main(String[] args) {
    int batch = 0;
    while (true){
//            模拟【每批结果都塞进全局缓存]
CACHE.add(new byte[1024*1024]);//1MB
batch++;
if (batch %10 ==0){
System.out.println("cached MB =" +CACHE.size());
}
}
}
}

JVM 层面发生了什么:

  1. 每次 `new byte1MB` 在堆上分配。

  2. 引用进入静态 `CACHE`,成为 GC Roots 可达对象。

  3. Young GC / Full GC 都无法回收这些数组。

  4. 堆被填满 → `Java heap space`。

排查步骤(生产同构)

  1. 启动时带 `-XX:+HeapDumpOnOutOfMemoryError`,拿到 `hprof`。

  2. MAT 中 Dominator Tree 看到 `byte\[\]` 被 `StaticLeakDemo.CACHE` 持有。

  3. 对照代码:批处理结果本应写库后释放,却进了全局 List。

修复

java 复制代码
public void processBatch(List<Item> items) {
    List<Result> buffer = new ArrayList<>(items.size());
    for (Item item : items) {
        buffer.add(handle(item));
    }
    repository.saveAll(buffer);
    buffer.clear(); // 或让 buffer 离开作用域,无需静态缓存
}

若确需缓存:设上限(Caffeine/Guava)、过期时间,或对可丢数据使用软/弱引用,并监控命中率与堆曲线。

对比实验

去掉 `static`,把 `CACHE` 改成 `main` 局部变量并在循环外创建、循环内不无限 add,或改为「每 N 次 clear」,用 VisualVM 会看到堆呈锯齿状回落------这就是「可被 GC 回收」与「泄漏」的可视化差异。

十三、总结

重点回顾

  1. 规范概念 ≠ HotSpot 实现 :方法区 vs 元空间;字符串池位置随版本变化。

  2. 栈存引用与基本类型,堆存对象 ;元空间存类元数据;直接内存在堆外。

  3. 类加载 靠双亲委派保安全与唯一;自定义加载器用 `findClass` 扩展。

  4. GC 以可达性分析为根基,分代/分区是工程折中;现代默认看 G1,低延迟看 ZGC。

  5. 调优顺序:先证据(GC 日志、dump、栈),再改代码与架构,最后才拧参数。

常见误区

  • 以为 `System.gc()` 会立刻、完整地清理一切------它只是建议。

  • 把 Full GC 一律当成「老年代 GC」------范围与触发因收集器而异,要以日志为准。

  • 堆设得越大越好------更大的堆可能换来更长的停顿(视 GC 而定)和更高的主机成本。

相关推荐
网腾无限44 分钟前
优尼沃OS工程手记:从单体架构到主权智能体集群的改造总结
后端
code2cat1 小时前
【随笔】MCP缓存期限与共享范围:让Agent复用资料时记住边界
java·后端·缓存·ai agent·mcp
llqbzllll1 小时前
线程池里的“幽灵数据”:ThreadLocal 用完不 remove,为什么下个请求还能看到?
后端
量化分析码农1 小时前
【Python量化系统工程实战 #02】每天手动拉数据太烦?用 APScheduler 搭一条「自动采集 + 增量去重」的流水线
后端
维克兜率天1 小时前
【维克】配对交易的季节性:哪些品种适合长拿?
android·开发语言·笔记·python·算法·kotlin·量化
沫璃染墨1 小时前
《Qt从零入门系列(十二):Qt文件操作详解——从QFile读写到QFileInfo与记事本实战》
开发语言·网络·c++·qt·交互·信号处理·文件
YYYing.2 小时前
【设计模式系列 (九) 】装饰器模式
c++·后端·设计模式·装饰器模式·c/c++
时间的拾荒人2 小时前
Qt 界面布局与容器控件详解:从分组框到布局管理器
开发语言·qt·面试
逃逸线LOF2 小时前
工具类文件头文件
java