1. Java 进程内存概览
当你启动一个 Java 程序时,JVM(Java Virtual Machine,Java 虚拟机)会向操作系统申请一块内存。有时很小,有时很大(尤其当你运行带一堆模组的 Minecraft 之类的程序时)。这块内存被划分为几个关键区域,每个区域都有自己的职责:
- 栈(Stack)——存放局部变量和方法调用。
- 堆(Heap)——存放你通过 new 创建的所有对象。
- 服务性区域(PermGen/MetaSpace)——存放类的元数据、静态字段以及其他“魔法”内容。
大致如下:
┌───────────────────────────────┐
│ JVM 进程 │
│ ┌─────────────┐ │
│ │ Stack │ ← 每个线程都有自己的栈!
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Heap │ ← 所有线程共享
│ └─────────────┘ │
│ ┌───────────────┐ │
│ │ PermGen/ │ ← 类的元数据
│ │ MetaSpace │
│ └───────────────┘ │
└───────────────────────────────┘
为什么这很重要?
- 理解内存结构能帮助你写出更高效、更安全的代码。
- 更容易诊断诸如 StackOverflowError 或 OutOfMemoryError 之类的问题。
- “垃圾回收器”和“内存泄漏”这些词不再可怕——你知道去哪里、如何排查。
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++ 不同(需要你自己负责释放内存,如 free、delete),在 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 是“看心情”的(实际上取决于内部算法以及内存压力),并不会在你置空引用后立刻回收。不要指望立即释放内存。
GO TO FULL VERSION