JVM 内存结构全链路:堆、栈、方法区、元空间
面试必问的 JVM 内存模型,从堆栈到元空间,每个区域的作用、OOM 类型和调优方向一次性讲清楚。
一、JVM 内存结构全景
┌─────────────────────────────────────────────────────────────┐
│ JVM 内存结构全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 线程共享区域 │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 堆 (Heap) │ │ │
│ │ │ ┌──────────────┬──────────────────────────┐ │ │ │
│ │ │ │ 新生代 │ 老年代 │ │ │ │
│ │ │ │ (Young Gen) │ (Old Gen) │ │ │ │
│ │ │ │ ┌───┬──┬───┐│ │ │ │ │
│ │ │ │ │Eden│S0│S1││ │ │ │ │
│ │ │ │ └───┴──┴───┘│ │ │ │ │
│ │ │ └──────────────┴──────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 方法区 / 元空间 (Metaspace) │ │ │
│ │ │ 类元数据、常量池、静态变量、方法字节码 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 线程私有区域 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ 虚拟机栈 │ │ 本地方法栈 │ │ 程序计数器(PC) │ │ │
│ │ │ (VM Stack)│ │(Native │ │ (Program Counter │ │ │
│ │ │ │ │ Stack) │ │ Register) │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
二、各区域详解
1. 程序计数器(PC Register)
// 每个线程独立拥有,记录当前执行的字节码行号
// 不会 OOM,是唯一不会 OOM 的区域
| 线程私有 | 每个线程独立 |
| 作用 | 记录当前线程执行到的字节码行号 |
| OOM | 不可能发生 |
| 大小 | 很小(一个 word) |
2. 虚拟机栈(VM Stack)
public class StackDemo {
public void methodA() {
int a = 1; // 局部变量
Object obj = new Object(); // 引用
methodB(); // 方法调用 → 压入新栈帧
}
public void methodB() {
int b = 2;
// 栈帧结构:
// ┌─────────────────────┐
// │ 局部变量表 │ a, b, obj, this…
// ├─────────────────────┤
// │ 操作数栈 │ 计算中间结果
// ├─────────────────────┤
// │ 动态链接 │ 指向运行时常量池
// ├─────────────────────┤
// │ 方法返回地址 │ 调用者PC
// └─────────────────────┘
}
}
虚拟机栈结构:
线程1的虚拟机栈
┌─────────────────┐
│ methodA 栈帧 │ ← 栈顶(当前执行)
│ ┌─────────────┐│
│ │局部变量表 ││ this, a=1, obj
│ │操作数栈 ││
│ │动态链接 ││
│ │返回地址 ││
│ └─────────────┘│
├─────────────────┤
│ main 栈帧 │
│ ┌─────────────┐│
│ │局部变量表 ││ args
│ │操作数栈 ││
│ │动态链接 ││
│ │返回地址 ││
│ └─────────────┘│
└─────────────────┘
| StackOverflowError | 栈深度超过限制(递归过深) |
| OutOfMemoryError | 栈扩展时无法申请到足够内存 |
3. 本地方法栈
与虚拟机栈类似,区别在于服务对象是 Native 方法。HotSpot 中与虚拟机栈合二为一。
4. 堆(Heap)
堆是 JVM 管理的最大内存区域,存放对象实例和数组:
// 所有 new 出来的对象都在堆中
Object obj = new Object(); // obj 引用在栈中,Object 对象在堆中
int[] arr = new int[100]; // 数组也在堆中
堆内存分代结构:
┌──────────────────────────────────────────────────────┐
│ 堆 (Heap) │
├───────────────────────┬──────────────────────────────┤
│ 新生代 (1/3) │ 老年代 (2/3) │
│ ┌─────┬────┬────┐ │ │
│ │Eden │ S0 │ S1 │ │ 长期存活的对象 │
│ │ 8 │ 1 │ 1 │ │ 大对象直接进入 │
│ └─────┴────┴────┘ │ Major GC / Full GC │
│ Minor GC │ │
│ 复制算法 │ 标记-清除/标记-整理 │
└───────────────────────┴──────────────────────────────┘
对象分配流程:
new 对象 → Eden 区分配
Eden 满 → Minor GC
存活 → 复制到 S0/S1,年龄+1
年龄 ≥ 15 → 晋升老年代
大对象 → 直接进老年代
| OutOfMemoryError: Java heap space | 堆内存不足,无法分配新对象 |
| OutOfMemoryError: GC overhead limit exceeded | GC 花费超过 98% 时间但回收不到 2% 内存 |
5. 方法区 / 元空间
方法区的演进:
JDK 7 之前: 永久代(Permanent Generation) — 堆的一部分,JVM 管理
JDK 7: 符号引用移到 native memory, 静态变量移到堆
JDK 8+: 元空间(Metaspace) — 使用本地内存(Native Memory)
// 方法区存储的内容
public class MethodAreaDemo {
// 静态变量 → JDK 7+ 移到堆
private static final int MAX = 100;
// 类信息、方法字节码 → 元空间
public void process() {
// 方法字节码存储在元空间
}
// 常量池中的字符串
String s1 = "abc"; // 常量池
String s2 = new String("abc"); // 堆中对象
}
| 位置 | JVM 堆中 | 本地内存 |
| 大小限制 | -XX:PermSize / -XX:MaxPermSize | -XX:MetaspaceSize / -XX:MaxMetaspaceSize |
| GC | Full GC 时回收 | 达到阈值触发 Full GC |
| OOM | java.lang.OutOfMemoryError: PermGen space | java.lang.OutOfMemoryError: Metaspace |
| 动态性 | 容易 OOM(固定大小) | 可用本地内存,不易 OOM |
为什么要用元空间替代永久代?
三、对象在堆中的分配策略
public class AllocationDemo {
public static void main(String[] args) {
// 小对象 → Eden 区
Object small = new Object();
// 大对象 → 老年代(避免 Eden→Survivor 复制开销)
// -XX:PretenureSizeThreshold=1M
byte[] large = new byte[2 * 1024 * 1024]; // 2MB → 直接进老年代
// 长期存活对象 → 老年代
// 对象头有 GC 年龄,每次 Minor GC 存活则 +1
// -XX:MaxTenuringThreshold=15 (默认)
Object longLived = new Object();
// 经过 15 次 Minor GC 仍存活 → 晋升老年代
}
}
对象分配决策树:
new 对象
│
▼
是否大对象? ──── 是 ───▶ 直接进老年代
│ 否
▼
尝试在 TLAB 分配 ──── 成功 ───▶ 分配完成
│ 失败
▼
尝试在 Eden 区 CAS 分配 ──── 成功 ───▶ 分配完成
│ 失败
▼
Minor GC
│
▼
存活对象年龄+1
│
├─ 年龄 ≥ 阈值 ─────────▶ 晋升老年代
├─ Survivor 区放不下 ────▶ 晋升老年代
└─ 否 ──────────────────▶ 复制到另一 Survivor
TLAB(Thread Local Allocation Buffer)
// 每个线程在 Eden 区有一小块私有区域
// 避免多线程分配时的 CAS 竞争
// -XX:+UseTLAB (默认开启)
// 分配流程:
// 1. 先在 TLAB 中分配(无锁)
// 2. TLAB 用完 → CAS 在 Eden 区分配
// 3. Eden 满 → Minor GC
四、对象访问定位
Object obj = new Object();
// 栈中的 reference → 堆中的 Object 对象
访问方式: 句柄 vs 直接指针
句柄方式 (JDK 早期):
栈 (reference) ──▶ 堆 (句柄池)
│
├─▶ 对象实例数据 (堆)
└─▶ 对象类型数据 (元空间)
直接指针方式 (HotSpot):
栈 (reference) ──▶ 堆 (对象头 + 实例数据)
│
└─▶ 对象类型数据 (元空间)
| 优点 | 对象移动时只改句柄指针 | 访问少一次间接跳转,速度快 |
| 缺点 | 多一次访问 | 对象移动时需修改所有引用 |
| 使用 | 无 | HotSpot 使用 |
五、各区域 OOM 类型总结
// 1. 堆 OOM
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 不断创建 1MB 对象
}
// Exception: java.lang.OutOfMemoryError: Java heap space
// 2. 栈溢出
public void recursive() {
recursive(); // 无限递归
}
// Exception: java.lang.StackOverflowError
// 3. 元空间 OOM
// 大量动态生成类(如 CGLIB 代理类)
// Exception: java.lang.OutOfMemoryError: Metaspace
// 4. 直接内存 OOM
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
// Exception: java.lang.OutOfMemoryError: Direct buffer memory
| Java heap space | 堆内存不足 | 增大 -Xmx,检查内存泄漏 |
| GC overhead limit | GC 回收效率过低 | 增大堆或排查泄漏 |
| StackOverflowError | 栈深度超限 | 检查递归,增大 -Xss |
| Metaspace | 元空间不足 | 增大 -XX:MaxMetaspaceSize |
| Direct buffer memory | 直接内存不足 | 检查 NIO Buffer 使用 |
六、常用 JVM 参数
| -Xms | 初始堆大小 | -Xms256m |
| -Xmx | 最大堆大小 | -Xmx1024m |
| -Xmn | 新生代大小 | -Xmn256m |
| -Xss | 栈大小 | -Xss512k |
| -XX:MetaspaceSize | 元空间初始大小 | -XX:MetaspaceSize=128m |
| -XX:MaxMetaspaceSize | 元空间最大大小 | -XX:MaxMetaspaceSize=256m |
| -XX:SurvivorRatio | Eden:Survivor 比例 | -XX:SurvivorRatio=8 |
| -XX:MaxTenuringThreshold | 晋升老年代年龄 | -XX:MaxTenuringThreshold=15 |
| -XX:PretenureSizeThreshold | 大对象阈值 | -XX:PretenureSizeThreshold=1M |
| -XX:+UseTLAB | 启用 TLAB | 默认开启 |
七、实战:内存泄漏排查
// 常见内存泄漏场景
public class MemoryLeakDemo {
// 1. 静态集合持有对象引用
private static final Map<String, Object> cache = new HashMap<>();
public void addToCache(String key, Object value) {
cache.put(key, value); // 永远不清理 → 泄漏
}
// 2. 未关闭的资源
public void readFile() throws IOException {
InputStream is = new FileInputStream("file.txt");
// 忘记 is.close() → 泄漏
}
// 3. 监听器未移除
public void addListener() {
button.addActionListener(e -> {
// 匿名类隐式持有外部类引用
// 如果 button 生命周期长 → 外部类无法回收
});
}
// 4. ThreadLocal 未 remove
private ThreadLocal<Object> threadLocal = new ThreadLocal<>();
public void process() {
threadLocal.set(new Object());
// 忘记 threadLocal.remove() → 线程池场景泄漏
}
}
排查步骤:
1. jps → 找到进程 PID
2. jstat -gcutil <pid> 1000 → 观察 GC 情况
3. jmap -heap <pid> → 查看堆内存分布
4. jmap -dump:format=b,file=heap.hprof <pid> → 导出堆转储
5. MAT / VisualVM 分析 hprof 文件
6. 找到 GC Root 引用链,定位泄漏对象
八、面试高频追问
Q1:为什么要把永久代替换为元空间?
永久代大小固定容易 OOM,尤其是动态代理、动态语言等场景大量生成类。元空间使用本地内存,大小灵活,不易 OOM。
Q2:对象一定分配在堆上吗?
不一定。JIT 的逃逸分析可以证明对象不逃逸时,通过标量替换在栈上分配,避免堆分配和 GC 压力。
Q3:为什么 Eden:S0:S1 = 8:1:1?
新生代中大部分对象朝生夕死,98% 的对象在 Minor GC 时被回收。复制算法只需要 10% 的空间(S0 或 S1)作为备用,Eden 区有 80% 空间可用,整体利用率 90%,远高于 50%(两个 Survivor 各占一半的情况)。
Q4:栈上分配和 TLAB 是一回事吗?
不是。栈上分配是 JIT 逃逸分析后的优化,对象分配在栈帧中,随方法退出自动销毁。TLAB 是堆中 Eden 区的线程私有区域,减少多线程分配时的 CAS 竞争,对象仍在堆中。
九、总结
JVM 内存结构:
线程共享: 堆(对象/数组) + 元空间(类元数据/常量池)
线程私有: 虚拟机栈(栈帧) + 本地方法栈 + 程序计数器
堆: 新生代(Eden:S0:S1=8:1:1) + 老年代
Minor GC(复制) + Full GC(标记-整理)
栈: StackOverflowError(递归过深) / OOM(无法扩展)
元空间: 本地内存, -XX:MaxMetaspaceSize 限制
对象分配: TLAB → Eden → Survivor → 老年代
大对象: 直接进老年代
GC Roots: 栈引用、静态变量、常量、JNI 引用
理解 JVM 内存结构是性能调优和排查 OOM 问题的基础,面试中每个区域的作用、OOM 类型和调优参数都是高频考点。



