Lesson 34 · JVM 原理与调优
JVM 运行时数据区全景:堆、栈、方法区、程序计数器
面试官:JVM 运行时数据区有哪些部分?
几乎所有 JVM 相关的面试题,都会从这个问题开始。它看似简单,却能考察候选人对 Java 内存模型的全局理解深度——堆和栈的区别、线程共享与隔离、方法区的演进历史、溢出场景的差异,全都藏在这一个问题里。
JVM 在执行 Java 程序时,会将内存划分成若干个不同的数据区域,每个区域有各自的用途和生命周期。根据 JVM 规范,运行时数据区包括:
| 区域 | 线程共享 | 核心职责 | 溢出异常 |
|---|---|---|---|
| 程序计数器 (PC Register) | 私有 | 记录当前线程执行的字节码行号 | 唯一不会 OOM 的区域 |
| 虚拟机栈 (VM Stack) | 私有 | 方法调用:局部变量、操作数、返回地址 | StackOverflowError / OOM |
| 本地方法栈 (Native Method Stack) | 私有 | Native 方法调用 | StackOverflowError / OOM |
| 堆 (Heap) | 共享 | 对象实例、数组,GC 的主战场 | OutOfMemoryError |
| 方法区 (Method Area) | 共享 | 类元信息、常量池、静态变量 | OutOfMemoryError |
| 直接内存 (Direct Memory) | 共享 | NIO 堆外内存,零拷贝 IO | OutOfMemoryError |
接下来,我们将逐一深入每个区域,揭开它们的真面目。
全景图:JVM 运行时数据区一览
在深入细节之前,先看一张完整的全景图。JVM 运行时数据区按线程共享性分为两大阵营:线程私有区域(每个线程独立拥有)和线程共享区域(所有线程共同访问)。
线程私有区域:PC、虚拟机栈、本地方法栈
3.1 程序计数器 (PC Register)
程序计数器是一块极小的内存空间,它是线程私有的。它的作用只有一个:记录当前线程正在执行的字节码指令的地址(行号)。字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
因为 CPU 会在多个线程之间快速切换,每次切换回来都需要恢复到上次的执行位置。如果 PC 是共享的,线程 A 刚执行到第 42 行就被切走,线程 B 把 PC 改成了第 100 行,那线程 A 回来后就找不到自己该从哪继续了。所以每个线程必须有自己的 PC。
程序计数器是 JVM 规范中唯一没有规定 OutOfMemoryError 的区域——它的内存占用极小且固定,不需要动态扩展。
3.2 虚拟机栈 (VM Stack)
虚拟机栈描述的是 Java 方法执行的内存模型。每个方法在执行时都会创建一个栈帧 (Stack Frame),用于存储以下信息:
| 组成部分 | 作用 |
|---|---|
| 局部变量表 | 存放方法参数和局部变量(基本类型的值 + 对象引用 reference) |
| 操作数栈 | 字节码指令的工作空间,用于中间计算(如 iadd 弹出两个值,压入结果) |
| 动态链接 | 指向运行时常量池中该方法的引用,支持多态调用(虚方法分派) |
| 方法返回地址 | 方法结束后回到调用者的哪一行继续执行 |
public class StackDemo {
public static int add(int a, int b) {
int sum = a + b; // a, b, sum 存在局部变量表
return sum; // 操作数栈: iload a → iload b → iadd → istore sum
}
public static void main(String[] args) {
int result = add(3, 5); // 调用 add → 压入新栈帧;返回 → 栈帧弹出
}
}
StackOverflowError 发生在栈的深度超出限制时——最典型的就是无限递归:
public class InfiniteRecursion {
public static void stackOverflow() {
stackOverflow(); // 每次调用都压入一个新栈帧,永不停止
}
public static void main(String[] args) {
stackOverflow();
// 抛出: java.lang.StackOverflowError
// 默认栈大小约 512KB~1MB(-Xss 可调)
}
}
虚拟机栈还有一种 OOM 情况:当无法申请到足够内存来扩展栈(比如创建了太多线程,每个线程都要占用栈空间),会抛出 OutOfMemoryError: unable to create new native thread。
3.3 本地方法栈 (Native Method Stack)
本地方法栈与虚拟机栈类似,只不过它服务的是 Native 方法(用 C/C++ 实现的底层方法,如 Unsafe.allocateMemory、Thread.start0())。HotSpot VM 直接把本地方法栈和虚拟机栈合二为一。
本地方法栈同样会抛出 StackOverflowError 和 OutOfMemoryError。
线程共享区域:堆与方法区
4.1 堆 (Heap)
堆是 JVM 中最大的一块内存区域,被所有线程共享。几乎所有的对象实例和数组都在这里分配。堆也是垃圾收集器(GC)的主战场。
堆的典型分代结构:
| 分区 | 占比 | 特点 | GC 类型 |
|---|---|---|---|
| Eden 区 | ~80% 新生代 | 新对象首先分配在 Eden | Minor GC |
| Survivor S0 / S1 | 各 ~10% 新生代 | Minor GC 存活对象的暂存区 | Minor GC |
| 老年代 (Old Gen) | 约 2/3 总堆 | 经过多次 GC 仍存活的对象晋升至此 | Full GC / Major GC |
public class HeapOOM {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 每次 1MB,list 持有强引用,GC 无法回收
} // → OOM: Java heap space(-Xms / -Xmx 控制堆大小)
}
}
通过 -Xms(初始堆大小)和 -Xmx(最大堆大小)来控制堆内存。
4.2 方法区 (Method Area)
方法区存储以下数据:
| 内容 | 说明 |
|---|---|
| 类信息 | 类的全限定名、父类、接口、字段、方法定义等元数据 |
| 运行时常量池 | 编译期生成的字面量和符号引用(.class 文件中的常量池的运行时表示) |
| 静态变量 | static 修饰的字段 |
| JIT 编译代码 | 热点代码被 JIT 编译后的本地机器码 |
4.3 方法区的演进:永久代 → 元空间
这是面试中的高频考点。方法区是一个逻辑概念,不同 JDK 版本有不同的实现:
/* ═══ JDK 5/6/7:永久代 (Permanent Generation) ═══ */
// 方法区用堆内存中的一块"永久代"来实现
// 大小由 -XX:MaxPermSize 控制(默认约 64MB~82MB)
// 字符串常量池在永久代中
// 问题:大小固定,容易因加载过多类而 OOM: PermGen space
/* ═══ JDK 8+:元空间 (Metaspace) ═══ */
// 永久代被移除,方法区改用"元空间"实现
// 元空间使用本地内存 (Native Memory),不再占用 JVM 堆内存
// 默认不设上限(受限于系统物理内存)
// 可用 -XX:MaxMetaspaceSize 设置上限
// 字符串常量池从方法区移到了堆中(其实 JDK 7 就移了)
三大原因:
1. 固定大小容易 OOM:永久代大小在启动时确定,难以预估需要多少空间。Web 应用中大量使用动态代理、JSP 编译、热部署时,类不断被加载,PermGen 很容易溢出。
2. GC 效率低:永久代的 GC 回收效率很差,类卸载条件苛刻,导致大量无用的类元数据占据空间。
3. 与 JRockit 合并:Oracle 收购 Sun 后,需要将 HotSpot 和 JRockit 的代码库合并。JRockit 没有永久代的概念,统一到元空间可以减少代码复杂度。
String.intern() 在 JDK 6 和 JDK 7+ 的行为不同——JDK 6 会在永久代创建新字符串,JDK 7+ 在堆中创建(或在常量池中记录已存在的引用)。这是很多候选人会搞混的知识点。
4.4 方法区 OOM 示例
/** 使用 CGLib 动态生成大量类,撑爆元空间 */
public class MethodAreaOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMTarget.class);
enhancer.setUseCache(false); // 关闭缓存,每次都生成新类
enhancer.setCallback((MethodInterceptor) (o, m, a, p) -> p.invokeSuper(o, a));
enhancer.create(); // → OOM: Metaspace
}
}
}
class OOMTarget {}
直接内存 (Direct Memory):堆外的秘密战场
直接内存不属于 JVM 运行时数据区的规范定义,但在实际开发中极为重要——Netty、Kafka、Elasticsearch 等高性能框架大量使用直接内存。
5.1 什么是直接内存?
直接内存是在 JVM 堆之外,通过操作系统直接分配的内存。Java NIO 中的 DirectByteBuffer 就是典型的直接内存使用者:
import java.nio.ByteBuffer;
public class DirectMemoryDemo {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024); // 分配 10MB 堆外内存
buffer.put((byte) 1);
buffer.flip();
byte val = buffer.get(); // val = 1
// 直接内存不受 GC 管理,依赖 Cleaner 机制(虚引用)回收
// 当 DirectByteBuffer 对象被 GC 时,关联的 Cleaner 会释放堆外内存
}
}
5.2 为什么需要直接内存?——零拷贝
传统的 IO 操作需要4 次数据拷贝,而直接内存可以实现零拷贝:
5.3 直接内存的 OOM
import java.nio.ByteBuffer;
import java.util.*;
public class DirectOOM {
public static void main(String[] args) {
List<ByteBuffer> buffers = new ArrayList<>();
while (true) {
buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); // 不断分配 1MB 直接内存
} // → OOM: Direct buffer memory (-XX:MaxDirectMemorySize 控制上限)
}
}
System.gc() 触发 Cleaner 来释放,如果同时设置了 -XX:+DisableExplicitGC,直接内存可能无法及时回收,导致 OOM。Netty 通过自定义的 ReferenceCountUtil.release() 手动管理直接内存的生命周期来解决这个问题。
StackOverflowError vs OutOfMemoryError
面试中经常被问到:什么时候抛 StackOverflowError?什么时候抛 OutOfMemoryError?它们的本质区别是什么?
| 对比维度 | StackOverflowError | OutOfMemoryError |
|---|---|---|
| 发生区域 | 虚拟机栈 / 本地方法栈 | 堆 / 方法区 / 直接内存 / 虚拟机栈 |
| 根本原因 | 栈的深度超出限制 | 内存总量不够,无法分配更多空间 |
| 典型场景 | 无限递归 / 极深调用链 | 大对象、内存泄漏、类加载过多 |
| JVM 参数 | -Xss(栈大小) | -Xmx / -XX:MaxMetaspaceSize / -XX:MaxDirectMemorySize |
| 可恢复性 | 几乎无法恢复,需修复代码 | 部分场景可通过扩容缓解,但根因仍需修复代码 |
6.1 虚拟机栈的两种溢出
public class StackErrors {
/* 场景 1:栈深度溢出 → StackOverflowError */
private int depth = 0;
public void stackLeak() {
depth++;
stackLeak(); // 无限递归,栈帧不断压入 → StackOverflowError
}
/* 场景 2:栈扩展失败 → OutOfMemoryError */
public void createThreads() {
while (true) {
new Thread(() -> { try { Thread.sleep(1000000); } catch (Exception e) {} }).start();
} // 线程太多 → OOM: unable to create new native thread
}
}
6.2 各种 OOM 汇总
| OOM 错误信息 | 发生区域 | 典型原因 |
|---|---|---|
Java heap space | 堆 | 对象过多 / 大对象 / 内存泄漏 |
GC overhead limit exceeded | 堆 | GC 花了 98% 以上时间却回收不到 2% 的内存 |
Metaspace | 方法区 | 加载了过多的类(动态代理、热部署) |
Direct buffer memory | 直接内存 | NIO 分配的 DirectByteBuffer 过多 |
unable to create new native thread | 虚拟机栈 | 线程数过多,操作系统无法分配更多栈空间 |
Requested array size exceeds VM limit | 堆 | 尝试分配超过 JVM 限制的数组 |
PermGen space (JDK 7-) | 永久代 | 加载了过多的类或字符串 |
有。当栈的大小(-Xss)设得较大时,每个线程的栈会占用更多内存。如果创建大量线程,总的栈空间需求可能超出系统内存,此时就会从 StackOverflow 变成 OOM(unable to create new native thread)。
所以存在一个权衡:-Xss 设大 → 支持更深的调用链,但能创建的线程数变少;-Xss 设小 → 能创建更多线程,但容易 StackOverflow。
总结:运行时数据区速查手册
最后,用一张表把所有区域的核心信息一网打尽:
| 区域 | 线程共享 | 存储内容 | 大小控制 | 溢出异常 |
|---|---|---|---|---|
| 程序计数器 | 私有 | 当前执行的字节码行号 | 固定,无需配置 | 无(唯一不溢出的区域) |
| 虚拟机栈 | 私有 | 栈帧(局部变量表、操作数栈、动态链接、返回地址) | -Xss | StackOverflowError / OOM |
| 本地方法栈 | 私有 | Native 方法调用信息 | -Xss(HotSpot 合并) | StackOverflowError / OOM |
| 堆 | 共享 | 对象实例、数组 | -Xms / -Xmx | OOM: Java heap space |
| 方法区 | 共享 | 类元信息、常量池、静态变量、JIT 代码 | PermGen: -XX:MaxPermSize Metaspace: -XX:MaxMetaspaceSize | OOM: Metaspace / PermGen |
| 直接内存 | 共享 | NIO DirectByteBuffer 堆外内存 | -XX:MaxDirectMemorySize | OOM: Direct buffer memory |
"JVM 运行时数据区分为线程私有和线程共享两大部分。线程私有的有程序计数器——每个线程一个,记录当前执行的字节码行号,是唯一不会 OOM 的区域;虚拟机栈——每个方法调用创建一个栈帧,包含局部变量表和操作数栈,递归过深会 StackOverflowError;本地方法栈——服务 Native 方法。线程共享的有堆——存放对象实例,是 GC 的主战场,分为新生代和老年代;方法区——存放类元信息和常量池,从 JDK 7 的永久代演进到 JDK 8 的元空间,后者使用本地内存,解决了永久代固定大小容易 OOM 的问题。此外还有直接内存,虽然不在 JVM 规范中,但 NIO 和 Netty 广泛使用,用于实现零拷贝 IO,受 MaxDirectMemorySize 控制。"