ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

彻底拆解JVM运行时常量池:结构、类加载流程与实战排查

彻底拆解JVM运行时常量池:结构、类加载流程与实战排查 写JVM内存分析相关的文章写了这么多年运行时常量池Runtime Constant Pool是我觉得最容易被低估、被一笔带过的概念。很多朋友的认知就停在“方法区的一部分存常量”这个层面但你真要问他它和Class文件里的常量池是什么关系里面到底存了什么类加载哪个阶段开始用它它能动态变大吗跟字符串常量池是一个东西吗不少人是说不清楚的。这篇我就把这个概念彻底拆开来讲。内容上我会兼顾JVM规范和HotSpot实现从内存位置、内部结构、类加载流程、相邻概念区分到实战排查和面试考点一次性覆盖。准备面试的一定要看完第五、六章工作中被元空间OOM、字符串内存膨胀折磨过的重点看第四章和第五章。1. 先定位运行时常量池在JVM内存模型中处在哪个位置1.1 规范定义与内存布局演进很多人第一次接触运行时常量池是从《Java虚拟机规范》里那句“运行时常量池是方法区的一部分”开始的。方法区这个概念在规范里明确存在它用来存储类型信息类名、修饰符、父类、接口、字段信息、方法信息以及我们这篇文章的主角——运行时常量池。但规范是规范HotSpot是HotSpot两者并不完全一样。早期HotSpot用“永久代”PermGen来实现方法区也就是从物理内存上把方法区放在堆里由堆内存管理器统一管理。这个设计后来被证明坑很大永久代空间有限默认才几十兆类加载一多字符串一多就频繁报java.lang.OutOfMemoryError: PermGen space而且调优的时候你还要同时思考堆空间和永久代的比例非常别扭。JDK 8开始HotSpot彻底移除永久代把方法区的实现换成了“元空间”Metaspace。元空间使用的是本地内存Native Memory不再占用堆内存默认大小只受操作系统物理内存限制。这对运行时常量池来说有一个直接影响它的物理归宿变了从原来可以观察到的堆内“PermGen区域”变成了堆外的一块本地内存区域。这里要说清楚运行时常量池本身作为逻辑概念一直存在变化的只是它在不同JDK版本下的物理载体。你排查问题的时候JDK 8之后遇到Metaspace相关OOM大概率就和方法区内容膨胀包括运行时常量池有关。1.2 它和Class文件常量池的区别和联系一个Java类在编译阶段javac会把类里出现的各种“静态信息”收集起来写进Class文件的“常量池”Constant Pool小节。你随便打开一个.class文件用十六进制工具看或者在命令行执行javap -verbose就能看到那张常量池表。Class文件常量池里装的是两类东西一是字面量比如字符串常量、声明为final的常量值二是符号引用比如类或接口的全限定名、字段的名称和描述符、方法的名称和描述符。注意这些只是“符号”不是内存里真实存在的对象的地址。当JVM加载这个类的时候会把Class文件常量池里的内容搬进方法区并且做一次格式转换和扩充这个转换后的版本就是“运行时常量池”。所以你可以把它理解为Class文件常量池是静态蓝图运行时常量池是加载到内存后、由JVM接管的那份运行时台账。两者还有一个关键区别——动态性。Class文件常量池是编译期写死的内容固定而运行时常量池不仅支持在类加载时从Class文件复制已有常量还支持在运行期向里面添加新常量。最经典的操作就是String.intern()这个方法可以把运行期创建的字符串对象“登记”到常量池里让后续引用能直接命中。这一点后面第四章我再展开。2. 拆解运行时常量池的内部结构它到底在存什么2.1 字面量程序里直接写死的值字面量Literal是常量池里最直观的成员。你代码里写的hello、数字100、3.14f、布尔值true、字符a在编译后都会以对应的常量池项进入Class文件的常量池表类加载后再进入运行时常量池。这里注意一个细节只有被实际使用的字面量才会被编译进常量池。如果你在代码里声明了一个final int MAX 1024javac会直接在编译阶段把使用MAX的地方替换成数字1024同时常量池里可能只会出现这个1024字面量本身不会保留“有一个变量叫MAX”这种元信息。你反编译的时候会发现代码里全是裸数字这是一个很常见的现象。字符串字面量比较特殊。Java的字符串字面量在Class文件里是以CONSTANT_Utf8_info存储的同时在常量池里还会有一个CONSTANT_String_info条目指向它。真正的字符串对象实例并不是存在常量池里的而是在类加载、初始化之后位于堆内存中常量池里保存的是指向这个字符串实例的引用。这个引用关系正是很多人混淆“字符串常量池”和“运行时常量池”的根源我放到第四章仔细讲。2.2 符号引用描述“类、字段、方法”的说明书符号引用Symbolic Reference是运行时常量池里最能体现JVM设计思想的部分。它不直接指向内存地址而是一组用来定位目标的“描述信息”。类和接口的符号引用记录类的全限定名。比如java/lang/String遇到new、instanceof等指令时JVM需要根据这个名称定位对应的Class对象。字段的符号引用包含字段所属的类或接口名、字段名、字段描述符。描述符用来表达类型比如Ljava/lang/String;代表String类型I代表int。方法的符号引用包含方法的所属类名、方法名、方法描述符。方法描述符会完整描述参数列表和返回类型例如(ILjava/lang/String;)V表示参数是一个int和一个String返回void。Class文件常量池在被加载为运行时常量池时还会保留这些符号引用的结构但在解析阶段JVM会把它们替换成“直接引用”Direct Reference——也就是真实的内存地址、偏移量或句柄。直接引用的形态在不同虚拟机实现里不一样HotSpot里可能是某个Class对象在元空间里的指针也可能是方法在方法表vtable里的偏移量或者字段在对象布局里的偏移量。这也是我想强调的一点运行时常量池不是一个只读的静态表格它会随着类解析的推进从“符号状态”逐步变成“直接引用状态”。判断一个类是否完成了解析很多时候就是看它常量池里的对应项是不是已经指向了实际的运行时结构。2.3 从javap输出看一张真实的常量池表空讲结构太抽象我建议你打开手边任意一个class文件试一下。写一个最简单的测试类public class Demo { private String name hello; private final int count 42; public void greet(String who) { System.out.println(hello, who); } }编译后执行javap -verbose Demo.class你会看到类似这样的输出实际内容因javac版本略有差异Constant pool: #1 Methodref #13.#27 #2 Fieldref #28.#29 #3 String #30 #4 Class #31 #5 Methodref #13.#32 #6 Fieldref #33.#34 #7 String #35 #8 Class #36 #9 Methodref #37.#38 ...左边是索引编号右边是常量池项类型后面的数字引用的是其他常量池项的编号。#3 String #30表示第3项是一个字符串字面量内容存往第30项CONSTANT_Utf8_info。这就是一张完整的、类加载之后要被搬进运行时常量池的“底稿”。看这种东西有什么实际用处当你怀疑某个类的常量池异常膨胀、或者在做字节码插桩、手写ASM的时候你经常需要和这些条目打交道。不夸张地说没读懂Class文件常量池就别谈能真正掌握字节码增强。3. 类加载全流程中运行时常量池是如何被建立和使用的3.1 类加载的五个阶段与常量池的创建时机一个类从字节码变成可用的Java类型要经历加载Loading、验证Verification、准备Preparation、解析Resolution、初始化Initialization五个阶段。运行时常量池的创建发生在“加载”阶段JVM读取Class文件的二进制流把它解析成方法区中的运行时数据结构其中就包括创建运行时常量池。也就是说从类加载的一瞬间起这个类就有了自己的运行时常量池即使后续的验证、准备、解析还没完成常量池的“雏形”已经存在了。这也是和Class文件常量池相比最直接的差异一个是方法区外部的静态描述一个是JVM内部真正用于运行期的数据结构。准备阶段做的核心工作是为类的静态变量分配内存并设置默认值比如静态int变量先置0对象引用先置null。这里有个容易混淆的点准备阶段只处理静态变量给的是零值不是代码里写的初始值。真正赋值发生在初始化阶段clinit方法执行时。而运行时常量池里那些被final修饰的常量早在这个阶段就已经准备完毕了因为final常量的值在编译期就确定了它的值直接存放在运行时常量池里不需要等到初始化再赋值。3.2 解析阶段的核心动作符号引用到直接引用解析阶段是运行时常量池真正“发挥威力”的阶段。在这个阶段JVM会把常量池里的符号引用逐个替换为直接引用。举个例子你代码里写new HashMap()编译后会有一系列常量池项包含对java/util/HashMap类的符号引用。在没有解析之前JVM只知道“这里要用到一个叫java/util/HashMap的类”但并不知道这个类在元空间的具体地址。执行new指令时JVM需要拿到HashMap的Class对象引用于是触发解析根据类名去系统类加载器、自定义类加载器逐级查找找到类后把它的地址“回填”到运行时常量池对应的常量池项上。后续再次执行到同样的new HashMap()指令时JVM直接从运行时常量池拿到已经解析好的直接引用跳过了重复查找类、加载类的步骤这也是字节码指令能高效执行的原因。解析不是一次性完成的也不是从头到尾顺序执行的JVM允许“延迟解析”lazy resolution也就是在使用到某个符号引用时才去解析它。你启动一个Spring Boot应用启动初期类加载频繁、CPU飙高很大一部分消耗就是在大量解析类符号引用。解析可能失败吗可能比如类找不到或者按规范的要求判断字段、方法不存在会分别抛出NoClassDefFoundError、NoSuchFieldError、NoSuchMethodError。换句话说运行时常量池里写着的“说明书”找不到对应的实体程序就会在运行期当场翻车。3.3 动态链接多态和接口调用的隐藏依赖这里必须提一个和常量池紧密相关的概念动态链接Dynamic Linking。JVM里的方法调用大部分走的是“符号引用 运行时常量池”的路径而不是直接在字节码里写入方法的最终地址。这样做的最大好处是支持多态、接口实现和后期绑定。以invokevirtual指令为例字节码里保存的是某个方法的符号引用索引。执行时JVM去运行时常量池找到这个符号引用对应的直接引用得到方法所在类的元数据再通过方法表或接口方法表定位到实际要调用的方法。因为真正的方法解析发生在运行期所以当我用接口类型声明一个变量后面具体指向哪个实现类JVM都能在每次调用时通过虚方法表正确分发。可以说如果没有运行时常量池来承载这套“符号引用→直接引用”的动态解析机制Java的继承、多态、接口抽象这些基本盘就撑不起来。这也是为什么面试官喜欢把它和多态绑定在一起问不是没道理的。3.4 运行时常量池的动态扩展能力与intern机制前面提到运行时常量池具备动态性能往里面新增条目这里展开讲。最典型的入口就是String.intern()。在JDK 7之前intern()会把字符串对象复制到永久代里的字符串常量池StringTable永久代空间有限滥用intern很快就 PermGen OOM。JDK 7之后字符串常量池位置调整到了堆内存intern()的行为也变了如果一个字符串不在常量池里它会把当前堆中字符串对象的引用记录下来也就是“登记”进常量池而不是复制一份对象。这样省内存但也带来了一个副作用——那个堆中的字符串对象永远不会被GC回收因为它被常量池引用了。这就是很多人说“intern要慎用”的原因它确实省了重复创建的开销却可能变相成为内存泄漏源头。除了intern()运行时常量池的动态扩展还体现在框架场景里。Spring、MyBatis这类框架在启动和运行期会大量通过反射读取类的元信息、扫描注解还会用CGLIB、Javassist动态生成代理类。每生成一个代理类JVM就会给它创建对应的运行时常量池代理类数量一多元空间里这些常量池表的总量就非常可观。很多人做性能调优明明看着堆内存很健康却报OOM: Metaspace多半就是运行时常量池跟着类的数量一起膨胀了。4. 别再把它们划等号运行时常量池与邻近概念的边界4.1 运行时常量池 vs 字符串常量池这是我看到被混淆得最频繁的一对概念几乎每次在技术群里答疑都会遇到有人把两个当成同一个东西。运行时常量池是每个类一份的存放该类所有字面量和符号引用字符串常量池是JVM全局一份的哈希表主要存放字符串对象的引用所有类共享。说得更细一点你在A类和B类里都写了hello这个字符串Class文件常量池里A类和B类各自会有一份CONSTANT_Utf8_info加载到运行时常量池后也是各自持有。但当代码真正执行并触发字符串实例创建时JVM会去全局的字符串常量池里查如果已经有一个内容为“hello”的字符串对象就复用那个引用不会为A类和B类各创建一个新的字符串对象。所以总结一下每个类的运行时常量池里可能都记录了“hello”这个字面量对应的引用位置但它们指向的是全局唯一的同一个字符串对象。一个是台账一个是台账指向的实体仓库定位和职责完全不同。4.2 字符串常量池 vs 包装类缓存池再往宽了看JVM里还有一类“缓存池”比如Integer的-128~127缓存、Character的缓存、Boolean的两个实例。这些是包装类型内部自带的缓存机制不是虚拟机规范层面的常量池跟String的常量池也完全不是一回事。但这两者在面试题里经常被放在一起考察。比如问你Integer a 100; Integer b 100; System.out.println(a b); // true因为命中缓存 Integer c 128; Integer d 128; System.out.println(c d); // false超出缓存范围各自new对象这类问题考察的是包装类缓存池跟运行时常量池没有任何直接关系。如果你能把字符串常量池、包装类缓存池、运行时常量池三者之间的界限讲清楚面试官通常就能判断你对JVM内存模型的理解是背出来的还是真吃透了。4.3 一张表理清JVM里所有“常量池”这里我整理了一张对比表方便你日后查阅。这几个“池”每次被混为一谈的时候回看这张表就行了。名称作用范围存储内容JDK 7 之后的内存位置Class文件常量池每个class文件字面量、符号引用磁盘上的class文件内运行时常量池每个已加载的类字面量、符号引用/直接引用、动态新增常量方法区元空间字符串常量池JVM全局共享字符串对象的引用堆内存包装类缓存池各类内部缓存Integer/Character等包装类对象实例堆内存需要特别注意的是字符串常量池在JDK 7的这个位置变更它不仅是内存区域换了还导致intern()的语义、GC行为全都跟着变了。如果你在用比较老的JDK 6/7线上环境排查这类问题要格外谨慎很多网上流传的结论在JDK 8下已经不成立了。5. 实战经验运行时常量池相关异常与排查方法5.1 哪些异常会落在常量池头上运行时常量池本身出问题最常见的是两类一类是方法区空间溢出。JDK 8之前报java.lang.OutOfMemoryError: PermGen spaceJDK 8之后报java.lang.OutOfMemoryError: Metaspace。触发原因通常不是单个类里的常量池项有多巨大而是类加载的“数量”失控。比如动态代理类、CGLIB代理、JSP编译生成的Servlet类、热部署反复卸载和加载类每一个类都会带来一份自己的运行时常量池类一多空间就绷不住了。另一类是字符串常量池的过度膨胀。虽然它生活在堆里不算方法区了但大量调用String.intern()或大量使用字符串字面量的业务会让常量池对应的字符串对象占用堆空间持续增长。最典型的是把很多动态拼接的字符串拿来intern()以为能省内存结果每个字符串都因为被常量池引用而无法回收最终把堆撑爆。我见过最夸张的一个事故就是有团队在缓存key上统一使用了intern以为能减少内存占用结果运行了三天永久代当时还是JDK 7环境直接OOM。另外解析失败抛出的NoClassDefFoundError、NoSuchMethodError这一类问题排查的时候也要把运行时常量池的符号解析情况纳入考虑。很多类冲突、方法签名对不上的诡异问题其实就是在解析阶段常量池里的符号引用没能找到预期的类或方法导致的。5.2 用于观察常量池的工具与命令我先说一个最基础、但也最常被忽略的工具javap。它能看Class文件常量池虽然是静态视角但对理解运行时常量池的初始内容非常有帮助。注意javap看的是磁盘上的Class文件而不是内存里已经变换过的运行时常量池。你用它分析的是“输入”不是“运行时状态”。想看运行时状态可以用下面这些手段jmap -clstats pid打印类加载器统计信息可以看到每个类加载器加载了多少类、占了多少空间。类数量和元空间占用有明显的正相关这能间接反映运行时常量池总量。jcmd pid GC.class_stats可以看到每个类的元数据占用明细包括常量池的开销。注意这个命令在JDK 11之后需要通过-XX:UnlockDiagnosticVMOptions开启。jcmd pid VM.metaspace查看元空间使用率、容量、碎片情况判断是不是方法区整体空间吃紧。arthas的memory命令和classloader命令排查动态代理类爆炸、重复加载类导致的元空间膨胀非常顺手。实测下来jcmd在JDK 8是首选因为它不需要额外装工具输出也足够详尽。如果你的生产环境是JDK 11或更高记得关注元空间相关输出指标尤其是Used和Committed两列增长趋势能直接看出是否有类加载泄漏。5.3 一次动态代理导致元空间OOM的排查复盘分享一个之前处理过的真实案例可以帮你理解排查思路。当时有个网关服务运行在JDK 8上上线后每隔几天就报java.lang.OutOfMemoryError: Metaspace重启后恢复正常再过几天又挂。第一反应是堆内存问题但看了jstat -gcutil堆使用率一直很健康反倒是Metaspace那一列持续爬升直到100%触发OOM。接着用jcmd pid GC.class_stats | head把类实例数量按占用空间排序发现排名靠前的是大量CGLIB生成的EnhancerBySpringCGLIB、FastClassBySpringCGLIB这一类代理类。当时的代码里有个定时任务每隔几分钟就会向Spring容器注册一个新的bean实例而每次注册都会触发一次代理类的生成。生成出来的代理类虽然不再被业务直接引用但它们对应的Klass元数据、运行时常量池都留在元空间里类加载器和元数据之间形成了“互相引用”的关系GC无法回收。这种问题光调大-XX:MaxMetaspaceSize治标不治本。最终的修法是改造业务逻辑把动态注册改成预先注册固定数量对代理实例做复用避免反复生成新的代理类。那次之后我养成了一个习惯只要看到类加载相关的OOM第一反应不是调参而是用classloader相关命令看看“谁在不停造类”。5.4 开发期就能规避的四个习惯有一些习惯你在编码阶段注意一下就能大概率避开常量池相关的坑。第一别在热点路径上拼字符串然后intern。如果确实需要intern先评估这个字符串的数量级和生命周期。几百个没问题几十万个还带参数拼接就危险了。第二控制动态代理和字节码生成的使用。能复用代理对象就复用避免在循环体内生成新的代理类。在Spring里注意Scope(prototype)的高频创建场景。第三给JVM配置合理的元空间上限。生产环境建议显式设置-XX:MaxMetaspaceSize不要赌“反正它占本地内存”因为Metaspace不设上限时一旦泄漏会慢慢吞掉操作系统的可用内存直到容器或主机被拖死比JVM自己OOM还难排查。第四用好jcmd、arthas这类工具做常态化监控把Metaspace的Used曲线纳入告警。很多元空间相关的泄漏问题如果能早期发现趋势根本不会发展到OOM。6. 面试高频考点与常见误区6.1 这几个问题经常被问答错的很多运行时常量池是JVM面试的高频区尤其是那些喜欢深挖JVM的面试官很喜欢在“聊一聊JVM内存模型”这种开放题里突然把话题收窄到这个点上。以下是我见过的高频问题挨个给你梳理一下答题要点。运行时常量池与Class文件常量池有什么区别核心答三点一是位置不同一个是磁盘静态文件一个是方法区运行态二是转换过程类加载时拷贝并转换三是是否可动态扩展运行时常量池支持运行期新增常量。符号引用和直接引用是什么符号引用是一组描述性信息直接引用是内存地址或偏移量。解析阶段完成替换之后JVM可以快速定位类、字段和方法。为什么多态需要运行时常量池因为方法调用指令是通过常量池里的符号引用触发动态解析的只有到了运行期才能确定具体的目标方法这为虚方法分派提供了基础。String.intern() 在JDK 7前后有什么区别JDK 7之前是复制字符串对象进永久代之后是记录堆中对象的引用位置也从永久代变成了堆。运行时常量池的OOM怎么触发大量动态生成类导致元空间膨胀或大量intern字符串导致堆中字符串对象无法回收。你如果能把第5章里那个案例的排查逻辑讲给面试官听说明你不只是背概念而是真处理过线上问题。这个加分效果非常明显。6.2 我日常用的几个判断技巧最后分享几个我写代码、做架构评审时常用的判断技巧。看一段代码是否会引起常量池问题我首先分清楚它用的是“编译期就能确定的字面量”还是“运行期动态拼出来的字符串”。前者基本安全常量池只是登记引用后者如果量大就要警惕字符串重复创建和intern滥用。看一个框架是否会导致元空间膨胀我会打开它的类生成入口数一数每处理一个业务对象会生成多少个新类如果是一比一甚至一比多的关系那这个框架在超大规模场景下一定会有类加载压力。选型的时候这可以作为一个重要的技术评判维度。还有一个比较实用的习惯在JVM启动参数里加上-XX:PrintClassHistogram或者用arthas的sc命令定期看看类数量的变化配合元空间使用曲线基本能提前察觉大部分和运行时常量池、类加载相关的内存风险。说句实在的运行时常量池在JVM的宏大体系里只是很小的一块但“小而关键”的东西往往最考验功底。你能把这个点讲透就意味着你对类加载机制、内存模型、方法分派这些核心知识是串起来的而不是孤立的记忆碎片。我自己在排查类加载相关的线上问题时九成以上的突破口最后都会回到运行时常量池的一张表、一个引用关系上。多花点时间把它嚼透回报率非常高。
返回列表