Appearance
JVM:Java 程序的运行系统
JVM(Java Virtual Machine)是 Java 程序从源码走向真实运行的核心执行环境。Java 源码不会直接交给操作系统运行,而是先被 javac 编译成 .class 字节码,再由 JVM 完成类加载、字节码校验、运行时内存分配、方法调用、即时编译和垃圾回收。
从工程角度看,JVM 同时承担三类职责:
- 运行入口:把 Class 文件加载成 JVM 内部可识别的类型、方法、字段和常量。
- 资源管理:管理堆、栈、方法区、直接内存、线程、锁和本地调用边界。
- 执行优化:解释执行字节码,并把热点路径编译成机器码,同时用 GC 回收无用对象。
理解 JVM 的关键,不是背参数,而是把“程序如何进入 JVM、数据放在哪里、代码如何执行、对象如何被回收、问题如何观测”这几条线连起来。可以先从 动手入门路径 跑通一个最小程序,再按 Class 文件、类加载、执行引擎、运行时内存和 GC 的顺序深入。
JVM 解决的问题
Java 最初强调“一次编写,到处运行”,这句话背后的工程条件是:业务代码不直接绑定某个操作系统和 CPU,而是先编译成一套稳定的中间格式。JVM 位于字节码和底层系统之间,向上承接 Java 语言和生态框架,向下连接操作系统、CPU、内存、线程、文件和网络。
| 问题 | JVM 的处理方式 | 常见现象 |
|---|---|---|
| 不同系统如何运行同一套程序 | 让 .class 字节码成为跨平台输入,由不同平台的 JVM 负责执行 | Windows、Linux、macOS 使用同一份 jar |
| 类和方法什么时候可用 | 类加载器读取字节码,经过验证、准备、解析、初始化后进入运行时 | ClassNotFoundException、NoSuchMethodError |
| 对象和方法调用放在哪里 | 堆保存对象,栈保存方法调用帧,方法区保存类元数据 | OOM、栈溢出、元空间增长 |
| 高频代码如何变快 | 解释器先运行,JIT 把热点代码编译成机器码 | 应用预热、CPU 抖动、性能曲线变化 |
| 对象不用了如何释放 | GC 从根对象出发判断可达性,再按回收器策略释放内存 | Young GC、Mixed GC、Full GC、长停顿 |
这套分层让 Java 框架能够把大量运行时能力交给 JVM:Spring 依赖类加载和反射,Netty 依赖直接内存和 NIO,动态代理依赖类生成和元空间,线上诊断依赖线程栈、堆 dump、GC 日志和 JFR。
从源码到运行
一个 Java 方法真正执行前,通常经过下面的链路:
text
.java 源码
-> javac 编译
-> .class 字节码
-> ClassLoader 加载
-> 链接与初始化
-> 运行时数据区
-> 解释执行 / JIT 编译
-> OS / CPU 执行源码与 Class 文件
javac 把源码编译为 Class 文件。Class 文件不是简单的文本翻译,而是一份结构化二进制协议,里面包含常量池、字段表、方法表、属性表和字节码指令。
bash
javac OrderService.java
javap -v OrderService
javap -c OrderServicejavap -c 可以看到方法级字节码,javap -v 可以看到常量池、访问标记、方法描述符和属性。很多“明明编译过,运行却找不到方法”的问题,都和运行期 Class 文件版本、方法签名、依赖包覆盖有关。
字节码与栈式执行
JVM 字节码是一套偏栈式的指令模型。方法执行时会创建栈帧,栈帧里有局部变量表、操作数栈、动态链接和返回地址。比如一次整数相加,并不是直接写成 CPU 指令,而是先把变量压入操作数栈,再执行加法指令。
这种设计让 Class 文件更稳定,也让不同平台的 JVM 可以用自己的执行引擎实现解释执行、JIT 编译、逃逸分析、内联和锁优化。
类加载:类型如何进入 JVM
类加载不是简单“读文件”。HotSpot 里的类从字节码变成可运行类型,大致经过:
| 阶段 | 做什么 | 常见问题 |
|---|---|---|
| 加载 | 根据类名找到字节码,生成 Class 元数据 | 类路径错误、依赖缺失 |
| 验证 | 校验字节码格式、安全约束和类型关系 | 版本不兼容、非法字节码 |
| 准备 | 为静态变量分配默认值 | 静态字段初值认知错误 |
| 解析 | 把符号引用转成直接引用 | 方法、字段、类解析失败 |
| 初始化 | 执行静态变量赋值和静态代码块 | 初始化循环、静态块异常 |
类的唯一性由“类加载器 + 类全限定名”共同决定。同一个 com.example.User,如果被两个不同类加载器加载,在 JVM 看来就是两个不同类型。这是插件系统、Tomcat WebApp 隔离、Spring Boot DevTools 热重启、Dubbo/SPI 扩展加载里经常遇到的根因。
双亲委派模型的目的,是让核心类库优先由更上层的类加载器加载,避免业务代码伪造 java.lang.String 这类基础类型。实际工程中也会主动打破或调整委派顺序,例如应用服务器隔离不同 Web 应用,插件框架加载独立依赖,或者 OSGi 按模块声明可见性。
运行时数据区
运行时数据区回答“数据到底放在哪里”。不同区域的生命周期、线程关系和异常表现都不同。
| 区域 | 线程关系 | 主要内容 | 常见异常或现象 |
|---|---|---|---|
| 堆 | 线程共享 | 对象实例、数组 | OutOfMemoryError: Java heap space、GC 频繁 |
| 方法区 / 元空间 | 线程共享 | 类元数据、方法信息、运行时常量池 | OutOfMemoryError: Metaspace、类加载器泄漏 |
| 虚拟机栈 | 线程私有 | 栈帧、局部变量表、操作数栈 | StackOverflowError、线程数过多 |
| 本地方法栈 | 线程私有 | Native 方法调用栈 | native 崩溃、JNI 问题 |
| 程序计数器 | 线程私有 | 当前线程字节码执行位置 | 很少单独排查 |
| 直接内存 | 堆外 | NIO、Netty、mmap、JNI 数据 | Direct buffer memory、RSS 过高 |
| Code Cache | 线程共享 | JIT 编译后的机器码 | 编译受限、性能下降 |
排查内存问题时不能只看 -Xmx。容器内存、线程栈、直接内存、Metaspace、Code Cache 都会进入进程总内存。一个服务 -Xmx 设置得很克制,也可能因为直接内存或线程数过多被容器杀掉。
方法调用与对象布局
方法调用进入 JVM 栈,每一次调用都会形成栈帧。栈帧包含局部变量表和操作数栈,方法返回时栈帧弹出。递归过深、复杂表达式、过大的栈帧都可能触发栈溢出。
对象主要存在于堆里。HotSpot 对象通常由对象头、实例数据和对齐填充组成:
- 对象头保存 Mark Word、类型指针等信息,和锁、哈希、GC 年龄有关。
- 实例数据保存字段值。
- 对齐填充让对象大小满足内存对齐要求。
这也是为什么“一个对象占多大内存”不能只看字段大小。对象头、引用压缩、字段排列、数组头、对齐都会影响实际占用。高并发缓存、批量对象创建、消息堆积时,这些细节会直接影响堆占用和 GC 压力。
GC:从引用链到回收器
GC 的第一步不是“扫堆”,而是判断哪些对象还活着。HotSpot 主要使用可达性分析:从 GC Roots 出发,沿引用链遍历对象;不可达对象才有机会被回收。
常见 GC Roots 包括:
- 当前线程栈帧里的局部变量和参数。
- 静态字段引用的对象。
- JNI 引用。
- 活跃线程对象。
- 被锁持有的对象。
- JVM 内部结构引用的对象。
不同回收器的差异,主要体现在回收区域、停顿控制、并发程度、碎片整理和吞吐取舍:
| 回收器 | 核心定位 | 适用方向 |
|---|---|---|
| Serial | 简单、单线程、低额外开销 | 小堆、工具程序 |
| Parallel | 吞吐优先 | 批处理、离线计算 |
| CMS | 老年代并发标记清除 | 老系统低延迟维护 |
| G1 | 分区回收、可预测停顿目标 | 通用服务端 |
| ZGC / Shenandoah | 大堆、低延迟、并发整理 | 延迟敏感服务 |
GC 调优不应该从“套参数模板”开始,而应该先看业务目标:吞吐优先、延迟优先、内存成本优先,还是稳定性优先。不同目标会导向不同的堆大小、回收器、对象生命周期控制和监控指标。
常用参数与边界
| 参数 | 含义 | 注意点 |
|---|---|---|
-Xms / -Xmx | 初始堆 / 最大堆 | 服务端常设为一致,减少运行期扩缩容抖动 |
-Xss | 单线程栈大小 | 线程多时会显著影响进程内存 |
-XX:MaxMetaspaceSize | 元空间上限 | 动态代理、热部署、脚本引擎会增加压力 |
-XX:MaxDirectMemorySize | 直接内存上限 | Netty、NIO、大文件传输需要关注 |
-XX:+UseG1GC | 使用 G1 | 现代服务端常见选择 |
-XX:MaxGCPauseMillis | G1 停顿目标 | 是目标,不是硬保证,设置过低会增加 GC 压力 |
-Xlog:gc* | GC 统一日志 | JDK 9+ 推荐使用 |
容器环境下还要关注:
- JVM 是否识别 cgroup 限制。
- 堆、直接内存、线程栈、Metaspace 与容器总内存之间是否留有余量。
InitialRAMPercentage、MaxRAMPercentage是否符合服务规模。- OOM 是 JVM 自己抛出,还是容器层面直接 kill。
观测与排查入口
JVM 问题需要先拿证据,再下结论。常用命令如下:
bash
# 查看 Java 进程
jps -l
# 查看启动参数和运行参数
jcmd <pid> VM.command_line
jcmd <pid> VM.flags
# 查看堆与类加载信息
jcmd <pid> GC.heap_info
jcmd <pid> VM.classloader_stats
# 导出线程栈和堆快照
jstack <pid> > thread-dump.txt
jcmd <pid> GC.heap_dump /tmp/app.hprof
# 周期性观察 GC 指标
jstat -gcutil <pid> 1000JDK 9+ GC 日志建议:
bash
-Xlog:gc*:file=gc.log:time,uptime,level,tags排查路径可以按现象拆分:
| 现象 | 优先看什么 | 可能根因 |
|---|---|---|
Java heap space | heap dump、对象直方图、引用链 | 缓存无界、集合累积、请求堆积 |
Metaspace | 类加载器统计、动态类数量 | 热部署泄漏、代理类暴涨 |
Direct buffer memory | 直接内存上限、Netty buffer、RSS | buffer 未释放、堆外上限过小 |
StackOverflowError | 线程栈、递归深度、调用链 | 递归、循环依赖、表达式过深 |
| Full GC 频繁 | GC 日志、老年代占用、晋升速度 | 老年代压力、泄漏、堆过小 |
| CPU 高但 GC 不高 | 线程栈、锁、热点方法、JFR | 死循环、锁竞争、序列化、JIT 预热 |
| 启动慢 | 类加载、反射扫描、JIT、IO | 框架初始化、依赖扫描、冷启动 |
学习路线
JVM 内容可以按这条路径串起来:
- 动手入门路径:用一个最小 Java 程序观察编译、运行、GC 日志和诊断命令。
- Class 文件与字节码:理解常量池、方法描述符、Code 属性和字节码指令。
- 类加载机制:理解加载、链接、初始化、双亲委派、类隔离和类加载器泄漏。
- 执行引擎与 JIT:理解解释执行、热点探测、分层编译、内联、逃逸分析和 Code Cache。
- 运行时数据区:把堆、栈、方法区、直接内存、线程关系和参数边界分清。
- 对象生命周期:把对象分配、对象头、TLAB、晋升、引用链和 GC Roots 连起来。
- GC:从对象存活、STW、并行并发、分区回收和回收器选择入手。
- 诊断案例:用堆 OOM、Full GC、Metaspace、容器 OOM、CPU 高和线程过多串起排查闭环。
- 现代 JVM 发展:理解 G1 默认化、ZGC、容器感知、JFR、CDS、虚拟线程和 Native Image。
配套视频目录见 JVM 深入视频系列。视频适合按顺序建立整体链路;文字页面适合回查概念、参数和排查路径。
子页面
- 动手入门路径:从
javac、javap、jcmd、GC 日志开始建立第一条观察链路。 - Class 文件与字节码:解释版本号、常量池、方法描述符、异常表和字节码增强。
- 类加载机制:解释类加载生命周期、双亲委派、线程上下文类加载器和框架隔离。
- 执行引擎与 JIT:解释解释器、JIT、热点代码、逃逸分析、Safepoint 和 Code Cache。
- 运行时数据区:堆、栈、方法区、程序计数器、本地方法栈和直接内存。
- 对象生命周期:对象从创建、引用、晋升到回收的完整路径。
- GC:对象存活判断、STW、并行并发、主流垃圾回收器选型。
- 诊断案例:常见线上问题的证据采集和判断路径。
- 现代 JVM 发展:JDK 8 到 JDK 21 之后的运行时能力变化。
- JNI:Java 与 native 能力的边界。
