11 · JVM 内存结构与运行时数据区
对应学习计划:第一月第3周「JVM 虚拟机(面试高频压轴)」
面试定位:JVM 内存结构是 JVM 面试的第一问,也是 OOM 排查、GC 调优的「地图」。答不清内存分区,后面的题全掉分。
一、本文能帮你回答的面试题
- JVM 运行时数据区有哪几块?线程私有/共享怎么分?
- 堆(Heap)的分代结构?(新生代/老年代/元空间)
- 栈帧里都有什么?(局部变量表/操作数栈/动态连接/返回地址)
-Xms/-Xmx/-Xmn/-XX:MetaspaceSize都是控制谁的?- 什么对象进老年代?(大对象、长期存活对象)
- 常见 OOM:堆溢出、栈溢出、元空间溢出的区别?
- 对象创建到被回收的完整生命周期?
二、概念介绍
JVM 按「是否线程私有」把内存分成两块:
JVM 内存(运行时数据区)
├── 线程私有(随线程生死)
│ ├── 程序计数器(PC)
│ ├── 虚拟机栈(Stack)
│ └── 本地方法栈(Native Method Stack)
└── 线程共享(随 JVM 生死)
├── 堆(Heap)
├── 方法区(Metaspace/元空间)
└── 直接内存(Direct Memory,堆外)
记忆口诀:两栈一计线程有,一堆一区线程共。
三、原理深入
3.1 线程私有区(三兄弟)
① 程序计数器(PC Register)
- 记录当前线程执行的字节码行号。
- 唯一不会 OOM 的区域;唯一规范中没有规定任何 OutOfMemoryError 的区域。
- 多线程切换时靠它恢复执行位置。
② 虚拟机栈(Stack)
- 每个方法调用 = 压入一个栈帧 ;栈内存默认
-Xss(如 512k~1m)。 - 栈帧组成(面试常问):
-
局部变量表:8 大基本类型 + 引用 + returnAddress 的槽位。
-
操作数栈:字节码指令的运算工作区(如 iadd 从栈取两数相加压回)。
-
动态连接:指向运行时常量池的方法引用(实现多态)。
-
方法返回地址:异常表 + 正常返回的跳转位置。
线程A栈: [栈帧3: methodC()] → 栈帧2: methodB() → 栈帧1: methodA() → main
栈越深 → 递归太深 → StackOverflowError(不是 OOM 栈)
-
③ 本地方法栈:给 native 方法用(如 Unsafe 底层、JNI),与虚拟机栈对称。
3.2 线程共享区
① 堆(Heap)------JVM 内存大头,GC 主战场
堆(默认 -Xms = 起始,-Xmx = 最大,通常两者设相等避免动态伸缩)
├── 新生代(Young)
│ ├── Eden(伊甸园,新对象都先去这)
│ ├── S0(Survivor From)
│ └── S1(Survivor To,S0/S1 轮换)
└── 老年代(Old)
对象晋升路线:
new 对象 → Eden → (MinorGC 存活 + 年龄+1)→ S0/S1 反复换 → 年龄≥15(默认 MaxTenuringThreshold)→ Old
大对象(-XX:PretenureSizeThreshold)→ 直接 Old
② 方法区 / 元空间(Metaspace,JDK8 起)
- 存放:类元信息(Class 对象、方法字节码)、运行时常量池、静态变量(JDK7 后移到堆)。
- JDK7 叫永久代 Permanent Gen,JDK8 改成元空间(直接内存) ------为什么要改?永久代有固定上限且容易 OOM(类加载器泄漏、动态代理过多);元空间使用本地内存,默认只看 OS 内存,并支持动态扩容。
- 控制:
-XX:MetaspaceSize(初始/触发阈值)、-XX:MaxMetaspaceSize(上限)。
③ 直接内存(Direct Memory)
- NIO 的
DirectByteBuffer、Netty 的堆外内存都在这。 - 不受 -Xmx 约束,受 OS 物理内存限制;
-XX:MaxDirectMemorySize控制。 - 溢出特征:
OutOfMemoryError: Direct buffer memory(比较坑:GC 不管它,只能靠Cleaner回收)。
3.3 常见参数速查
| 参数 | 控制 | 默认(典型) |
|---|---|---|
-Xms |
堆初始大小 | 物理内存 1/64 |
-Xmx |
堆最大大小 | 物理内存 1/4 |
-Xmn |
新生代大小 | 堆 1/3 |
-Xss |
栈大小/线程栈 | 1MB(JDK8) |
-XX:MetaspaceSize |
元空间初始 | 21MB |
-XX:MaxMetaspaceSize |
元空间上限 | 无上限(OS 决定) |
-XX:MaxDirectMemorySize |
直接内存上限 | 略小于堆 |
生产铁律:
-Xms必须等于-Xmx,避免堆动态伸缩导致的频繁 FullGC 与性能抖动。
3.4 对象的完整一生(答题剧本)
① 类加载 → ② 分配内存(指针碰撞/空闲列表)→ ③ 内存零值初始化
→ ④ 设置对象头(Mark Word + 类型指针)→ ⑤ 执行 <init> 构造
→ 使用中:可达性分析(GC Roots 引用链)
→ 死亡时:标记 + 清除 → 新生代复制/老年代标记整理 → 内存归还
GC Roots(可达性分析的根):
- 栈(虚拟机栈/本地方法栈)中引用的对象。
- 静态变量、常量引用。
- 活跃线程(Thread)、JNI 引用(GC Roots 背下来,面试必问)。
3.5 四种 OOM 一表对比
| 溢出类型 | 报错 | 原因 | 排查 |
|---|---|---|---|
| 堆溢出 | Java heap space |
对象太多/内存泄漏 | 堆 dump + MAT 分析 |
| 栈溢出 | StackOverflowError |
递归无出口/栈太深 | 看异常栈 |
| 元空间溢出 | Metaspace |
动态代理/CGLIB 类无限生成 | 检查类加载器泄漏 |
| 直接内存溢出 | Direct buffer memory |
堆外缓存未释放 | 检查 NIO/Netty 是否泄漏 |
四、实战例子
实战 1:看运行时参数(打印 JVM 配置)
java
public class JvmInfo {
public static void main(String[] args) {
long max = Runtime.getRuntime().maxMemory() / 1024 / 1024;
long total = Runtime.getRuntime().totalMemory() / 1024 / 1024;
long free = Runtime.getRuntime().freeMemory() / 1024 / 1024;
System.out.println("堆最大: " + max + "MB");
System.out.println("当前堆: " + total + "MB");
System.out.println("空闲: " + free + "MB");
System.out.println("核数: " + Runtime.getRuntime().availableProcessors());
}
}
// 配合 jcmd <pid> VM.flags 或 jmap -heap <pid> 查看真实参数
实战 2:栈溢出复现(StackOverflowError)
java
public class StackOverflowDemo {
static int depth = 0;
static void recurse() {
depth++;
recurse(); // 无限递归
}
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.out.println("栈溢出,递归深度: " + depth);
}
}
}
// 换 -Xss512k 减少栈大小 → 深度变小 → 证明栈空间由 -Xss 控制
实战 3:对象晋升到老年代(观察 GC 日志)
bash
# 启动参数:xms/xmx 相等避免伸缩,打印 GC 详情
java -Xms256m -Xmx256m -Xmn128m -XX:+UseParallelGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps Demo
# 反复创建大对象(2MB × 100 次,超过 Eden 一半会直接进老年代)
# 只见 MajorGC(Old)出现:证明大对象越过了 Survivor
java
public class BigObjectDemo {
private static final int CHUNK = 2 * 1024 * 1024; // 2MB
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
byte[] big = new byte[CHUNK]; // 大对象
}
}
}
五、面试话术(背下来)
Q:JVM 内存结构?
「分线程私有和共享。私有:程序计数器、虚拟机栈、本地方法栈。共享:堆(新生代/老年代)、方法区(JDK8 是元空间)、还有堆外直接内存。栈管线程方法调用,帧里是局部变量表和操作数栈;堆管对象,GC 主战场;元空间放类元信息,走本地内存。」
Q:为什么要元空间替代永久代?
「永久代是堆内固定大小,容易 OOM(类加载器泄漏时);JDK8 的元空间直接使用本地内存,不受堆大小限制,默认上限是 OS 内存,而且字符串常量池、静态变量等已挪到堆,从数据归属上也更合理。」
Q:对象怎么进老年代?
「三种:一是 MinorGC 后存活且年龄达到阈值(默认15)被晋升;二是大对象超过 -XX:PretenureSizeThreshold 直接进老年代(Eden 放不下);三是 Survivor 区放不下的对象直接进老年代(动态年龄判定规则)。」
Q:怎么排查堆 OOM?
「加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath 生成 dump,用 MAT/VisualVM 查大对象和引用链,看是内存泄漏(该回收没回收)还是内存不足(真的大数据量)。线上有「gc.log + dump 快照 + 压测模拟」标准流程。」
六、踩坑总结(易错点)
| # | 坑 | 说明 |
|---|---|---|
| 1 | -Xms 不等于 -Xmx |
堆动态伸缩,缩时卡顿 + FullGC 频繁 |
| 2 | 盲目增大堆 | 堆越大 GC 单次 STW 越长;配合吞吐目标调,不是越大越好 |
| 3 | 以为方法区没了 | 只是换名成元空间,JDK8 后从堆内挪到本地内存 |
| 4 | 栈溢出当 OOM 查 | 是 Error 不是 OOM,无限递归/深链首查代码 |
| 5 | 忽略直接内存 | Netty/NIO 泄漏报 Direct buffer memory,GC日志里看似正常但进程 OOM |
| 6 | 程序计数器有 OOM | 没有!它是唯一不会溢出的区域 |
| 7 | 混淆 StackOverflowError 和 OutOfMemoryError: unable to create new native thread |
后者是线程太多耗尽操作系统线程/内存,可能是你开了无限线程池 |
七、课后作业
- 手画一张「JVM 内存结构图」,标出各区域放什么 + 控制参数。
- 写一个堆 OOM 的 Demo,用
-Xmx64m -XX:+HeapDumpOnOutOfMemoryError生成 dump,再写一个 MAT 的分析结论。 - 用
jmap -heap观察一个运行中 SpringBoot 的堆分区与新生代/老年代占比。 - 验证:什么时候新生代对象会直接去老年代(提示:Survivor 空间不足以容纳时)。