JVM内存区域划分与作用
JVM 内存布局是排查线上问题的基础。很多开发者遇到 OutOfMemoryError 时不知道从哪里入手。本文结合实际案例,讲清楚各区域的作用和常见异常,帮你建立排查思路。
运行时数据区概览
┌─────────────────────────────────────────┐ │ 线程私有区域 │ │ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ │程序计数器 │ │虚拟机栈 │ │本地方法栈│ │ │ └──────────┘ └──────────┘ └─────────┘ │ ├─────────────────────────────────────────┤ │ 线程共享区域 │ │ ┌───────────────────────────────────┐ │ │ │ 堆 │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ 年轻代 │ │ 老年代 │ │ │ │ │ │ Eden │ │ │ │ │ │ │ │ Survivor0 │ │ │ │ │ │ │ │ Survivor1 │ │ │ │ │ │ │ └─────────────┘ └─────────────┘ │ │ │ └───────────────────────────────────┘ │ │ ┌───────────────────────────────────┐ │ │ │ 方法区(元空间) │ │ │ │ 类信息、常量、静态变量、JIT代码 │ │ │ └───────────────────────────────────┘ │ │ ┌───────────────────────────────────┐ │ │ │ 直接内存(堆外) │ │ │ └───────────────────────────────────┘ │ └─────────────────────────────────────────┘
|
1. 程序计数器(Program Counter Register)
作用:当前线程执行的字节码行号指示器。
特点:
- 线程私有,每个线程独立
- 唯一不会发生 OOM 的区域
- 执行 Java 方法时记录字节码地址,执行 Native 方法时为空
线程A的程序计数器: 0x0001 (执行到第1行) 线程B的程序计数器: 0x0010 (执行到第16行)
|
2. 虚拟机栈(VM Stack)
作用:描述 Java 方法执行的内存模型,每个方法执行时创建栈帧。
#
栈帧结构
┌─────────────────┐ │ 局部变量表 │ 存放基本类型和对象引用 ├─────────────────┤ │ 操作数栈 │ 方法执行的工作区 ├─────────────────┤ │ 动态链接 │ 指向运行时常量池的方法引用 ├─────────────────┤ │ 方法返回地址 │ 方法执行完毕后的返回位置 ├─────────────────┤ │ 附加信息 │ 调试信息等 └─────────────────┘
|
#
局部变量表
public void method(int a, int b) { int c = a + b; Object obj = new Object(); long d = 100L; }
|
#
常见问题:StackOverflowError
public void recursive() { recursive(); }
|
解决:
3. 本地方法栈(Native Method Stack)
作用:为 Native 方法服务。
特点:
- 线程私有
- HotSpot 中与虚拟机栈合二为一
- 也会抛出 StackOverflowError 和 OOM
4. 堆(Heap)
作用:存放对象实例和数组,是 JVM 管理的最大内存区域。
#
堆内存划分
┌──────────────────────────────────────┐ │ 堆 │ │ ┌────────────────────────────────┐ │ │ │ 年轻代 (Young) │ │ -Xmn │ │ ┌──────────────────────────┐ │ │ │ │ │ Eden (8/10) │ │ │ │ │ ├──────────────────────────┤ │ │ │ │ │ Survivor0 (1/10) │ │ │ │ │ ├──────────────────────────┤ │ │ │ │ │ Survivor1 (1/10) │ │ │ │ │ └──────────────────────────┘ │ │ │ └────────────────────────────────┘ │ │ ┌────────────────────────────────┐ │ │ │ 老年代 (Old) │ │ -Xms - Xmn │ └────────────────────────────────┘ │ └──────────────────────────────────────┘
|
#
对象分配与晋升
对象创建 -> Eden区 | |-> Minor GC后存活 -> Survivor0 | |-> 再次Minor GC -> Survivor1(复制算法,S0和S1交换) | |-> 年龄达到阈值(默认15)-> 老年代 | |-> 大对象直接进入老年代
|
#
参数配置
-Xms512m -Xmx512m -Xmn128m -XX:NewRatio=2 -XX:SurvivorRatio=8
|
5. 方法区(Method Area)
作用:存储类信息、常量、静态变量、即时编译器编译后的代码。
#
JDK 8 之前:永久代(PermGen)
-XX:PermSize=64m -XX:MaxPermSize=128m
|
#
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
|
元空间与永久代的区别:
- 永久代在 JVM 堆中,元空间使用本地内存
- 元空间大小只受限于物理内存
- 减少 Full GC 频率
#
方法区存储内容
方法区 ├── 类型信息 │ ├── 类名、修饰符 │ ├── 父类、接口 │ └── 方法信息 ├── 常量池 │ ├── 字面量(字符串、数字) │ └── 符号引用 ├── 字段信息 ├── 方法信息 ├── 静态变量 └── JIT编译后的代码
|
#
运行时常量池
String s1 = "hello"; String s2 = "hello"; String s3 = new String("hello");
System.out.println(s1 == s2); System.out.println(s1 == s3); System.out.println(s1 == s3.intern());
|
6. 直接内存(Direct Memory)
作用:NIO 使用的堆外内存,避免 Java 堆与 Native 堆之间的数据复制。
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
|
参数:
-XX:MaxDirectMemorySize=128m
|
注意:直接内存不受 JVM 堆大小限制,但受物理内存限制,也可能导致 OOM。
内存溢出场景
| 区域 |
OOM 场景 |
错误信息 |
| 堆 |
对象过多,无法回收 |
Java heap space |
| 元空间 |
动态生成类过多 |
Metaspace |
| 虚拟机栈 |
线程过多/递归过深 |
unable to create new native thread |
| 直接内存 |
NIO 使用过多 |
Direct buffer memory |
总结
| 区域 |
线程 |
存储内容 |
异常 |
| 程序计数器 |
私有 |
字节码行号 |
无 |
| 虚拟机栈 |
私有 |
栈帧、局部变量 |
StackOverflowError |
| 本地方法栈 |
私有 |
Native方法 |
StackOverflowError |
| 堆 |
共享 |
对象实例 |
OOM |
| 方法区 |
共享 |
类信息、常量 |
OOM |
| 直接内存 |
共享 |
NIO缓冲区 |
OOM |
理解 JVM 内存区域,是进行内存调优和 OOM 问题排查的基础。
核心要点
堆内存分为年轻代和老年代,年轻代又分为 Eden、Survivor 等区域
栈内存是线程私有的,每个线程都有自己的栈空间
方法区(元空间)存储类信息、常量池等
常见的 OOM 类型:HeapSpace、OutOfMemoryError、StackOverflowError
总结
理解 JVM 内存模型是成为高级 Java 工程师的必备技能。在实际项目中,配置合适的堆内存大小、选择合适的垃圾收集器,都需要对内存布局有清晰的认识。