第2章:Java 内存区域与内存溢出异常
一、导读
2.1 本章学习目标
本章详细讲解 JVM 的运行时数据区:程序计数器、Java 虚拟机栈、本地方法栈、Java 堆、方法区。每个区域的作用、可能的异常、以及内存溢出场景。
本章的核心学习目标包括:
- 理解 JVM 运行时数据区的划分:哪些是线程私有的,哪些是共享的
- 掌握每个区域的作用:存储什么数据、何时创建、何时销毁
- 理解常见的内存异常:StackOverflowError、OutOfMemoryError
- 能够诊断内存溢出问题:分析堆转储、定位内存泄漏
2.2 为什么这一章如此重要
内存管理是 JVM 的核心功能。理解内存区域的划分,对于:
- 排查内存泄漏:知道对象分配在哪个区域
- 优化 GC 性能:理解堆的分代结构
- 避免 StackOverflowError:控制递归深度
- 调优 JVM 参数:合理设置各区域大小
二、核心概念详解
2.1 运行时数据区概览
JVM 运行时数据区分为以下几个部分:
┌─────────────────────────────────────────┐
│ 线程私有 │
├─────────────────────────────────────────┤
│ 程序计数器 │ Java 虚拟机栈 │ 本地方法栈 │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 线程共享 │
├─────────────────────────────────────────┤
│ Java 堆 │ 方法区 │
└─────────────────────────────────────────┘2.2 程序计数器(Program Counter Register)
线程私有,记录当前线程执行的字节码指令地址。
- 如果执行的是 Java 方法,记录正在执行的字节码指令地址
- 如果执行的是 Native 方法,值为 undefined
- 唯一不会发生 OutOfMemoryError 的区域
// 程序计数器示例
public void method() {
int a = 1; // 字节码指令地址 0
int b = 2; // 字节码指令地址 3
int c = a + b; // 字节码指令地址 6
}2.3 Java 虚拟机栈(Java Virtual Machine Stack)
线程私有,存储栈帧(Stack Frame)。每个方法调用创建一个栈帧。
栈帧包含:
- 局部变量表:存储方法参数和局部变量
- 操作数栈:用于字节码指令的操作数
- 动态链接:指向运行时常量池中该方法的引用
- 方法返回地址:方法结束后返回到调用位置
// 栈帧示例
public int add(int a, int b) { // 栈帧创建
int c = a + b; // 局部变量表:a, b, c
return c; // 操作数栈:c 的值
} // 栈帧销毁可能的异常:
- StackOverflowError:栈深度超过限制(递归过深)
- OutOfMemoryError:动态扩展栈时无法申请到足够内存
// StackOverflowError 示例
public void infiniteRecursion() {
infiniteRecursion(); // 无限递归
}2.4 本地方法栈(Native Method Stack)
线程私有,为 Native 方法服务。与 Java 虚拟机栈类似,只是服务对象不同。
// Native 方法示例
public native void nativeMethod();2.5 Java 堆(Java Heap)
线程共享,对象实例分配的主要区域。是垃圾收集器管理的主要区域。
堆的分代结构:
- 新生代(Young Generation):Eden + 两个 Survivor(From、To)
- 老年代(Old Generation):长期存活的对象
┌─────────────────────────────────────────┐
│ Java 堆 │
├─────────────────────────────────────────┤
│ 新生代 │ 老年代 │
├──────────┬──────────┬──────┤ │
│ Eden │ From │ To │ │
└──────────┴──────────┴──────┴─────────────┘可能的异常:
- OutOfMemoryError:堆内存不足
// OutOfMemoryError 示例
public class HeapOOM {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 1MB
}
}
}
// 运行参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError2.6 方法区(Method Area)
线程共享,存储类信息、常量、静态变量、JIT 编译后的代码。
在 Java 8 之后,方法区的实现从永久代(PermGen)改为元空间(Metaspace),使用本地内存。
// 方法区存储的内容
public class Example {
// 静态变量存储在方法区
public static int staticVar = 100;
// 常量存储在方法区
public static final String CONSTANT = "Hello";
// 类信息存储在方法区
public void method() {
// ...
}
}可能的异常:
- OutOfMemoryError:元空间不足
2.7 运行时常量池(Runtime Constant Pool)
方法区的一部分,存储编译期生成的字面量和符号引用。
// 常量池示例
public class ConstantPool {
public static void main(String[] args) {
String s1 = "Hello"; // 字面量
String s2 = "Hello"; // 相同的字面量,指向同一个对象
System.out.println(s1 == s2); // true
}
}2.8 直接内存(Direct Memory)
NIO 使用的堆外内存,不受 JVM 堆大小限制,但受物理内存限制。
// 直接内存示例
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB三、重要知识点
3.1 对象创建的内存分配
对象创建时的内存分配流程:
类加载检查:检查类是否已加载
分配内存:在堆中为对象分配内存
- 指针碰撞:堆内存规整时使用
- 空闲列表:堆内存不规整时使用
初始化零值:将内存空间初始化为零值
设置对象头:设置 Mark Word、类型指针等
执行构造方法:调用 <init> 方法
3.2 对象的内存布局
对象在堆中的存储布局:
- 对象头(Header):
- Mark Word:存储 hashCode、GC 分代年龄、锁状态等
- 类型指针:指向类元数据
- 数组长度(如果是数组)
- 实例数据(Instance Data):对象的实际数据
- 对齐填充(Padding):保证对象大小是 8 字节的整数倍
3.3 对象的访问定位
访问对象的方式:
- 句柄访问:通过句柄池间接访问
- 直接指针访问:直接通过引用访问(HotSpot 使用)
四、常见误区与难点
4.1 误区一:"方法区就是永久代"
在 Java 8 之前,方法区的实现是永久代(PermGen)。Java 8 之后,方法区的实现改为元空间(Metaspace),使用本地内存。
4.2 误区二:"栈中存储对象"
栈中存储的是对象的引用,对象本身存储在堆中。
public void method() {
Object obj = new Object(); // obj 在栈中,Object 实例在堆中
}4.3 难点:理解堆的分代结构
堆分为新生代和老年代,不同的垃圾收集器使用不同的分代策略。理解分代结构对于调优 GC 至关重要。
4.4 难点:理解内存溢出场景
不同的区域可能发生不同的内存溢出:
- 堆溢出:对象过多、内存泄漏
- 栈溢出:递归过深
- 元空间溢出:加载的类过多、动态生成类过多
五、实践应用
5.1 查看 JVM 内存区域
# 查看堆内存
jmap -heap <pid>
# 查看非堆内存
jmap -clstats <pid>
# 查看内存使用情况
jstat -gc <pid> 10005.2 分析内存溢出
# 生成堆转储
jmap -dump:format=b,file=heap.hprof <pid>
# 使用 MAT 分析堆转储
# 下载 Eclipse MAT,打开 heap.hprof 文件
# 使用 VisualVM 分析
# 启动 VisualVM,连接到目标 JVM,生成堆转储5.3 调优 JVM 内存参数
# 堆大小
-Xms512m # 初始堆大小
-Xmx2g # 最大堆大小
# 新生代大小
-Xmn256m # 新生代大小
# 元空间大小
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=512m
# 栈大小
-Xss256k # 线程栈大小六、本章小结
第2章讲解了 JVM 的内存区域:
程序计数器:线程私有,记录当前执行的字节码指令地址
Java 虚拟机栈:线程私有,存储栈帧
本地方法栈:线程私有,为 Native 方法服务
Java 堆:线程共享,对象实例分配的主要区域
方法区:线程共享,存储类信息、常量、静态变量
运行时常量池:方法区的一部分,存储字面量和符号引用
直接内存:NIO 使用的堆外内存
后续章节将讲解垃圾收集器与内存分配策略。