欢迎光临
我们一直在努力

第5篇:方法区的进化——永久代到元空间,为什么要变?

系列文章目录

  • 第一篇:一段旅程的开始——JVM内存模型简介-97
  • 第二篇:对象的内存布局——从类型指针到OOP-Klass模型-96
  • 第三篇:从一段代码看透JVM内存布局:对象、Klass、Method的底层真相-95
  • 第四篇:类加载机制——从.class到Klass的完整旅程-96
  • 第五篇:方法区的进化——永久代到元空间,为什么要变?-96
  • 第六篇:GC Roots与可达性分析——对象是如何被标记存活的?-96
  • 第七篇:引用类型——强、软、弱、虚,你还在用强引用吗?-96
  • 第八篇:Stop The World——GC为什么会让程序卡顿?-96
  • 第九篇:MinorGC完整流程与复制算法深度解析-96
  • 第十篇:动态年龄判定与空间分配担保——MinorGC背后的“潜规则”-94
  • 第十一篇:FullGC深度解析——当老年代也撑不住的时候-96
  • 第十二篇:CMS与G1垃圾回收器深度剖析-96
  • 第十三篇:直接内存与零拷贝——NIO性能优化的底层真相-96
  • 第十四篇:JVM参数调优实战——从GC日志到参数调整-96
  • 第十五篇:OOM排查实战——从一个内存泄漏案例说起-96

  • 前言

    在上一篇文章《类加载机制》中,我们深入探讨了类的完整生命周期,了解了类的元数据最终存储在方法区中。但你可能注意到一个细节:我在描述方法区时,用的是“方法区(元空间)”。

    为什么要特意标注“元空间”?方法区不是一直都是这样的吗?

    事实上,方法区的实现经历了一次重大变革:从JDK 7及之前的永久代,到JDK 8及之后的元空间。这次变革不是简单的换名字,而是深刻影响了JVM的内存管理、OOM风险、以及性能表现。

    今天,我们就来深入剖析这次变革的前因后果。读完本文,你将能回答:

    • 为什么JDK 8要移除永久代?
    • 元空间和永久代有什么本质区别?
    • String.intern()在不同JDK版本的行为为什么不同?
    • 方法区也会被GC吗?

    下一篇,我们将进入GC的核心机制——GC Roots与可达性分析。


    一、方法区、永久代、元空间:厘清概念

    1.1 三者的关系

    很多初学者会把这三个概念混为一谈,其实它们的关系是:

    ┌─────────────────────────────────────────────────────────────────────┐
    │ JVM规范 │
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ 方法区 │ │
    │ │ (Method Area) │ │
    │ │ 存储类元信息、常量、静态变量等 │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    │ ↑ │
    │ │ 实现 │
    │ ↓ │
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ 实现方式 │ │
    │ ├─────────────────────────────┬───────────────────────────────┤ │
    │ │ JDK 7及之前 │ JDK 8及之后 │ │
    │ ├─────────────────────────────┼───────────────────────────────┤ │
    │ │ 永久代 │ 元空间 │ │
    │ │ (PermGen) │ (Metaspace) │ │
    │ │ 位于堆内存,大小固定 │ 位于本地内存,动态扩展 │ │
    │ └─────────────────────────────┴───────────────────────────────┘ │
    └─────────────────────────────────────────────────────────────────────┘

    概念区分:

    • 方法区:JVM规范中的逻辑区域,定义“存什么”
    • 永久代:JDK 8之前方法区的实现方式
    • 元空间:JDK 8及之后方法区的实现方式

    // 这些数据都存在方法区
    public class User {
    private String name; // 字段信息 → 方法区
    private static int count; // 静态变量 → 方法区
    public static final int MAX = 100; // 常量 → 方法区

    public String getName() { // 方法信息 → 方法区
    return name;
    }
    }

    // 运行时数据
    User user = new User(); // 对象实例 → 堆
    // User类的元数据(类名、字段描述、方法字节码)→ 方法区


    二、永久代时代(JDK 7及之前)

    2.1 永久代的内存布局

    在JDK 7及之前,方法区由永久代(PermGen)实现。永久代是堆的一部分,和Eden、Survivor、Old在同一块物理内存中。

    JDK 7 堆内存布局:
    ┌─────────────────────────────────────────────────────────────────────┐
    │ 堆内存 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ ┌─────────────────────┬─────────────────────┬───────────────────┐ │
    │ │ 新生代 │ 老年代 │ 永久代 │ │
    │ ├──────────┬──────────┼─────────────────────┼───────────────────┤ │
    │ │ Eden │Survivor │ Old │ PermGen │ │
    │ │ │ (S0+S1) │ │ │ │
    │ └──────────┴──────────┴─────────────────────┴───────────────────┘ │
    └─────────────────────────────────────────────────────────────────────┘

    永久代参数:
    -XX:PermSize=64m # 初始大小
    -XX:MaxPermSize=256m # 最大大小

    2.2 永久代存储的内容

    存储内容说明示例
    类元信息 类名、父类、接口、访问修饰符等 User类的完整结构
    方法信息 方法名、返回值、参数、字节码、异常表 getName()方法的字节码
    字段信息 字段名、类型、修饰符、偏移量 name字段的偏移量16
    常量池 编译期生成的字面量和符号引用 "hello"字符串、User符号引用
    静态变量 类的静态字段值 static int count = 10
    JIT编译缓存 热点代码编译后的机器码 getName()编译后的本地代码

    2.3 永久代的痛点

    痛点1:大小受限,容易OOM

    // 动态生成类的场景
    for (int i = 0; i < 100000; i++) {
    Class<?> clazz = createDynamicClass(); // 不断生成新类
    // 永久代逐渐被占满
    }
    // 最终:java.lang.OutOfMemoryError: PermGen

    在应用服务器热部署、动态代理频繁、大量JSP页面等场景下,永久代OOM是JDK 7及之前的经典问题。

    痛点2:与Java对象竞争堆内存

    永久代是堆的一部分,与新生代、老年代共享堆内存。这意味着:

    • 如果堆内存有限,永久代和Java对象互相挤压
    • 如果永久代设置太大,Java对象可用的堆空间就变小
    • 如果永久代设置太小,又容易PermGen OOM

    痛点3:GC效率低

    永久代的GC条件非常苛刻:

    • 只有FullGC时才会回收永久代
    • 类卸载的条件很严格(该类所有实例被回收、ClassLoader被回收、Class对象没有被引用)
    • 频繁FullGC会严重影响性能

    痛点4:调优困难

    不同类型的应用加载的类数量差异很大:

    • 简单应用:几千个类
    • Spring Boot应用:几万个类
    • 大型Web应用:几十万个类(含JSP生成的类)

    很难预估一个应用到底需要多大的永久代。


    三、元空间时代(JDK 8及之后)

    3.1 元空间的内存布局

    JDK 8彻底移除了永久代,使用元空间(Metaspace)取而代之。元空间不再使用堆内存,而是使用本地内存(Native Memory)。

    JDK 8 内存布局:
    ┌─────────────────────────────────────────────────────────────────────┐
    │ 堆内存 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ ┌─────────────────────┬─────────────────────────────────────────┐ │
    │ │ 新生代 │ 老年代 │ │
    │ ├──────────┬──────────┼─────────────────────────────────────────┤ │
    │ │ Eden │Survivor │ Old │ │
    │ │ │ (S0+S1) │ │ │
    │ └──────────┴──────────┴─────────────────────────────────────────┘ │
    └─────────────────────────────────────────────────────────────────────┘

    ┌─────────────────────────────────────────────────────────────────────┐
    │ 本地内存 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ 元空间 (Metaspace) │ │
    │ │ 类元信息、方法信息、字段信息、常量池、JIT编译缓存 │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ 直接内存 (Direct Memory) │ │
    │ │ NIO缓冲区等 │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ 其他本地内存 │ │
    │ │ 线程栈、JNI等 │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    └─────────────────────────────────────────────────────────────────────┘

    元空间参数:
    -XX:MetaspaceSize=256m # 初始大小(触发GC的阈值)
    -XX:MaxMetaspaceSize=512m # 最大大小(不设则用本地内存上限)

    3.2 元空间存储内容的变化

    存储内容永久代元空间说明
    类元信息 类名、父类、接口等
    方法信息 方法字节码、异常表等
    字段信息 字段名、类型、偏移量
    常量池 字面量、符号引用
    JIT编译缓存 热点代码机器码
    静态变量 移到了堆 存储在堆中的Class对象里

    重要变化:静态变量从方法区移到了堆中!

    public class User {
    private static int count = 10; // JDK 7: 存储在永久代
    // JDK 8: 存储在堆中的Class对象里
    }

    3.3 元空间的优势

    对比项永久代元空间
    内存来源 堆内存 本地内存
    大小限制 MaxPermSize(默认几十MB) MaxMetaspaceSize(可不设)
    OOM风险 高(堆内存有限) 低(本地内存大)
    调优难度 难预估类数量 相对宽松
    GC影响 需要FullGC才能回收 可独立触发GC
    碎片问题 有(堆内存碎片) 无(本地内存管理)

    核心优势:

  • 解耦:让类元信息和Java对象不再竞争同一块内存池
  • 容量大:理论上只受物理内存限制
  • OOM风险低:不会因为少量类加载就OOM
  • GC独立:元空间的GC可以与堆GC解耦

  • 四、方法区的GC:类卸载

    很多人以为方法区(永久代/元空间)不会被GC,这是一个误解。方法区也会发生垃圾回收,主要针对常量池的回收和类型的卸载。

    4.1 常量池回收

    // 字符串常量池回收示例
    public class StringInternDemo {
    public static void main(String[] args) {
    // 创建大量临时字符串
    for (int i = 0; i < 100000; i++) {
    String str = new String("temp_" + i);
    str.intern(); // 放入常量池
    }
    // 这些临时字符串在常量池中可能被回收
    }
    }

    常量池回收的条件:该常量没有被任何地方引用。

    4.2 类卸载的条件

    类卸载的条件非常苛刻,需要同时满足三个条件:

    // 类卸载的三个条件
    1. 该类所有的实例都已经被回收(堆中没有该类的任何对象)
    2. 加载该类的ClassLoader已经被回收
    3. 该类对应的java.lang.Class对象没有被任何地方引用

    // 类卸载示例
    public class ClassUnloadDemo {
    public static void main(String[] args) throws Exception {
    // 创建自定义类加载器
    CustomClassLoader loader = new CustomClassLoader();

    // 加载类并创建实例
    Class<?> clazz = loader.loadClass("com.example.User");
    Object user = clazz.newInstance();

    // 释放所有引用
    user = null;
    clazz = null;
    loader = null; // ClassLoader不再被引用

    // 触发GC,User类可能被卸载
    System.gc();

    // 注意:System.gc()不保证立即执行,且类卸载还需要满足其他条件
    }
    }

    4.3 为什么类卸载条件如此苛刻?

  • ClassLoader生命周期长:通常应用只有一个系统类加载器,永远不会被回收
  • Class对象可能被缓存:很多框架会缓存Class对象
  • 反射调用:反射调用会持有Class对象引用
  • 实际场景:

    • 普通应用:类基本不会被卸载
    • 热部署应用(如Tomcat):每个Web应用有自己的ClassLoader,卸载Web应用时可以卸载所有类

    五、String.intern()的演进

    String.intern()的行为是理解方法区演进的最好案例。

    5.1 intern()的作用

    String s1 = new String("hello");
    String s2 = s1.intern(); // 将字符串放入常量池
    String s3 = "hello"; // 从常量池获取

    System.out.println(s1 == s2); // false(堆对象 vs 常量池对象)
    System.out.println(s2 == s3); // true(都是常量池对象)

    5.2 JDK 6及之前:常量池在永久代

    // JDK 6
    public class InternDemo {
    public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    for (int i = 0; i < 1000000; i++) {
    String str = new String("temp_" + i);
    list.add(str.intern()); // 全部放入永久代常量池
    }
    // 结果:OutOfMemoryError: PermGen space
    }
    }

    问题:永久代空间有限,大量intern()会导致PermGen OOM。

    5.3 JDK 7:常量池移到堆

    // JDK 7
    public class InternDemo {
    public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    for (int i = 0; i < 1000000; i++) {
    String str = new String("temp_" + i);
    list.add(str.intern()); // 放入堆中的常量池
    }
    // 结果:可能OOM,但OOM的是堆内存,不是PermGen
    }
    }

    变化:JDK 7将字符串常量池从永久代移到了堆中。

    5.4 JDK 8:常量池在堆,元空间存其他

    JDK 7:
    ┌─────────────────────────────────────────────────────────────────────┐
    │ 永久代 │
    │ ├─ 字符串常量池 ← 问题:容易OOM │
    │ ├─ 类元信息 │
    │ └─ 静态变量 │
    └─────────────────────────────────────────────────────────────────────┘

    JDK 8:
    ┌─────────────────────────────────────────────────────────────────────┐
    │ 堆 │
    │ ├─ 字符串常量池 ← 现在在堆中,空间更大 │
    │ └─ 静态变量 ← 也从元空间移到了堆 │
    └─────────────────────────────────────────────────────────────────────┘
    ┌─────────────────────────────────────────────────────────────────────┐
    │ 元空间 │
    │ ├─ 类元信息 │
    │ ├─ 方法信息 │
    │ └─ 运行时常量池(除了字符串) │
    └─────────────────────────────────────────────────────────────────────┘


    六、堆内存 vs 本地内存

    6.1 两种内存的本质区别

    // 堆内存:JVM管理,受GC控制
    byte[] heapBuffer = new byte[1024 * 1024]; // 1MB在堆中

    // 本地内存:操作系统管理,不受GC直接控制
    ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB在本地内存

    对比项堆内存本地内存
    管理方 JVM(GC) 操作系统
    分配速度 快(只在堆内划一块) 慢(系统调用)
    地址是否可变 会变(GC复制算法) 固定
    回收方式 GC自动回收 需手动或依赖Cleaner
    适用场景 绝大多数Java对象 元空间、直接内存、线程栈

    6.2 JVM与系统的关系

    关键认知:JVM就是一个运行在操作系统上的普通进程(尽管它很特殊)。

    操作系统内存布局:
    ┌─────────────────────────────────────────────────────────────────────┐
    │ 物理内存 │
    ├─────────────────────────────────────────────────────────────────────┤
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ JVM进程 │ │
    │ │ ┌─────────────────────────────────────────────────────┐ │ │
    │ │ │ 堆内存(GC管理) │ │ │
    │ │ │ Eden、Survivor、Old │ │ │
    │ │ └─────────────────────────────────────────────────────┘ │ │
    │ │ ┌─────────────────────────────────────────────────────┐ │ │
    │ │ │ 本地内存(OS管理) │ │ │
    │ │ │ 元空间、直接内存、线程栈、JNI、Code Cache │ │ │
    │ │ └─────────────────────────────────────────────────────┘ │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    │ ┌─────────────────────────────────────────────────────────────┐ │
    │ │ 其他进程 │ │
    │ └─────────────────────────────────────────────────────────────┘ │
    └─────────────────────────────────────────────────────────────────────┘

    堆、栈、方法区等,都是这个进程向操作系统申请来的内存空间的逻辑划分。


    七、常见面试题

    Q1:为什么JDK 8要把永久代换成元空间?

    答:根本原因是解耦——让类元信息和Java对象不再竞争同一块内存池。具体好处:

  • 永久代大小固定,容易PermGen OOM;元空间使用本地内存,容量更大
  • 永久代与堆共享内存,互相挤压;元空间独立
  • 永久代GC条件苛刻;元空间可以独立触发GC
  • Q2:元空间也会OOM吗?

    答:会。虽然元空间使用本地内存,但物理内存是有限的。如果设置了-XX:MaxMetaspaceSize,超过该值就会OOM;如果不设置,当本地内存耗尽时也会OOM。

    # 元空间OOM示例
    -XX:MaxMetaspaceSize=64m # 设置64MB上限
    # 如果加载的类太多,会抛出:java.lang.OutOfMemoryError: Metaspace

    Q3:字符串常量池在JDK 7和JDK 8中有什么区别?

    答:

    • JDK 6及之前:字符串常量池在永久代
    • JDK 7:字符串常量池移到了堆中
    • JDK 8:字符串常量池仍在堆中

    所以JDK 7之后,String.intern()不再导致PermGen OOM,但可能导致堆OOM。

    Q4:静态变量在JDK 7和JDK 8中存储在哪里?

    答:

    • JDK 7及之前:静态变量存储在永久代
    • JDK 8及之后:静态变量存储在堆中的Class对象里

    // JDK 8中,这段代码的存储位置
    public class User {
    private static int count = 10; // 存储在堆中的User.class对象里
    }

    Q5:方法区会被GC吗?什么情况下类会被卸载?

    答:会。方法区也会发生垃圾回收,主要针对常量池的回收和类型的卸载。类卸载需要同时满足三个条件:

  • 该类所有的实例都已经被回收
  • 加载该类的ClassLoader已经被回收
  • 该类对应的Class对象没有被任何地方引用
  • 在普通应用中,类基本不会被卸载;只有在热部署场景(如Tomcat卸载Web应用)时,才会发生类卸载。


    八、总结

    8.1 永久代 vs 元空间

    对比项永久代元空间
    内存来源 堆内存 本地内存
    大小限制 MaxPermSize MaxMetaspaceSize(可不设)
    OOM风险
    调优难度 相对宽松
    静态变量位置 永久代 堆中
    字符串常量池 永久代 堆中

    8.2 演进的核心思想

    演进核心思想解决的问题
    永久代→元空间 解耦 类元信息与Java对象竞争内存
    字符串常量池移出永久代 缓解压力 永久代空间有限,易OOM
    静态变量移出元空间 优化GC 静态变量随Class对象一起GC

    8.3 面试金句

    如果面试官问你“永久代和元空间的区别”,你可以这样回答:

    “永久代是JDK 8之前方法区的实现,位于堆内存中,大小受MaxPermSize限制,容易发生PermGen OOM。元空间是JDK 8及之后方法区的实现,使用本地内存,不再受堆内存限制,大大降低了OOM风险。同时,JDK 8将静态变量和字符串常量池移到了堆中,让元空间更专注于类元信息的存储。这次演进的核心思想是解耦——让类元信息和Java对象不再竞争同一块内存池。”


    下篇预告

    理解了方法区的演进,我们终于完整地掌握了JVM的内存布局:

    • 堆:存储对象实例
    • 栈:存储局部变量和方法调用
    • 方法区:存储类元信息

    但有一个核心问题我们还没有深入:JVM如何判断哪些对象是垃圾?

    下一篇《GC Roots与可达性分析——对象是如何被标记存活的?》将揭开GC的第一层面纱——对象的存活判定机制。


    如果你觉得本文有帮助,欢迎点赞、评论、转发!

    赞(0)
    未经允许不得转载:171主机测评 » 第5篇:方法区的进化——永久代到元空间,为什么要变?
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址