JVM 组成
JVM 可以理解成一台运行 Java 字节码的虚拟计算机。它不直接运行 .java 源码,而是运行编译后的 .class 字节码,并负责类加载、内存管理、字节码执行、垃圾回收、本地方法调用等工作。
零基础先记住一句话:
JVM 的核心组成包括:类加载子系统、运行时数据区、执行引擎、垃圾回收器、本地方法接口和本地方法库。
JVM整体结构
flowchart TD
A[".class 字节码文件"] --> B["类加载子系统"]
B --> C["运行时数据区"]
C --> D["执行引擎"]
C --> E["垃圾回收器 GC"]
D --> F["本地方法接口 JNI"]
F --> G["本地方法库"]这张图可以这样理解:
.class文件先由类加载子系统加载进 JVM。- 类信息、对象、方法调用数据会进入运行时数据区。
- 执行引擎负责解释或编译执行字节码。
- GC 负责回收堆中不再使用的对象。
- JNI 负责让 Java 调用操作系统或 C/C++ 本地库能力。
为什么要理解JVM组成
JVM 组成不是面试背诵题,它直接影响线上排查。
| 线上现象 | 可能关联的 JVM 组成 |
|---|---|
ClassNotFoundException | 类加载子系统 |
StackOverflowError | Java 虚拟机栈 |
OutOfMemoryError: Java heap space | 堆和 GC |
OutOfMemoryError: Metaspace | 方法区/元空间和类加载 |
| CPU 飙高 | 执行引擎、JIT、线程调度、死循环 |
| Full GC 频繁 | 堆、对象存活、垃圾回收器 |
| Native memory 不足 | 本地方法栈、直接内存、本地库 |
如果不知道 JVM 由哪些部分组成,排查问题时就容易把所有问题都误判成“内存不够”。
类加载子系统
类加载子系统负责把 .class 字节码加载到 JVM 中,并把它转换成 JVM 可以使用的类元信息。
类加载过程通常分为五个阶段:
flowchart TD
A["加载 Loading"] --> B["验证 Verification"]
B --> C["准备 Preparation"]
C --> D["解析 Resolution"]
D --> E["初始化 Initialization"]| 阶段 | 作用 |
|---|---|
| 加载 | 读取 .class 文件,生成对应的 Class 对象 |
| 验证 | 检查字节码是否合法,避免破坏 JVM |
| 准备 | 给静态变量分配内存并设置默认值 |
| 解析 | 把符号引用转换为直接引用 |
| 初始化 | 执行静态变量赋值和静态代码块 |
类加载Demo
public class ClassLoadDemo {
static int count = 10;
static {
System.out.println("ClassLoadDemo 初始化,count = " + count);
}
public static void main(String[] args) throws Exception {
Class.forName("ClassLoadDemo");
}
}这个例子会触发类初始化,执行 static 代码块。
需要注意:在准备阶段,count 先是默认值 0;到初始化阶段,才执行 count = 10。
运行时数据区
运行时数据区就是 JVM 运行程序时使用的内存区域。它是理解 OOM、GC、线程栈、对象分配的基础。
flowchart TD
A["运行时数据区"] --> B["程序计数器"]
A --> C["Java 虚拟机栈"]
A --> D["本地方法栈"]
A --> E["堆"]
A --> F["方法区/元空间"]运行时数据区可以按线程共享关系理解:
| 区域 | 是否线程共享 | 主要作用 |
|---|---|---|
| 程序计数器 | 线程私有 | 记录当前线程执行到哪条字节码 |
| Java 虚拟机栈 | 线程私有 | 保存方法调用栈帧 |
| 本地方法栈 | 线程私有 | 服务 Native 方法调用 |
| 堆 | 线程共享 | 存放对象实例,是 GC 重点区域 |
| 方法区/元空间 | 线程共享 | 存放类信息、方法信息、常量等 |
程序计数器
程序计数器记录当前线程正在执行的字节码位置。
为什么需要它?因为 CPU 会在线程之间频繁切换。线程暂停后再次恢复执行,JVM 必须知道它上次执行到哪里。
特点:
- 每个线程都有自己的程序计数器。
- 占用空间很小。
- JVM 规范中,这是唯一一个不会出现
OutOfMemoryError的运行时区域。
Java虚拟机栈
Java 虚拟机栈负责 Java 方法调用。每调用一个方法,就会创建一个栈帧;方法执行结束,栈帧出栈。
public class StackFrameDemo {
public static void main(String[] args) {
methodA();
}
private static void methodA() {
methodB();
}
private static void methodB() {
int value = 10;
System.out.println(value);
}
}方法调用过程:
flowchart TD
A["main 栈帧入栈"] --> B["methodA 栈帧入栈"]
B --> C["methodB 栈帧入栈"]
C --> D["methodB 执行完出栈"]
D --> E["methodA 执行完出栈"]
E --> F["main 执行完出栈"]栈帧里通常包含:
| 内容 | 说明 |
|---|---|
| 局部变量表 | 保存局部变量、方法参数、对象引用 |
| 操作数栈 | 字节码计算时使用的临时数据区 |
| 动态链接 | 指向运行时常量池中方法或字段的引用 |
| 方法返回地址 | 方法执行完后回到调用方的位置 |
常见错误:
| 错误 | 常见原因 |
|---|---|
StackOverflowError | 递归太深或循环调用 |
OutOfMemoryError: unable to create native thread | 线程过多、线程栈太大或系统资源不足 |
本地方法栈
本地方法栈服务的是 Native 方法。Native 方法通常由 C/C++ 实现,用于调用操作系统底层能力。
例如 Thread.start()、文件 IO、网络 IO、部分加密压缩能力,底层都可能涉及 Native 调用。
业务开发很少直接操作本地方法栈,但排查直接内存、线程创建失败、Native memory 问题时需要知道它的存在。
堆
堆是 JVM 中最大、最重要的一块内存区域,大多数对象实例都在堆中分配。
public class HeapDemo {
public static void main(String[] args) {
User user = new User("Tom", 18);
System.out.println(user.getName());
}
}
class User {
private final String name;
private final int age;
User(String name, int age) {
this.name = name;
this.age = age;
}
String getName() {
return name;
}
}内存关系可以简单理解为:
flowchart TD
A["Java 虚拟机栈"] --> B["局部变量 user 引用"]
B --> C["堆中的 User 对象"]
C --> D["name 字段"]
C --> E["age 字段"]对象一般在堆里,局部变量表里保存的是对象引用。
堆是 GC 重点管理的区域,常见分代模型如下:
flowchart TD
A["Java 堆"] --> B["新生代"]
A --> C["老年代"]
B --> D["Eden 区"]
B --> E["Survivor 0"]
B --> F["Survivor 1"]对象通常先进入 Eden,经过多次 GC 仍然存活后,可能晋升到老年代。
TLAB属于堆吗
属于。TLAB 全称是 Thread Local Allocation Buffer,中文可以理解为“线程本地分配缓冲区”。它不是堆外内存,也不是线程栈,而是 Eden 区里划给某个线程的一小段空间。
为什么需要 TLAB:多个线程同时创建对象时,如果都在 Eden 上抢同一个分配指针,会有并发竞争。HotSpot 会给每个线程预留一小块 Eden 空间,线程创建普通小对象时先在自己的 TLAB 里分配,这样大多数分配只需要移动本线程的指针,速度很快。
flowchart TD
A["Java 堆"] --> B["新生代"]
B --> C["Eden"]
C --> D["线程 A 的 TLAB"]
C --> E["线程 B 的 TLAB"]
C --> F["Eden 公共分配区"]
B --> G["Survivor 区"]
A --> H["老年代"]对象分配链路可以这样理解:
flowchart TD
A["线程执行 new"] --> B{"对象能否放进当前 TLAB"}
B -- "能" --> C["在本线程 TLAB 快速分配"]
B -- "不能" --> D{"是否申请新的 TLAB"}
D -- "能" --> E["从 Eden 切一块新 TLAB"]
D -- "不能" --> F["走 Eden 慢分配路径"]
C --> G["对象仍然是堆对象"]
E --> G
F --> G
G --> H["后续由 GC 管理"]TLAB 和逃逸分析不是一回事:
| 概念 | 解决什么 | 对象是否仍在堆上 |
|---|---|---|
| TLAB | 让多线程分配对象更快,减少 Eden 分配竞争 | 是,TLAB 属于 Eden,仍然是堆对象 |
| 逃逸分析 | JIT 判断对象是否逃出方法或线程 | 如果触发标量替换,可能不创建完整堆对象 |
所以面试里不能简单说“对象都在栈上分配”。更准确的说法是:大多数对象在堆上分配,可能先进入 Eden 的 TLAB;少数热点代码中的不逃逸对象,JIT 可能通过逃逸分析、标量替换、分配消除,让对象不真正创建为完整堆对象,详见 JIT逃逸分析。
方法区和元空间
方法区用于存放类级别信息。HotSpot 在 JDK 8 之后主要使用元空间 Metaspace 来实现方法区,元空间使用的是本地内存。
方法区/元空间通常存放:
| 内容 | 示例 |
|---|---|
| 类信息 | 类名、父类、接口、访问修饰符 |
| 字段信息 | 字段名、字段类型、字段修饰符 |
| 方法信息 | 方法名、参数、返回类型、字节码 |
| 运行时常量池 | 字面量、符号引用 |
| 静态变量相关数据 | 类级别数据 |
常见错误:
| 错误 | 常见原因 |
|---|---|
OutOfMemoryError: Metaspace | 动态生成类太多,类加载器泄漏 |
| 类加载越来越多 | 热部署、动态代理、脚本引擎反复生成类 |
JDK7和JDK8在方法区上的区别
企业项目和面试中 JDK 7、JDK 8 很重要,JVM 内存差异也经常被问。
| 版本 | HotSpot实现 | 常见错误 | 重点理解 |
|---|---|---|---|
| JDK 7 | 方法区主要由永久代 PermGen 实现 | OutOfMemoryError: PermGen space | 永久代有固定上限,类元数据、常量等压力会触发问题 |
| JDK 8 | 移除永久代,改用本地内存中的 Metaspace | OutOfMemoryError: Metaspace | 元空间不在 Java 堆中,但仍受本地内存和 MaxMetaspaceSize 影响 |
不要把“JDK 8 移除了永久代”理解成“不会再有类元数据 OOM”。如果动态代理、热部署、脚本引擎、插件化 ClassLoader 持续生成或加载类,Metaspace 仍然可能 OOM。
同时要注意:Java 进程占用内存不等于 -Xmx。一个 Java 服务的进程内存大致包括:
Java堆 + 元空间 + 直接内存 + 线程栈 + Code Cache + GC本地结构 + JNI/本地库 + JVM自身开销这也是容器里经常出现 OOMKilled 的原因:堆没有满,但整个进程超过了容器 limit。
执行引擎
执行引擎负责真正执行字节码。
.class 文件里的字节码不能直接被 CPU 执行,JVM 需要通过解释器或 JIT 即时编译器把字节码转换成机器能执行的指令。
flowchart TD
A["字节码"] --> B["解释器"]
A --> C["JIT 即时编译器"]
B --> D["逐条解释执行"]
C --> E["热点代码编译为本地机器码"]解释器
解释器逐条读取和执行字节码。
优点:
- 启动快。
- 不需要等待编译。
- 适合执行次数较少的代码。
缺点是长期运行性能不如本地机器码。
JIT即时编译器
JIT 会把频繁执行的热点代码编译成本地机器码。
例如某个服务接口里的核心方法被高频调用,JVM 发现它是热点方法后,可能会进行 JIT 编译和优化。
常见优化包括:
| 优化 | 说明 |
|---|---|
| 方法内联 | 把小方法调用展开,减少调用成本 |
| 逃逸分析 | 判断对象是否逃出方法作用域,详见 JIT逃逸分析 |
| 标量替换 | 把对象拆成若干个基本变量 |
| 锁消除 | 去掉不可能存在竞争的锁 |
所以 Java 程序常见现象是:刚启动时没那么快,运行一段时间后热点代码会变快。
垃圾回收器
垃圾回收器负责自动回收不再使用的对象。
GC 要解决两个核心问题:
- 哪些对象还活着?
- 哪些对象可以安全回收?
JVM 通常通过可达性分析判断对象是否存活。
flowchart TD
A["GC Roots"] --> B["对象 A"]
B --> C["对象 B"]
D["对象 C 没有引用链"] --> E["可回收"]常见 GC Roots:
| 来源 | 示例 |
|---|---|
| 虚拟机栈中的引用 | 方法局部变量引用的对象 |
| 静态变量引用 | static User currentUser |
| 常量引用 | 字符串常量等 |
| 本地方法栈引用 | Native 方法引用的对象 |
常见垃圾回收器:
| 垃圾回收器 | 特点 |
|---|---|
| Serial GC | 单线程,适合小内存或学习观察 |
| Parallel GC | 吞吐量优先 |
| CMS GC | 低停顿老年代回收器,已逐步退出主流 |
| G1 GC | 服务端常用,兼顾吞吐和停顿 |
| ZGC | 超低停顿,适合大内存低延迟场景 |
| Shenandoah | 超低停顿,目标类似 ZGC |
从 new 到 GC 的完整链路
面试里问 JVM 组成,很多时候会继续追问:对象到底怎么从代码变成内存里的对象,最后又怎么被回收。
flowchart TD
A["Java代码 new User()"] --> B{"类是否已加载"}
B -- "否" --> C["类加载、验证、准备、解析、初始化"]
B -- "是" --> D["在堆中分配内存"]
C --> D
D --> E["对象字段设置默认零值"]
E --> F["设置对象头<br/>Mark Word、Class Pointer"]
F --> G["执行构造方法 init"]
G --> H["对象被业务引用"]
H --> I{"是否还能从 GC Roots 到达"}
I -- "能" --> J["对象存活"]
I -- "不能" --> K["成为可回收对象"]对象创建过程可以拆成五步:
| 步骤 | 说明 |
|---|---|
| 类加载检查 | 确认对象所属类已经被加载和初始化 |
| 分配内存 | 在堆中给对象分配空间,常见方式有指针碰撞和空闲列表 |
| 初始化零值 | 把对象字段先设置成默认值,保证对象有确定初始状态 |
| 设置对象头 | 写入哈希、锁状态、GC 年龄、类型指针等元信息 |
| 执行构造方法 | 执行字段赋值、构造代码和父类构造链 |
为什么要先零值再构造:这样即使构造方法里还没给某个字段赋值,字段也有 JVM 规定的默认值,不会是随机内存内容。
对象创建细节可以继续看 HotSpot对象探秘。
对象在堆里的典型流转
大多数对象会先进入新生代 Eden 区。Minor GC 后仍然存活的对象会进入 Survivor 区,多次存活或对象较大时可能晋升到老年代。
flowchart TD
A["新对象"] --> B["Eden"]
B --> C{"Minor GC 后是否存活"}
C -- "否" --> D["被回收"]
C -- "是" --> E["Survivor"]
E --> F{"年龄是否达到阈值<br/>或 Survivor 放不下"}
F -- "否" --> E
F -- "是" --> G["晋升老年代"]
G --> H{"Major/Full GC 后是否可达"}
H -- "否" --> I["回收"]
H -- "是" --> J["继续存活"]这能解释为什么批量导入、大集合、缓存、ThreadLocal 泄漏会导致堆上涨:对象如果一直被引用链连着,就算 GC 执行很多次也不能回收。
GC Roots 为什么关键
判断对象能不能回收,不是看对象“有没有用”,而是看它还能不能从 GC Roots 走引用链到达。
flowchart TD
A["线程栈局部变量"] --> B["List"]
B --> C["大量 Record 对象"]
D["static Cache"] --> E["Map"]
E --> F["缓存对象"]
G["没有引用链的临时对象"] --> H["可回收"]OOM 排查时,MAT 的 Path To GC Roots 就是在回答:这个对象为什么还活着。常见原因包括静态集合、线程池任务队列、ThreadLocal、未关闭连接、缓存无淘汰、监听器未注销等。
本地方法接口JNI
JNI 是 Java Native Interface,用于让 Java 调用本地方法。
flowchart TD
A["Java 代码"] --> B["JNI"]
B --> C["C/C++ 本地方法"]
C --> D["操作系统能力"]为什么 Java 还需要 JNI?
- 有些能力必须调用操作系统底层接口。
- 有些高性能库已经由 C/C++ 实现。
- Java 标准库和 JVM 自身也需要和操作系统交互。
常见相关场景:
| 场景 | 说明 |
|---|---|
| 文件系统 | 底层需要操作系统接口 |
| 网络通信 | Socket、epoll 等能力依赖系统 |
| 加密压缩 | 可能调用本地优化库 |
| 图形图像 | 可能调用本地图形库 |
| 直接内存 | 堆外内存涉及 Native memory |
本地方法库
本地方法库就是 JNI 最终调用的本地代码库。
不同操作系统的库文件格式不同:
| 操作系统 | 本地库格式 |
|---|---|
| Windows | .dll |
| Linux | .so |
| macOS | .dylib |
一般业务代码不会直接维护这些库,但使用某些高性能组件、图像处理组件、硬件 SDK 时可能会遇到。
JVM组成和常见问题对应关系
| JVM 组成 | 典型问题 | 常用排查方向 |
|---|---|---|
| 类加载子系统 | 类找不到、类冲突、初始化失败 | 检查 classpath、依赖版本、类加载器 |
| 程序计数器 | 很少直接出问题 | 理解线程切换和字节码执行 |
| Java 虚拟机栈 | 栈溢出、线程过多 | 看递归深度、线程数、-Xss |
| 堆 | OOM、Full GC 频繁 | heap dump、对象引用链、GC 日志 |
| 方法区/元空间 | Metaspace OOM | 类数量、动态代理、ClassLoader 泄漏 |
| 执行引擎 | CPU 飙高、热点代码 | top、jstack、JIT 日志 |
| GC | 停顿长、吞吐下降 | GC 日志、堆分布、对象晋升 |
| JNI/本地库 | Native memory 问题 | NMT、系统内存、直接内存配置 |
学习顺序建议
flowchart TD
A["先理解 JVM 组成"] --> B["深入运行时数据区"]
B --> C["理解对象创建和堆分配"]
C --> D["学习 GC 判断对象存活"]
D --> E["学习垃圾回收算法和收集器"]
E --> F["学习类加载和双亲委派"]
F --> G["学习 JVM 参数和排查工具"]这也是本 JVM 目录建议的学习顺序。先有整体地图,再深入每一块。
常见面试题
JVM 由哪些部分组成
JVM 主要由类加载子系统、运行时数据区、执行引擎、垃圾回收器、本地方法接口和本地方法库组成。其中运行时数据区又包括程序计数器、Java 虚拟机栈、本地方法栈、堆、方法区/元空间。
哪些区域是线程私有的
程序计数器、Java 虚拟机栈、本地方法栈是线程私有的。堆和方法区/元空间是线程共享的。
对象一般放在哪里
大多数对象实例放在堆中,局部变量表中保存对象引用。少数情况下,JIT 优化可能通过逃逸分析让对象不真正分配到堆上。这里的重点不是“程序员手动把对象放到栈上”,而是 JIT 证明对象不逃逸后,可能通过标量替换和分配消除让完整堆对象不再创建,详见 JIT逃逸分析。
JVM为什么需要执行引擎
因为 .class 中是字节码,CPU 不能直接执行。执行引擎通过解释器逐条执行字节码,或者通过 JIT 把热点字节码编译成本地机器码。
GC主要管理哪里
GC 主要管理堆,同时也会涉及方法区/元空间中的类卸载等内容。日常说的对象回收,重点通常是堆对象回收。
本章小结
JVM 不是单纯的一块内存,而是一套完整的 Java 运行系统。类加载子系统负责把类加载进来,运行时数据区负责存放程序运行数据,执行引擎负责执行字节码,GC 负责回收无用对象,JNI 和本地方法库负责连接操作系统底层能力。
学习 JVM 时,先掌握整体组成,再分别深入内存结构、对象模型、GC、类加载和排查工具,知识点才不会散。
