Skip to content

03.5 - 方法区与元空间

定位: 存储类元数据、运行时常量池、JIT 编译代码的线程共享区域——JDK 8 从 PermGen 迁移到 Metaspace 面试高频度: ⭐⭐⭐⭐ 考查方式: PermGen 到 Metaspace 的迁移原因、类元数据存储内容、Metaspace OOM

一、这是什么?为什么需要它?

方法区(Method Area)是 JVM 规范定义的一个逻辑概念——所有线程共享,存储已加载的类信息、常量、静态变量、JIT 编译后的代码等数据。

如果说堆是对象的"家"(对象实例存储的地方),那方法区就是对象的"设计图仓库"——每个对象的类型信息(类名、父类、接口、方法、字段定义等)都存储在这里。

堆上的对象:                         方法区的类元数据:
┌────────────────────┐             ┌──────────────────────┐
│ new String("hello")│ 指向类型    │ String 类的元数据      │
│   obj              │ ─────────→ │ ├ 类名: java/lang/String│
│                    │             │ ├ 父类: Object        │
│                    │             │ ├ 接口: Serializable  │
│                    │             │ ├ 方法表               │
│                    │             │ │  ├ length() → 地址  │
│                    │             │ │  ├ charAt() → 地址  │
│                    │             │ │  └ ...              │
│                    │             │ └ 字段表               │
└────────────────────┘             └──────────────────────┘

为什么需要单独存储类元数据? JVM 执行期间需要频繁查询类型信息:instanceof 检查、方法调用分派、强转类型校验、反射 API。如果每次都需要从 Class 文件重新解析,性能根本无法接受。方法区就是这些运行时类型信息的缓存,类加载一次,后续整个生命周期复用。

二、原理拆解

2.1 方法区存储什么?

┌──────────────────────────────────────────────┐
│              方法区 / Metaspace               │
│                                              │
│  ┌─ 类元数据 (Class Metadata) ─────────────┐ │
│  │  每个加载的类对应一组元数据:              │ │
│  │  - 类名、父类、接口列表                  │ │
│  │  - 字段信息 (名称、类型、修饰符)          │ │
│  │  - 方法信息 (名称、描述符、字节码)        │ │
│  │  - 方法表 (vtable / itable)             │ │
│  │  - 常量池引用                           │ │
│  │  - 注解信息                              │ │
│  └─────────────────────────────────────────┘ │
│                                              │
│  ┌─ 运行时常量池 (Runtime Constant Pool) ───┐ │
│  │  Class 文件常量池在类加载后:              │ │
│  │  - 符号引用 (待解析的类/方法/字段引用)    │ │
│  │  - 直接引用 (已解析的内存地址)            │ │
│  │  - String.intern() 动态加入的字符串 (JDK7前)│
│  └─────────────────────────────────────────┘ │
│                                              │
│  ┌─ JIT 编译代码缓存 ──────────────────────┐ │
│  │  JIT 编译的热点方法机器码                 │ │
│  │  (由 -XX:ReservedCodeCacheSize 控制)     │ │
│  └─────────────────────────────────────────┘ │
│                                              │
│  ┌─ 静态变量 (JDK 7 后) ───────────────────┐ │
│  │  类变量 (static field) 的引用或值         │ │
│  │  (HotSpot 实际实现在堆上, 逻辑上在方法区)  │ │
│  └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘

2.2 PermGen 时代:为什么 OOM 像流行病?

在 JDK 7 及之前,方法区的实现被称为永久代(PermGen)——因为它和堆连着,被认为是"永久"存在的数据(GC 不轻易回收)。

PermGen 的两个致命问题:

┌─ 问题 1: 固定大小 ─────────────────────────┐
│  -XX:MaxPermSize 控制上限 (默认 64MB/82MB)   │
│  调小 → OOM, 调大 → 浪费                     │
│  生产环境难以预测需要多大 PermGen              │
└─────────────────────────────────────────────┘
┌─ 问题 2: 字符串常量池在 PermGen ───────────┐
│  String.intern() 动态增长, PermGen 满了 OOM  │
│  PermGen GC 条件苛刻, intern 的字符串不回收  │
│  生产环境: 同一个字符串被 intern 后永不释放   │
└─────────────────────────────────────────────┘

典型场景: 在 Tomcat 中频繁热部署应用。每次部署用新的 ClassLoader 加载类,但上一个 ClassLoader 如果还存在引用,PermGen 中的类元数据无法被卸载。反复几次后 PermGen 满 → java.lang.OutOfMemoryError: PermGen space

解决方案(当时): 调大 -XX:MaxPermSize=256m,或者干脆不要频繁热部署。但这些都是治标不治本。

2.3 JDK 8 迁移到 Metaspace

JDK 8 彻底移除了 PermGen,用元空间(Metaspace) 替代,存储在本地内存(Native Memory) 中,不再属于 JVM 堆。

JDK 8 之前的 JVM 内存结构          JDK 8 之后的 JVM 内存结构
(方法区在 PermGen, 属于堆)         (方法区在 Metaspace, 在本地内存)

┌─────────────────────┐           ┌─────────────────────┐
│     堆               │           │     堆               │
│  ┌───────┬────────┐ │           │  ┌───────┬────────┐ │
│  │ Young │ Old    │ │           │  │ Young │ Old    │ │
│  ├───────┴────────┤ │           │  ├───────┴────────┤ │
│  │   PermGen      │ │           │  │ 字符串常量池    │ │
│  │  ├ 类元数据     │ │           │  └────────────────┘ │
│  │  ├ 运行时常量池  │ │           └─────────────────────┘
│  │  ├ 字符串常量池  │ │           ┌─────────────────────┐
│  │  └ JIT缓存      │ │           │  本地内存 (Native)   │
│  └─────────────────┘ │           │  ┌─ Metaspace ─────┐│
│                       │           │  │ ├ 类元数据       ││
│  受 -Xmx 控制         │           │  │ ├ 运行时常量池   ││
│  -XX:MaxPermSize 单独│           │  │ └ JIT 缓存       ││
│                       │           │  └─────────────────┘│
└─────────────────────┘           │  不受 -Xmx 控制       │
                                   │  -XX:MaxMetaspaceSize │
                                   └─────────────────────┘

三个关键变更:

变更效果
类元数据移到本地内存默认无上限(受 OS 物理内存限制),不再有 PermGen OOM
字符串常量池移到堆堆 GC 自动回收不再被引用的 intern 字符串
运行时常量池仍在 Metaspace因为它与类元数据紧密关联,类卸载时随类一起释放

新的 JVM 参数:

bash
# Metaspace 初始大小 (默认约 21MB, 触发 GC 的阈值)
-XX:MetaspaceSize=256m

# Metaspace 最大大小 (默认无限, 受 OS 内存限制)
-XX:MaxMetaspaceSize=512m

# 每次 GC 后 Metaspace 扩容的增量
-XX:MinMetaspaceFreeRatio=40    # GC 后空闲 Metaspace 比例下限
-XX:MaxMetaspaceFreeRatio=70    # GC 后空闲 Metaspace 比例上限

注意:MetaspaceSize 不是初始分配大小,而是触发第一次 Metaspace GC 的阈值。如果设为 256m,Metaspace 使用量达到 256m 时会触发一次 GC 来卸载类。这是为了尽早发现类加载泄漏。

为什么 JDK 8 能做这个迁移? 移除了 PermGen 后,类元数据和 Java 对象在内存管理上彻底解耦:

  1. 堆 GC 只需关心对象,不必遍历 PermGen 的引用
  2. Metaspace 的扩容/收缩由 OS 管理,JVM 不需要在堆内预留空间
  3. 类卸载条件不变但更容易满足——Metaspace 空间不足时自动触发类卸载

三、图解全景

┌─────────────────────────────────────────────────────────────────┐
│               PermGen → Metaspace 迁移全景对比                      │
│                                                                 │
│  JDK 7 (PermGen)                    JDK 8+ (Metaspace)          │
│  ┌──────────────────────┐          ┌──────────────────────┐     │
│  │ 堆 (受 -Xmx 控制)     │          │ 堆 (受 -Xmx 控制)     │     │
│  │ ┌─────┬────┬──────┐ │          │ ┌─────┬────┬──────┐ │     │
│  │ │Eden │ S  │ Old  │ │          │ │Eden │ S  │ Old  │ │     │
│  │ ├─────┴────┴──────┤ │          │ ├─────┴────┴──────┤ │     │
│  │ │ 字符串常量池     │ │          │ │ 字符串常量池     │ │     │
│  │ └─────────────────┘ │          │ └─────────────────┘ │     │
│  └──────────────────────┘          └──────────────────────┘     │
│  ┌──────────────────────┐          ┌──────────────────────┐     │
│  │ PermGen (永久代)      │          │ Metaspace (本地内存)  │     │
│  │  固定大小             │          │  默认无上限           │     │
│  │  -XX:MaxPermSize     │          │  -XX:MaxMetaspaceSize│     │
│  │                      │          │                      │     │
│  │  ├ 类元数据          │          │  ├ 类元数据          │     │
│  │  ├ 运行时常量池      │          │  ├ 运行时常量池      │     │
│  │  ├ 字符串常量池 ←OOM │          │  ├ JIT 缓存          │     │
│  │  └ JIT 缓存          │          │  └ (无字符串常量池)  │     │
│  │                      │          │                      │     │
│  │  堆 GC 必须扫描       │          │  Metaspace 扩容/收缩 │     │
│  │  PermGen 引用        │          │  由 OS 管理           │     │
│  └──────────────────────┘          └──────────────────────┘     │
│                                                                 │
│  解决问题的核心:                                                  │
│  1. 字符串常量池归堆 → GC 自动回收                                │
│  2. 类元数据归本地内存 → 默认无上限                               │
│  3. 两个区域独立管理 → 堆 GC 不再扫描类元数据                      │
└─────────────────────────────────────────────────────────────────┘

四、实战验证

验证 1: Metaspace OOM(故意用不同 ClassLoader 加载类)

java
import java.net.URL;
import java.net.URLClassLoader;

public class MetaspaceOOMDemo {
    public static void main(String[] args) throws Exception {
        // 不断用新的 ClassLoader 加载类, 耗尽 Metaspace
        while (true) {
            URLClassLoader loader = new URLClassLoader(
                new URL[]{new URL("file:/tmp/classes/")}
            );
            Class<?> clazz = loader.loadClass("com.example.Dummy");
            // 不关闭 loader → 类无法卸载 → Metaspace 持续增长
        }
    }
}
bash
# 设置较小的 Metaspace 上限, 加速 OOM
java -XX:MaxMetaspaceSize=64m MetaspaceOOMDemo
# 很快会输出: java.lang.OutOfMemoryError: Metaspace

如果将 -XX:MaxMetaspaceSize 设为 0 或 不设(默认无限),程序会耗尽物理内存,造成操作系统级别的 OOM Killer。

验证 2: 查看 Metaspace 使用量

bash
# 启动 Java 程序后, 用 jstat 查看 Metaspace 使用情况
jstat -gcmetacapacity <pid>

# 输出示例:
#  MCMN       MCMX        MC       CCSMN      CCSMX       CCSC     YGC   FGC
#  0.0     1056768.0     4480.0      0.0     1048576.0     512.0     0     0
#  ↑ Metaspace 最小/最大/当前使用量 (KB)
bash
# 或使用 jcmd
jcmd <pid> VM.metadata

Metaspace 的最大值(MCMX)默认约为 1GB(1056768 KB),这不是限制,而是预留的虚拟地址空间。

五、面试视角

追问答案要点
为什么 JDK 8 把永久代换成元空间?PermGen 固定大小难以调优,String.intern() 动态增长导致 OOM。Metaspace 使用本地内存默认无上限,字符串常量池移入堆由 GC 自动回收
Metaspace 会出现 OOM 吗?会。如果用 -XX:MaxMetaspaceSize 设置了上限,达到上限会 OOM。不设上限时,耗尽 OS 物理内存也会 OOM(或触发 OS OOM Killer)。典型场景:动态类加载(CGLIB、热部署)
PermGen 和 Metaspace 的区别?PermGen 在堆内固定大小,Metaspace 在本地内存默认无上限;PermGen 包含字符串常量池(易 OOM),Metaspace 不含(字符串池已移入堆);PermGen 需要堆 GC 扫描,Metaspace 独立管理
方法区存储什么?类元数据(类名、字段、方法、vtable/itable)、运行时常量池、JIT 编译代码缓存、静态变量引用
永久代移除后,运行时常量池去哪了?Metaspace。因为运行时常量池与类元数据生命周期一致,类卸载时随类一起释放
-XX:MetaspaceSize 是什么?触发第一次 Metaspace GC 的阈值,不是初始分配大小。设为较大值可以推迟 Metaspace GC,避免频繁类卸载
什么场景下类可以被卸载?ClassLoader 不可达 + 该 ClassLoader 加载的所有类不再被使用。只有自定义 ClassLoader 加载的类可能被卸载(Bootstrap/Ext/App 加载的类永不解散)

📚 相关链接

  • **类加载生命周期** — 类加载在方法区构建类元数据的完整过程
  • **Java堆** — 方法区与堆的交互(对象指向类元数据)
  • **运行时常量池** — 运行时常量池是方法区的重要组成部分
  • ← 返回 **运行时数据区索引**

Knowledge4J — Java 知识库