JVM 核心机制
JVM 不是一台具体的机器,而是一层运行时抽象。它把 .class 字节码加载进来,分配内存,执行代码,并在对象不可达时回收内存。HotSpot 是最常见的实现,下面的分代、Metaspace、C1/C2 都以 HotSpot 为例。
一、整体结构
JVM 把工作拆成几个子系统:类加载器负责把类读进来,运行时数据区负责保存对象和方法信息,执行引擎负责解释或编译字节码,本地接口负责调用 JVM 外面的代码。
整体结构可以先用一张图串起来。

类加载、内存管理和执行不是三件独立的事:类加载决定了哪些方法能被执行,运行时数据区决定了对象和类信息放在哪里,执行引擎再决定字节码是解释执行还是编译执行。
二、类加载
一个类从 .class 文件到能用,通常经历五个阶段:
- 加载:类加载器读取字节码,生成类的二进制表示。
- 链接:验证字节码安全,准备静态字段的默认值,解析符号引用。
- 初始化:执行静态初始化代码和静态字段赋值。
- 使用:创建实例、调用静态方法、访问静态字段。
- 卸载:类和它的加载器都不再被引用时,才可能被卸载。
链接里的"准备"和"初始化"容易混。准备阶段给静态字段赋默认值,例如 int 是 0;真正执行 static int value = 42; 的赋值是在初始化阶段。

双亲委派的核心是:加载器先请求父加载器,父加载器加载不到,再自己加载。这样核心类不会被应用自定义的同名类轻易替换。JDK 9+ 常见层级是 Bootstrap、Platform、Application;JDK 8 之前对应的是 Bootstrap、Extension、Application。
自定义加载器可以打破双亲委派,Tomcat 的 Web 应用隔离和热部署都利用过这类机制。它解决的是类加载边界,不是类版本管理。
三、运行时数据区
运行时数据区可以按线程私有和线程共享来分。

线程私有区域包括:
- PC Register:记录当前线程执行到哪条字节码。
- JVM Stack:保存线程的方法调用栈帧。
- Native Method Stack:保存 native 方法调用。
线程共享区域包括:
- Heap:对象实例和数组,GC 的主要工作区域。
- Method Area:JVM 规范里的逻辑区域,HotSpot 用 Metaspace 实现,保存类元数据、方法信息等。
- Runtime Constant Pool:每个类一份,保存符号引用和字面量。
Direct Memory 不属于 JVM 规范定义的运行时数据区,但 NIO 的 DirectByteBuffer 会占用它。堆、元空间和直接内存都可能出现 OutOfMemoryError。
这里说的是运行时内存布局,不是 Java 内存模型 JMM。JMM 讲的是多线程下的可见性、有序性和 happens-before,不是堆和栈怎么分。
四、栈帧
每次方法调用都会压入一个栈帧,方法返回时弹出。栈顶的帧就是当前正在执行的方法。

一个栈帧通常包含四块:
- 局部变量表:方法参数和局部变量。
- 操作数栈:字节码执行时的临时操作数。
- 动态链接:指向当前方法所在类的运行时常量池。
- 返回地址:方法返回后继续执行的位置。
以 a + b 为例,字节码会先把两个操作数压入操作数栈,执行 iadd 弹出相加,再把结果压回栈顶。局部变量表保存变量,操作数栈负责计算,这两个结构把"变量"和"表达式"分开了。
栈太深会抛 StackOverflowError,常见原因是递归没有收敛。无法为新线程分配栈时,抛的是 OutOfMemoryError,两者不是一回事。
五、堆与对象晋升
HotSpot 常见的堆布局把新生代分成 Eden、S0 和 S1,老年代保存存活时间更长的对象。

新对象通常先在 Eden 分配。Minor GC 发生时,Eden 和其中一个 Survivor 里的存活对象会复制到另一个 Survivor,对象年龄加一。年龄达到阈值,或者对象太大,可能被晋升到老年代。
老年代满了会触发 Full GC。不同收集器的 Full GC 行为不完全一样,有的会整堆回收,有的会做混合回收。具体策略要看 JDK 版本、收集器和启动参数。
分代是 HotSpot 的常见实现方式,不是 JVM 规范强制要求。对象也可能被 JIT 的逃逸分析拆成标量,看起来像"没有在堆上分配",但这只是优化,不改变 Java 的对象语义。
六、GC Roots 与回收
垃圾回收判断对象是否存活,靠的是可达性分析:从一组 GC Roots 出发,沿着引用关系遍历,能走到的对象存活,走不到的对象可以被回收。
常见的 GC Roots 包括:
- 线程栈里的局部变量。
- 类的静态字段。
- JNI 引用。
- 活动线程和同步监视器。
- 类加载器等运行时结构。

引用计数无法单独处理循环引用。两个对象互相引用,但外部已经没有任何 GC Root 能到达它们时,这两个对象仍然可以被回收。这也是 JVM 不采用单纯引用计数的原因。
常见回收算法有三种:
| 算法 | 基本思路 | 代价 |
|---|---|---|
| 标记-清除 | 标记存活对象,清除未标记对象 | 容易产生内存碎片 |
| 复制 | 把存活对象复制到另一块空间 | 需要额外空间 |
| 标记-整理 | 标记后把存活对象向一端移动 | 移动对象有成本,但能整理碎片 |
分代收集利用"大多数对象很快死亡"的规律:新生代多用复制算法,老年代多用标记-整理或混合策略。G1 把堆分成多个 Region,按回收收益选择区域;ZGC、Shenandoah 更关注低延迟。选择收集器时,吞吐量、延迟和内存占用很难同时拉满。
七、解释器与 JIT
字节码不是一开始就全部编译成本地机器码。执行引擎通常先解释执行,再根据运行时的热点统计,把频繁执行的方法交给 JIT 编译。

解释器启动快,适合执行次数不多的代码;JIT 编译有成本,但编译后的机器码执行更快。HotSpot 的 C1 偏向快速编译,C2 会做更深的优化,分层编译在两者之间取折中。
JIT 的优化建立在假设上,例如某个接口调用点一直只有一种实现。运行过程中假设被打破时,可能触发去优化,退回解释执行或重新编译。所以"JIT 编译过"不等于"永远不会回退"。
八、常见故障
| 现象 | 常见原因 | 先查什么 |
|---|---|---|
Java heap space |
堆内存不足或对象泄漏 | 堆转储、对象直方图、GC 日志 |
Metaspace |
类太多、动态生成类、类加载器泄漏 | 类加载器、Metaspace 使用量 |
Direct buffer memory |
NIO 直接内存占用过多 | DirectByteBuffer、-XX:MaxDirectMemorySize |
unable to create native thread |
线程数或系统资源达到上限 | 线程数、栈大小、系统限制 |
StackOverflowError |
递归过深或调用链异常 | 线程栈、递归终止条件 |
GC overhead limit exceeded |
GC 花时间多但回收效果差 | GC 日志、老年代占用、泄漏对象 |