2深入理解 Java 虚拟机(第3版)

Java 内存区域与内存溢出异常

JVM 内存管理的核心

运行时数据区堆栈方法区程序计数器本地方法栈

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 的区域
java
// 程序计数器示例
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)。每个方法调用创建一个栈帧。

栈帧包含:

  • 局部变量表:存储方法参数和局部变量
  • 操作数栈:用于字节码指令的操作数
  • 动态链接:指向运行时常量池中该方法的引用
  • 方法返回地址:方法结束后返回到调用位置
java
// 栈帧示例
public int add(int a, int b) {  // 栈帧创建
    int c = a + b;  // 局部变量表:a, b, c
    return c;  // 操作数栈:c 的值
}  // 栈帧销毁

可能的异常:

  • StackOverflowError:栈深度超过限制(递归过深)
  • OutOfMemoryError:动态扩展栈时无法申请到足够内存
java
// StackOverflowError 示例
public void infiniteRecursion() {
    infiniteRecursion();  // 无限递归
}

2.4 本地方法栈(Native Method Stack)

线程私有,为 Native 方法服务。与 Java 虚拟机栈类似,只是服务对象不同。

java
// Native 方法示例
public native void nativeMethod();

2.5 Java 堆(Java Heap)

线程共享,对象实例分配的主要区域。是垃圾收集器管理的主要区域。

堆的分代结构:

  • 新生代(Young Generation):Eden + 两个 Survivor(From、To)
  • 老年代(Old Generation):长期存活的对象
┌─────────────────────────────────────────┐
│              Java 堆                    │
├─────────────────────────────────────────┤
│  新生代                    │   老年代     │
├──────────┬──────────┬──────┤             │
│  Eden    │  From    │  To  │             │
└──────────┴──────────┴──────┴─────────────┘

可能的异常:

  • OutOfMemoryError:堆内存不足
java
// 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:+HeapDumpOnOutOfMemoryError

2.6 方法区(Method Area)

线程共享,存储类信息、常量、静态变量、JIT 编译后的代码。

在 Java 8 之后,方法区的实现从永久代(PermGen)改为元空间(Metaspace),使用本地内存。

java
// 方法区存储的内容
public class Example {
    // 静态变量存储在方法区
    public static int staticVar = 100;
    
    // 常量存储在方法区
    public static final String CONSTANT = "Hello";
    
    // 类信息存储在方法区
    public void method() {
        // ...
    }
}

可能的异常:

  • OutOfMemoryError:元空间不足

2.7 运行时常量池(Runtime Constant Pool)

方法区的一部分,存储编译期生成的字面量和符号引用。

java
// 常量池示例
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 堆大小限制,但受物理内存限制。

java
// 直接内存示例
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 误区二:"栈中存储对象"

栈中存储的是对象的引用,对象本身存储在堆中。

java
public void method() {
    Object obj = new Object();  // obj 在栈中,Object 实例在堆中
}

4.3 难点:理解堆的分代结构

堆分为新生代和老年代,不同的垃圾收集器使用不同的分代策略。理解分代结构对于调优 GC 至关重要。

4.4 难点:理解内存溢出场景

不同的区域可能发生不同的内存溢出:

  • 堆溢出:对象过多、内存泄漏
  • 栈溢出:递归过深
  • 元空间溢出:加载的类过多、动态生成类过多

五、实践应用

5.1 查看 JVM 内存区域

bash
# 查看堆内存
jmap -heap <pid>

# 查看非堆内存
jmap -clstats <pid>

# 查看内存使用情况
jstat -gc <pid> 1000

5.2 分析内存溢出

bash
# 生成堆转储
jmap -dump:format=b,file=heap.hprof <pid>

# 使用 MAT 分析堆转储
# 下载 Eclipse MAT,打开 heap.hprof 文件

# 使用 VisualVM 分析
# 启动 VisualVM,连接到目标 JVM,生成堆转储

5.3 调优 JVM 内存参数

bash
# 堆大小
-Xms512m  # 初始堆大小
-Xmx2g    # 最大堆大小

# 新生代大小
-Xmn256m  # 新生代大小

# 元空间大小
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=512m

# 栈大小
-Xss256k  # 线程栈大小

六、本章小结

第2章讲解了 JVM 的内存区域:

程序计数器:线程私有,记录当前执行的字节码指令地址

Java 虚拟机栈:线程私有,存储栈帧

本地方法栈:线程私有,为 Native 方法服务

Java 堆:线程共享,对象实例分配的主要区域

方法区:线程共享,存储类信息、常量、静态变量

运行时常量池:方法区的一部分,存储字面量和符号引用

直接内存:NIO 使用的堆外内存

后续章节将讲解垃圾收集器与内存分配策略。


2/13Java
0%