2.2 方法区与 Metaspace:类元数据标本柜


2.2 方法区与 Metaspace:类元数据标本柜

本节摘要:方法区是规范定义的逻辑分区,HotSpot 先用永久代实现、JDK 8 起改用本地内存上的 Metaspace。本节讲清这条演进线的动机、字符串常量池的位置变迁、运行时常量池里到底有什么,并用一段制造类元数据溢出的代码演示 Metaspace 溢出的现场。

上一节切完了堆,本节转向共享区里另一块切片:方法区。它不像堆那样日日进出大量对象,却决定了一个程序"能装下多少个类"。理解它有两重实用价值:一是排查 Metaspace 溢出与类加载器泄漏(第六章的常见病灶之一);二是看懂第三章类装载——类被加载后的"户籍档案"就存放在这里。本节还顺带把新标本柜的合理上限配置一并给出。

规范里的方法区与实现里的两次搬家

JVMS 对方法区的描述是逻辑性的:存放每个类的结构信息——运行时常量池、字段与方法数据、构造函数与普通方法的字节码。规范没有规定它放在哪、怎么回收,这是留白区。HotSpot 的实现史因此经历了两次搬家:

第一次是从堆里划出一块"永久代"(Permanent Generation,JDK 7 及之前)。把方法区当作堆的一个分代来管理,好处是复用了分代回收的设施,坏处是上限死板:-XX:MaxPermSize 一旦给定,类元数据的总量就被焊死,动态生成类的应用(反射密集、Groovy 脚本、老式应用服务器)三天两头撞上 OutOfMemoryError: PermGen space。而且永久代与堆的回收节奏耦合,调优时要同时顾两套参数。

第二次搬家发生在 JDK 8:永久代被彻底移除,类元数据迁入本地内存中的 Metaspace。它默认只受进程可用本地内存约束,-XX:MaxMetaspaceSize 不设则为无上限(实际上限是物理内存)。这次搬家还有一笔附带交易:字符串常量池早在 JDK 7 就已迁入堆,JDK 8 又把静态变量随实例对象归入堆。永久代只剩一个空壳,随即被拆除。

图 2-2:从永久代到 Metaspace 的演进对照

图 2-2:从永久代到 Metaspace 的演进对照

运行时常量池里到底有什么

每个 class 文件都带一张常量池表(第一章 javap 示例里见过它的样子),类加载后这张表进入方法区,成为运行时常量池。里面躺着:类与接口的全限定名、字段与方法的名称和描述符、字符串字面量、被 final 修饰的编译期常量,以及符号引用——运行期再解析成直接引用的那类"欠条"。

还有一个高频面试题值得当场钉死:字符串常量池在哪?JDK 7 起它在堆里,不在 Metaspace。所以大量 String.intern() 挤占的是堆空间,报的是 heap space 而不是 Metaspace 错误。运行时常量池(方法区)与字符串常量池(堆)是两码事,别混。

亲手做一次 Metaspace 溢出

类元数据溢出比堆溢出少见,但动态代理滥用、Groovy 脚本引擎、反复热部署都能触发。用 CGLIB 动态生成类来复现(示例基于 JDK 11,参数 -XX:MaxMetaspaceSize=32m 把柜子容量压小便于触发):

import org.springframework.cglib.proxy.Enhancer; import org.springframework.cglib.proxy.MethodInterceptor; public class MetaspaceLeak { public static void main(String[] args) { int count = 0; try { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(MetaspaceLeak.class); enhancer.setCallback((MethodInterceptor) (obj, m, params, proxy) -> proxy.invokeSuper(obj, params)); enhancer.create(); // 每轮生成一个全新的子类 count++; } } catch (Throwable t) { System.out.println("generated classes: " + count); System.out.println("crash: " + t.getClass().getName()); } } }

运行输出大致如下:

$ java -XX:MaxMetaspaceSize=32m MetaspaceLeak generated classes: 8412 crash: java.lang.OutOfMemoryError: Metaspace

八个多千个动态类就撑爆了三十二 MB 的标本柜。这个实验揭示了动态代理的内存代价:每个代理类都是货真价实的类,占元数据、占常量池;循环里无节制生成代理类,Metaspace 必然见底。生产中若见到 Metaspace 持续上涨且回收后不落,第一嫌疑就是某个 ClassLoader 连同它装载的全部类无法回收(加载器泄漏),排查路径到第六章的病灶三再展开。

⚠️ 常见坑一:给 Metaspace 设上限后照搬旧 PermSize 经验。永久代的默认值通常偏小,而现代框架加载的类普遍更多;从老版本迁移时沿用数 MB 级的旧参数,应用一启动就溢出。合理做法是先不设上限跑一轮压测,用 jstat 的 MC 列观察稳态占用,再留三成余量设上限。

⚠️ 常见坑二:看到 Metaspace 报警就调大参数了事。若上涨源于加载器泄漏,调大上限只是推迟崩溃时间,真正的止血是找出谁在反复创建类加载器——第六章会给出用类加载器直方图定位的具体手法。

💡 关键直觉:方法区的回收对象不是对象而是类。一个类要被卸载,条件苛刻到近乎洁癖:它的类加载器必须先死。这就是为什么加载器泄漏在 Metaspace 时代依然危险——柜子换了,柜门锁(加载器)的规矩没变。

要点回顾

  • 方法区是规范概念:存类结构信息;HotSpot 用永久代实现过,JDK 8 起改为本地内存上的 Metaspace;
  • 搬家清单:字符串常量池 JDK 7 入堆,静态变量 JDK 8 入堆,永久代随之拆除;
  • 上限参数换代:MaxPermSize 退役,MaxMetaspaceSize 接班,不设则受本地内存约束;
  • 压缩类空间单独封顶:Klass 结构所在的 CompressedClassSpaceSize 是 Metaspace 内的独立账本;
  • 动态类是主要消耗者:代理类、脚本引擎生成类、热部署,都可能让柜子见底;
  • 类的卸载以加载器死亡为前提:加载器泄漏是 Metaspace 涨而不落的头号病因。

标本柜看完,下一节切到线程私有区:虚拟机栈与程序计数器。方法调用在栈帧里怎么展开、StackOverflowError 是怎么被触发的、为什么程序计数器是全机器唯一不会溢出的区域——下一节逐一看清。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U