CodeGym /课程 /JAVA 25 SELF /JVM 内存结构:栈、堆、PermGen/MetaSpace

JVM 内存结构:栈、堆、PermGen/MetaSpace

JAVA 25 SELF
第 64 级 , 课程 0
可用

1. Java 进程内存概览

当你启动一个 Java 程序时,JVM(Java Virtual Machine,Java 虚拟机)会向操作系统申请一块内存。有时很小,有时很大(尤其当你运行带一堆模组的 Minecraft 之类的程序时)。这块内存被划分为几个关键区域,每个区域都有自己的职责:

  • 栈(Stack)——存放局部变量和方法调用。
  • 堆(Heap)——存放你通过 new 创建的所有对象。
  • 服务性区域(PermGen/MetaSpace)——存放类的元数据、静态字段以及其他“魔法”内容。

大致如下:

┌───────────────────────────────┐
│ JVM 进程                      │
│ ┌─────────────┐               │
│ │  Stack      │ ← 每个线程都有自己的栈!
│ └─────────────┘               │
│ ┌─────────────┐               │
│ │  Heap       │ ← 所有线程共享
│ └─────────────┘               │
│ ┌───────────────┐             │
│ │ PermGen/      │ ← 类的元数据
│ │ MetaSpace     │
│ └───────────────┘             │
└───────────────────────────────┘

为什么这很重要?

  • 理解内存结构能帮助你写出更高效、更安全的代码。
  • 更容易诊断诸如 StackOverflowErrorOutOfMemoryError 之类的问题。
  • “垃圾回收器”和“内存泄漏”这些词不再可怕——你知道去哪里、如何排查。

2. 栈(Stack):快、局部,但不永久

栈是为每个线程单独分配的特殊内存区域。它像一摞盘子:最后放上去的,要在拿走其他盘子后才能先拿下来。也就是说,栈遵循 LIFO(Last In, First Out,后进先出)原则。

栈的作用

栈中存放:

  • 方法的局部变量(例如方法里的 int x = 5;)。
  • 方法返回地址(用于在方法结束后知道回到哪里继续执行)。

每次调用方法时,都会向栈中压入一个新的帧(stack frame)——类似一个“盒子”,里面放着该方法的所有局部变量和一些管理信息。方法执行完毕后,这个帧会被弹出——方法的局部变量随之消失。

示例

public static void main(String[] args) {
    int a = 10;           // a 在 main 的栈里
    int b = sum(a, 5);    // 调用 sum
}

public static int sum(int x, int y) {
    int result = x + y;   // x、y、result 在 sum 的栈里
    return result;
}
  • 调用 sum 时,会在栈中为它创建单独的帧。
  • sum 执行完毕后,它的局部变量会消失。

变量的生命周期

局部变量只在其所属方法执行期间存活。方法一旦结束——它们就不存在了,内存会被立即释放。

栈溢出

如果你不小心(或故意)写了无限递归,每次方法调用都会向栈中压入一个新帧。某个时刻栈会被耗尽,然后你会得到:

Exception in thread "main" java.lang.StackOverflowError

示例:

public static void main(String[] args) {
    recurse();
}
public static void recurse() {
    recurse(); // 无限递归!
}

栈的大小

栈的大小是有限的——通常每个线程只有几 MB(可通过参数 -Xss 指定)。当栈被耗尽——程序会因错误而崩溃。

3. 堆(Heap):对象的居所

堆(Heap)是所有线程共享的内存区域,所有你通过 new 创建的对象以及数组都存活于此。面向对象编程的“魔法”基本都发生在堆中。

对象如何进入堆?

String s = new String("Hello");
int[] arr = new int[10];
  • 变量 s 是引用,位于栈中。
  • String 对象本身和数组 arr 位于堆中。

对象的生命周期

对象在堆中存活,前提是至少存在一个强引用(strong reference)指向它。一旦没有任何引用指向该对象——它就变成“垃圾”,可被垃圾回收器(GC)删除。

内存管理

与 C/C++ 不同(需要你自己负责释放内存,如 freedelete),在 Java 中由 GC 负责。你不能显式释放对象,但可以将指向它的所有引用置空——这样它会成为待回收对象。

示意:什么放在哪?

Stack (main)
  └─ s ─┬────────────┐
         │           │
         ▼           │
      Heap           │
   ┌─────────────┐   │
   │ String "Hello"◄──┘
   └─────────────┘

堆的特点

  • 整个 JVM 进程只存在一个堆。
  • 可以在启动时指定堆大小(-Xmx-Xms)。
  • 如果堆中没有可用空间且 GC 无法释放内存——程序会抛出 OutOfMemoryError

4. PermGen 与 MetaSpace:类“住”在哪里?

当你写下 class MyClass { ... } 并运行程序时,JVM 需要有地方存放与该类有关的一切——方法、字段、字节码、静态变量、常量甚至字符串字面量。为此,JVM 有一个专门的内存区域来“安置”类。

在 Java 8 之前,这个区域叫做 PermGen(Permanent Generation)。但它问题不少——例如大小固定,如果空间不足,应用就会直接抛出 OutOfMemoryError: PermGen space 错误。

自 Java 8 起,出现了新的、更灵活的区域——MetaSpace。它取代了旧的 PermGen,并且可以自动扩展,会按需占用系统可用的物理内存。

PermGen(Java 8 之前)

  • PermGen 中存放类的元数据、静态字段、字符串字面量。
  • PermGen 的大小受限(默认较小),可以通过参数 -XX:MaxPermSize=256m 增大。
  • 如果应用动态加载了大量类(例如在 Web 服务器中),PermGen 可能“耗尽”,你会看到错误:
java.lang.OutOfMemoryError: PermGen space
  • 问题:当类被动态卸载时,PermGen 的清理并不总是可靠(例如重载 Web 应用时)。

MetaSpace(Java 8+)

  • 自 Java 8 起,PermGen 消失,MetaSpace 出现。
  • MetaSpace 存放类的元数据,但现在是在本机内存中(不在 Java 堆里)。
  • MetaSpace 的大小默认不受限(仅受系统内存限制),但可以通过 -XX:MaxMetaspaceSize=512m 设置上限。
  • 当内存不足时,错误现在表现为:
java.lang.OutOfMemoryError: Metaspace
  • MetaSpace 中同样包含静态字段、方法、类信息。

示意:整体结构

┌───────────────────────────────┐
│ JVM 进程                      │
│ ┌─────────────┐               │
│ │  Stack      │ ← 局部变量、方法调用
│ └─────────────┘               │
│ ┌─────────────┐               │
│ │  Heap       │ ← 对象、数组、所有通过 new 创建的内容
│ └─────────────┘               │
│ ┌───────────────┐             │
│ │ MetaSpace     │ ← 类元数据、静态字段
│ └───────────────┘             │
└───────────────────────────────┘

为什么这很重要?

如果你只写普通桌面或服务端应用,可能从未遇到 PermGen 或 MetaSpace 的问题。但如果你在做动态类加载(如插件、Web 应用、以及像 Spring 这样会加载/卸载大量类的框架),那么了解 MetaSpace 就是必备技能!

5. 插图:JVM 内存示意图

flowchart TD
    subgraph JVM
        direction TB
        Stack1["Stack (Thread 1)"]
        Stack2["Stack (Thread 2)"]
        Heap[Heap]
        MetaSpace[MetaSpace]
    end
    Stack1 --指向--> Heap
    Stack2 --指向--> Heap
    Heap --使用来自--> MetaSpace
  • 每个线程都有自己的栈。
  • 所有栈都可以引用堆中的对象。
  • 堆中的对象“知道”自己的类,该类的信息存放在 MetaSpace 中。

6. 示例:在真实代码中的样子

public class MemoryDemo {
    public static void main(String[] args) {
        int x = 42; // x 在 main 的栈里
        String s = "Hello!"; // s 是栈中的引用,String 对象在堆里,字面量 "Hello!" 在 MetaSpace
        Person p = new Person("Alice"); // p 是栈中的引用,Person 对象在堆里

        // 调用一个方法以创建新的栈帧
        printPerson(p);
    }

    public static void printPerson(Person person) {
        // person 是 printPerson 栈中的引用
        System.out.println(person.getName());
    }
}

class Person {
    private String name;
    public Person(String name) {
        this.name = name;
    }
    public String getName() { return name; }
}

解析:

  • x 是局部变量,存活在 main 方法的栈中。
  • s 是栈中的引用,对应的 String 对象在堆中,字符串字面量 "Hello!" 在 MetaSpace。
  • p 是栈中的引用,对应的 Person 对象在堆中。
  • Person 及其所有方法/字段——在 MetaSpace(类元数据)。
  • 调用 printPerson(p) 会创建新的栈帧;其中局部引用 person 指向堆中的同一个对象。

7. JVM 如何管理内存:简明 FAQ

我能管理栈吗?
不能,栈完全由 JVM 控制。你只能在启动时设置其大小(-Xss)。

我能管理堆吗?
部分可以:堆大小可在启动时设置(-Xmx-Xms)。清理由垃圾回收器(GC)负责。

我能管理 MetaSpace 吗?
可以限制大小(-XX:MaxMetaspaceSize),但通常不需要。

内存不足时会发生什么?
— 如果栈耗尽——StackOverflowError
— 如果堆耗尽——OutOfMemoryError: Java heap space
— 如果 MetaSpace 耗尽——OutOfMemoryError: Metaspace

8. 使用内存时的常见错误

错误 #1:由于无限递归导致的 StackOverflowError。 最常见的原因是忘记为递归设置退出条件。例如方法不停地自我调用。JVM 不可能无止境地扩展栈,程序终将“崩溃”。

错误 #2:由于堆溢出导致的 OutOfMemoryError。 如果你创建了太多对象,并且变量/集合仍然持有它们的引用(例如不断向列表添加元素,却从不删除),堆可能会耗尽。

错误 #3:OutOfMemoryError: PermGen space / Metaspace。 如果你使用插件或动态加载大量类,而 MetaSpace 没有被清理(例如类卸载不当),MetaSpace 也可能耗尽。

错误 #4:混淆“引用”和“对象”。 许多初学者会混淆:在栈中的 Person 类型变量只是一个引用,而对象本身在堆中。

错误 №5:以为垃圾回收器会立刻删除一切。 GC 是“看心情”的(实际上取决于内部算法以及内存压力),并不会在你置空引用后立刻回收。不要指望立即释放内存。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION