一句话总结
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 及以前 | 永久代 PermGen | JVM 堆内 | -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。