Skip to content

JVM 组成

JVM 可以理解成一台运行 Java 字节码的虚拟计算机。它不直接运行 .java 源码,而是运行编译后的 .class 字节码,并负责类加载、内存管理、字节码执行、垃圾回收、本地方法调用等工作。

零基础先记住一句话:

JVM 的核心组成包括:类加载子系统、运行时数据区、执行引擎、垃圾回收器、本地方法接口和本地方法库。

JVM整体结构

mermaid
flowchart TD
    A[".class 字节码文件"] --> B["类加载子系统"]
    B --> C["运行时数据区"]
    C --> D["执行引擎"]
    C --> E["垃圾回收器 GC"]
    D --> F["本地方法接口 JNI"]
    F --> G["本地方法库"]

这张图可以这样理解:

  1. .class 文件先由类加载子系统加载进 JVM。
  2. 类信息、对象、方法调用数据会进入运行时数据区。
  3. 执行引擎负责解释或编译执行字节码。
  4. GC 负责回收堆中不再使用的对象。
  5. JNI 负责让 Java 调用操作系统或 C/C++ 本地库能力。

为什么要理解JVM组成

JVM 组成不是面试背诵题,它直接影响线上排查。

线上现象可能关联的 JVM 组成
ClassNotFoundException类加载子系统
StackOverflowErrorJava 虚拟机栈
OutOfMemoryError: Java heap space堆和 GC
OutOfMemoryError: Metaspace方法区/元空间和类加载
CPU 飙高执行引擎、JIT、线程调度、死循环
Full GC 频繁堆、对象存活、垃圾回收器
Native memory 不足本地方法栈、直接内存、本地库

如果不知道 JVM 由哪些部分组成,排查问题时就容易把所有问题都误判成“内存不够”。

类加载子系统

类加载子系统负责把 .class 字节码加载到 JVM 中,并把它转换成 JVM 可以使用的类元信息。

类加载过程通常分为五个阶段:

mermaid
flowchart TD
    A["加载 Loading"] --> B["验证 Verification"]
    B --> C["准备 Preparation"]
    C --> D["解析 Resolution"]
    D --> E["初始化 Initialization"]
阶段作用
加载读取 .class 文件,生成对应的 Class 对象
验证检查字节码是否合法,避免破坏 JVM
准备给静态变量分配内存并设置默认值
解析把符号引用转换为直接引用
初始化执行静态变量赋值和静态代码块

类加载Demo

java
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、线程栈、对象分配的基础。

mermaid
flowchart TD
    A["运行时数据区"] --> B["程序计数器"]
    A --> C["Java 虚拟机栈"]
    A --> D["本地方法栈"]
    A --> E["堆"]
    A --> F["方法区/元空间"]

运行时数据区可以按线程共享关系理解:

区域是否线程共享主要作用
程序计数器线程私有记录当前线程执行到哪条字节码
Java 虚拟机栈线程私有保存方法调用栈帧
本地方法栈线程私有服务 Native 方法调用
线程共享存放对象实例,是 GC 重点区域
方法区/元空间线程共享存放类信息、方法信息、常量等

程序计数器

程序计数器记录当前线程正在执行的字节码位置。

为什么需要它?因为 CPU 会在线程之间频繁切换。线程暂停后再次恢复执行,JVM 必须知道它上次执行到哪里。

特点:

  1. 每个线程都有自己的程序计数器。
  2. 占用空间很小。
  3. JVM 规范中,这是唯一一个不会出现 OutOfMemoryError 的运行时区域。

Java虚拟机栈

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);
    }
}

方法调用过程:

mermaid
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 中最大、最重要的一块内存区域,大多数对象实例都在堆中分配。

java
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;
    }
}

内存关系可以简单理解为:

mermaid
flowchart TD
    A["Java 虚拟机栈"] --> B["局部变量 user 引用"]
    B --> C["堆中的 User 对象"]
    C --> D["name 字段"]
    C --> E["age 字段"]

对象一般在堆里,局部变量表里保存的是对象引用。

堆是 GC 重点管理的区域,常见分代模型如下:

mermaid
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 里分配,这样大多数分配只需要移动本线程的指针,速度很快。

mermaid
flowchart TD
    A["Java 堆"] --> B["新生代"]
    B --> C["Eden"]
    C --> D["线程 A 的 TLAB"]
    C --> E["线程 B 的 TLAB"]
    C --> F["Eden 公共分配区"]
    B --> G["Survivor 区"]
    A --> H["老年代"]

对象分配链路可以这样理解:

mermaid
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移除永久代,改用本地内存中的 MetaspaceOutOfMemoryError: Metaspace元空间不在 Java 堆中,但仍受本地内存和 MaxMetaspaceSize 影响

不要把“JDK 8 移除了永久代”理解成“不会再有类元数据 OOM”。如果动态代理、热部署、脚本引擎、插件化 ClassLoader 持续生成或加载类,Metaspace 仍然可能 OOM。

同时要注意:Java 进程占用内存不等于 -Xmx。一个 Java 服务的进程内存大致包括:

text
Java堆 + 元空间 + 直接内存 + 线程栈 + Code Cache + GC本地结构 + JNI/本地库 + JVM自身开销

这也是容器里经常出现 OOMKilled 的原因:堆没有满,但整个进程超过了容器 limit。

执行引擎

执行引擎负责真正执行字节码。

.class 文件里的字节码不能直接被 CPU 执行,JVM 需要通过解释器或 JIT 即时编译器把字节码转换成机器能执行的指令。

mermaid
flowchart TD
    A["字节码"] --> B["解释器"]
    A --> C["JIT 即时编译器"]
    B --> D["逐条解释执行"]
    C --> E["热点代码编译为本地机器码"]

解释器

解释器逐条读取和执行字节码。

优点:

  1. 启动快。
  2. 不需要等待编译。
  3. 适合执行次数较少的代码。

缺点是长期运行性能不如本地机器码。

JIT即时编译器

JIT 会把频繁执行的热点代码编译成本地机器码。

例如某个服务接口里的核心方法被高频调用,JVM 发现它是热点方法后,可能会进行 JIT 编译和优化。

常见优化包括:

优化说明
方法内联把小方法调用展开,减少调用成本
逃逸分析判断对象是否逃出方法作用域,详见 JIT逃逸分析
标量替换把对象拆成若干个基本变量
锁消除去掉不可能存在竞争的锁

所以 Java 程序常见现象是:刚启动时没那么快,运行一段时间后热点代码会变快。

垃圾回收器

垃圾回收器负责自动回收不再使用的对象。

GC 要解决两个核心问题:

  1. 哪些对象还活着?
  2. 哪些对象可以安全回收?

JVM 通常通过可达性分析判断对象是否存活。

mermaid
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 组成,很多时候会继续追问:对象到底怎么从代码变成内存里的对象,最后又怎么被回收。

mermaid
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 区,多次存活或对象较大时可能晋升到老年代。

mermaid
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 走引用链到达。

mermaid
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 调用本地方法。

mermaid
flowchart TD
    A["Java 代码"] --> B["JNI"]
    B --> C["C/C++ 本地方法"]
    C --> D["操作系统能力"]

为什么 Java 还需要 JNI?

  1. 有些能力必须调用操作系统底层接口。
  2. 有些高性能库已经由 C/C++ 实现。
  3. 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 飙高、热点代码topjstack、JIT 日志
GC停顿长、吞吐下降GC 日志、堆分布、对象晋升
JNI/本地库Native memory 问题NMT、系统内存、直接内存配置

学习顺序建议

mermaid
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、类加载和排查工具,知识点才不会散。