JVM 内存结构是怎样的?

2025年 阅读约 10 分钟 面试指南 · Java面试 · JVM

深入解析JVM运行时数据区:堆(Heap)、方法区/元空间(Method Area/Metaspace)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、程序计数器(PC Register),JDK 8 元空间替代永久代的变化,各区域存储内容和OOM场景。

一句话总结

JVM 运行时数据区分为线程共享线程私有两部分。线程共享:(存放对象实例,GC 主战场)和方法区/元空间(存放类信息、常量、静态变量,JDK 8 后移到本地内存)。线程私有:虚拟机栈(方法调用的栈帧,含局部变量表、操作数栈)、本地方法栈(Native 方法调用)、程序计数器(当前线程执行字节码的行号指示器)。

初级理解

JVM 内存结构全景图

┌─────────────────────────────────────────────────────────┐ │ JVM 运行时数据区 │ ├───────────────────────┬─────────────────────────────────┤ │ 线程共享(2个) │ 线程私有(3个) │ ├───────────────────────┼─────────────────────────────────┤ │ ┌─────────────────┐ │ ┌──────────┐ ┌──────────────┐ │ │ │ 堆 Heap │ │ │ 虚拟机栈 │ │ 本地方法栈 │ │ │ │ ┌──┬──┬──────┐ │ │ │ VM Stack │ │Native Stack │ │ │ │ │新│老│ 字符串 │ │ │ └──────────┘ └──────────────┘ │ │ │ │生│年│ 常量池 │ │ │ ┌──────────────┐ │ │ │ │代│代│ │ │ │ │ 程序计数器 │ │ │ │ └──┴──┴──────┘ │ │ │ PC Register │ │ │ └─────────────────┘ │ └──────────────┘ │ │ ┌─────────────────┐ │ │ │ │ 方法区/元空间 │ │ │ │ │ Method Area │ │ │ │ └─────────────────┘ │ │ └───────────────────────┴─────────────────────────────────┘

五大区域速览

区域共享/私有存储内容OOM
堆 Heap线程共享对象实例、数组✅ OOM: Java heap space
方法区/元空间线程共享类信息、常量、静态变量✅ OOM: Metaspace
虚拟机栈线程私有栈帧(局部变量表、操作数栈)✅ StackOverflowError
本地方法栈线程私有Native 方法调用✅ StackOverflowError
程序计数器线程私有字节码行号指示器❌ 唯一不会 OOM 的区域

中级深入

堆(Heap)— GC 主战场

堆是 JVM 管理的最大一块内存,几乎所有对象都在堆上分配。堆可以物理不连续但逻辑连续。JDK 8 后字符串常量池从方法区移到了堆。

// 堆内存分区(分代收集理论) // JDK 8 默认:新生代:老年代 = 1:2 // 新生代内部:Eden:S0:S1 = 8:1:1 ┌──────────────────────────────────────┐ │ 堆 Heap │ ├────────────────┬─────────────────────┤ │ 新生代 Young │ 老年代 Old │ │ ┌──┬──┬────┐ │ │ │ │E │S0│ S1 │ │ 长期存活的对象 │ │ │de│ │ │ │ 大对象直接进入 │ │ │n │ │ │ │ │ │ └──┴──┴────┘ │ │ └────────────────┴─────────────────────┘

虚拟机栈(VM Stack)— 方法调用

每个方法执行时都会创建一个栈帧(Stack Frame),包含:

组成部分作用
局部变量表存放方法参数和局部变量(基本类型、对象引用)
操作数栈字节码指令的操作数入栈/出栈
动态链接指向运行时常量池中该方法的引用
返回地址方法正常退出或异常退出的返回地址
// 栈帧示例 public int add(int a, int b) { // 局部变量表:[this, a, b, result] // 操作数栈:iload a → iload b → iadd → istore result int result = a + b; return result; }

方法区 → 元空间(JDK 8 重大变化)

版本实现位置大小
JDK 7 及以前永久代 PermGenJVM 堆内-XX:PermSize / -XX:MaxPermSize
JDK 8 及以后元空间 Metaspace本地内存(堆外)-XX:MetaspaceSize / -XX:MaxMetaspaceSize
为什么移除永久代?1) 永久代大小难确定,容易 OOM: PermGen space;2) 类元数据放在本地内存更灵活,默认只受系统内存限制;3) 为 HotSpot 和 JRockit 融合铺路。

高级拓展

运行时常量池 vs 字符串常量池

常量池位置(JDK 8)内容
Class 常量池class 文件中编译期生成的字面量和符号引用
运行时常量池元空间Class 常量池的运行时表示,动态可添加
字符串常量池String.intern() 管理的字符串引用
// String.intern() 在不同 JDK 版本的行为 // JDK 6:intern() 会把字符串复制到永久代的字符串常量池 // JDK 7+:intern() 在堆的字符串常量池中记录首次出现的实例引用 String s1 = new StringBuilder("ja").append("va").toString(); System.out.println(s1.intern() == s1); // JDK 6: false, JDK 7+: false("java"已存在) String s2 = new StringBuilder("计").append("算机").toString(); System.out.println(s2.intern() == s2); // JDK 6: false, JDK 7+: true

直接内存(Direct Memory)

不属于 JVM 运行时数据区,但被 NIO 频繁使用。通过 ByteBuffer.allocateDirect() 分配堆外内存,避免 Java 堆和 Native 堆之间的数据复制。受 -XX:MaxDirectMemorySize 限制,默认等于 -Xmx。

各区域 OOM 场景

区域OOM 场景示例
对象太多,GC 无法回收死循环 new 对象、内存泄漏
元空间动态生成大量类CGLIB 代理、大量 JSP
虚拟机栈递归过深无限递归调用
直接内存NIO 分配过多堆外内存大量 DirectByteBuffer

实战场景

场景一:StackOverflowError 排查

// 无限递归 → StackOverflowError public void recursive() { recursive(); // 每次调用创建一个栈帧,栈深度超限 } // 排查:看堆栈最底部的重复方法调用 // 解决:加终止条件,或用循环替代递归

场景二:Metaspace OOM

// CGLIB 动态代理大量生成类 → Metaspace OOM // 排查:jstat -gc <pid> 看 MU/MC 是否持续增长 // 解决:-XX:MaxMetaspaceSize=256m,或限制 CGLIB 代理数量

常用内存查看命令

# 查看堆内存使用 jmap -heap <pid> # 查看元空间使用 jstat -gc <pid> 1000 # 查看堆中对象统计 jmap -histo <pid> | head -20 # 生成堆 dump jmap -dump:format=b,file=heap.hprof <pid>

面试模拟

Q:JVM 内存结构是怎样的?

A:分为线程共享和线程私有。共享:(对象实例,GC主战场,分新生代和老年代)和方法区/元空间(类信息、常量、静态变量,JDK 8 移到本地内存)。私有:虚拟机栈(栈帧,含局部变量表、操作数栈)、本地方法栈(Native方法)、程序计数器(字节码行号,唯一不会OOM的区域)。

Q:JDK 8 为什么用元空间替代永久代?

A:1) 永久代在 JVM 堆内,大小难确定,容易 OOM: PermGen space;2) 元空间在本地内存,默认只受系统内存限制,更灵活;3) 为 HotSpot 和 JRockit 融合做准备(JRockit 没有永久代概念);4) 字符串常量池从永久代移到堆,减少永久代压力。

Q:堆和栈的区别?

A::线程共享,存放对象实例,GC 管理,内存不连续,可动态扩展。生命周期:由 GC 决定。:线程私有,存放栈帧(局部变量表、操作数栈等),方法调用入栈/返回出栈,内存连续。生命周期:方法调用结束自动释放。栈没有 GC,但会有 StackOverflowError。