资讯动态

JVM常量池深度解析:从Class文件到字符串常量池与包装类缓存

发布时间:2026/10/21 9:21:25 来源:尧图企业网站定制
1. 为什么常量池值得花一万字去啃很多人学JVM上来就背“方法区、堆、栈、程序计数器”背得滚瓜烂熟结果一遇到String s abc和new String(abc)的区别就卡壳一看到Integer缓存就发懵一碰到intern()就不知道返回值到底指向哪块内存。这些问题的根子全在常量池上。常量池不是一个“池子”那么简单它是JVM里最容易被忽视、却又无处不在的一块区域。你写的每一个字面量、每一个类名、每一个方法签名、每一个字段描述符甚至每一个invokedynamic的引导方法都要经过常量池。它既是类文件结构的核心也是运行时内存布局的关键更是理解字符串行为、包装类缓存、类加载机制、动态语言支持的必经之路。这篇文章面向的是那些已经写过一些Java代码、知道有“堆”和“栈”这两个词、但一遇到字符串比较和包装类比较就心里没底的人。我会从类文件里的常量池讲起一路讲到运行时常量池、字符串常量池、包装类缓存再到intern()的底层行为和常见坑。每一块都会配上可运行的代码、字节码片段和内存示意图尽量让你看完之后能自己推导出结果而不是靠背结论。我自己的经验是常量池这块知识光看文字没用必须动手跑代码、看字节码、调参数。所以下面每一节我都会给出你可以直接复制粘贴去验证的代码。你跟着跑一遍比看十遍文字都管用。2. 类文件常量池一切故事的起点2.1 常量池在Class文件里的位置和结构一个.class文件本质是一段有严格格式的二进制流。它的开头是魔数0xCAFEBABE接着是版本号然后是常量池计数器再往后就是常量池表本身。常量池表里存的是一个个cp_info结构每个结构第一个字节是tag标明这个常量是什么类型。常见的tag有这么几种tag值常量类型含义1CONSTANT_Utf8UTF-8编码的字符串3CONSTANT_Integerint字面量4CONSTANT_Floatfloat字面量5CONSTANT_Longlong字面量6CONSTANT_Doubledouble字面量7CONSTANT_Class类或接口的符号引用8CONSTANT_String字符串类型字面量9CONSTANT_Fieldref字段的符号引用10CONSTANT_Methodref方法的符号引用11CONSTANT_InterfaceMethodref接口方法的符号引用12CONSTANT_NameAndType字段或方法的名称和描述符15CONSTANT_MethodHandle方法句柄16CONSTANT_MethodType方法类型18CONSTANT_InvokeDynamic动态调用点这里有个细节很多人不知道CONSTANT_Long和CONSTANT_Double在常量池里占两个位置。也就是说如果常量池计数器是10实际能放进去的常量可能只有9个因为long或double会“吃掉”一个索引。这个设计是为了让某些操作更简单但写解析器的时候如果不注意索引就会错位。另一个细节是常量池的索引从1开始0被保留表示“不引用任何常量”。所以你在字节码里看到#1、#2这样的编号都是从1起步的。2.2 用javap看穿常量池的真面目光说结构太抽象直接上工具。写一个最简单的类public class ConstantDemo { private static final int NUM 100; private static final String NAME hello; public static void main(String[] args) { String s world; System.out.println(NAME s); } }编译之后用javap -v ConstantDemo.class查看。你会看到类似这样的输出Constant pool: #1 Methodref #6.#20 // java/lang/Object.init:()V #2 Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream; #3 String #23 // world #4 Class #24 // java/lang/StringBuilder #5 Methodref #4.#20 // java/lang/StringBuilder.init:()V ... #21 Class #30 // java/lang/System #22 NameAndType #31:#32 // out:Ljava/io/PrintStream; #23 Utf8 world #24 Utf8 java/lang/StringBuilder ...这里能看出几个关键点。第一world这个字符串字面量在常量池里对应两个条目一个是CONSTANT_String#3它指向一个CONSTANT_Utf8#23。CONSTANT_String是“字符串类型”的符号引用而CONSTANT_Utf8才是真正的字符内容。第二System.out这种字段引用是通过CONSTANT_Fieldref指向CONSTANT_Class和CONSTANT_NameAndType组合出来的。第三方法引用也是类似的多级指向。这种“多级指向”的设计是为了复用。比如多个地方引用同一个类名只需要一个CONSTANT_Utf8存字符串多个CONSTANT_Class都指向它就行。这样能显著减小class文件的体积。2.3 常量池里的符号引用和直接引用类文件里的常量池存的都是符号引用。所谓符号引用就是用一组字面量来描述你要引用的目标比如类的全限定名、字段的名字和描述符、方法的名字和描述符。它不包含任何内存地址信息因为类文件还没被加载到内存里根本不知道地址。当类被加载到JVM之后这些符号引用会在解析阶段被替换成直接引用。直接引用就是指向内存中具体位置的指针、偏移量或者句柄。解析可以发生在类加载时也可以延迟到第一次使用时这取决于JVM的实现和具体的引用类型。这里有个常见的误解很多人以为常量池在类加载后就“变成”运行时常量池了。更准确的说法是运行时常量池是类文件常量池在内存中的表示它包含了常量池里的所有符号引用并且额外支持动态添加新常量。类文件常量池是静态的、只读的运行时常量池是动态的、可以扩展的。注意javap -v输出的常量池编号和运行时常量池的索引不是一回事。前者是class文件里的静态编号后者是JVM内部的数据结构。不要混为一谈。3. 运行时常量池方法区里的动态世界3.1 运行时常量池到底存在哪运行时常量池在逻辑上属于方法区。在JDK 8之前方法区的实现叫永久代运行时常量池就在永久代里。JDK 8之后永久代被元空间取代运行时常量池就跟着进了元空间。但字符串常量池是个例外它在JDK 7的时候就被挪到了堆里这个后面会细说。为什么要把字符串常量池挪到堆里因为永久代的内存回收效率太低而字符串又是最容易被大量创建和销毁的对象。放在堆里可以借助堆的垃圾回收机制来管理减少内存溢出的风险。这个改动在JDK 7就完成了但很多人到现在还以为字符串常量池在方法区。运行时常量池里存的东西比类文件常量池要多。除了符号引用它还存了编译期生成的各种字面量以及运行期动态生成的常量。比如String.intern()产生的字符串引用就会进入字符串常量池而字符串常量池在JDK 7之后是堆的一部分。3.2 常量池的动态性intern和动态语言支持运行时常量池和类文件常量池最大的区别就是动态性。类文件常量池在编译后就固定了但运行时常量池可以在运行期间添加新的常量。最典型的例子就是String.intern()。当你调用intern()时如果字符串常量池里已经有一个相等的字符串就返回那个字符串的引用如果没有就把当前字符串的引用加入常量池然后返回。注意JDK 7之后加入的是引用而不是复制一份字符串对象。这个改动直接影响了intern()的内存行为。另一个动态性的例子是invokedynamic指令。它允许在运行时动态解析调用点常量池里对应的CONSTANT_InvokeDynamic条目会关联一个引导方法由引导方法决定实际调用哪个方法。这是Java 8引入Lambda表达式的基础。Lambda表达式在编译时不会生成内部类而是生成一个invokedynamic指令由LambdaMetafactory在运行时动态生成调用点。Runnable r () - System.out.println(hi);这段代码编译后你会看到invokedynamic指令而不是一个匿名内部类。常量池里会有一个CONSTANT_InvokeDynamic条目指向LambdaMetafactory.metafactory这个引导方法。这就是运行时常量池动态性的直接体现。3.3 常量池的解析时机与惰性解析JVM规范允许在类加载时解析常量池里的符号引用也允许延迟到第一次使用时再解析。大多数JVM实现选择惰性解析因为这样可以加快类加载速度避免解析那些根本用不到的引用。惰性解析的代价是第一次使用某个引用时会有额外的解析开销。但这个开销通常很小而且只发生一次。解析之后符号引用就被替换成直接引用后续访问就很快了。这里有个容易踩的坑如果解析失败比如引用的类不存在JVM会抛出NoClassDefFoundError或NoSuchMethodError。这些错误可能发生在类加载时也可能发生在第一次调用时取决于解析时机。所以有时候你会看到程序启动正常但运行到某个分支才报错这就是惰性解析导致的。实操心得如果你在排查NoClassDefFoundError不要只盯着类加载阶段。检查一下是不是某个常量池引用在第一次使用时才解析而那个类在运行环境中确实缺失。用-verbose:class参数可以看到类加载的详细过程对定位这类问题很有帮助。4. 字符串常量池最容易被误解的一块4.1 字符串常量池在JDK 7前后的位置变化字符串常量池的位置变化是理解字符串行为的关键。JDK 6及之前字符串常量池在永久代里JDK 7及之后它被挪到了堆里。这个改动带来的最直接影响是intern()的行为变了。在JDK 6里调用intern()时如果常量池里没有这个字符串会把字符串复制一份到永久代然后返回永久代里那个副本的引用。在JDK 7里如果常量池里没有会把堆里这个字符串的引用直接记到常量池里返回的还是堆里那个对象的引用。这个区别可以用一段代码验证public class InternDemo { public static void main(String[] args) { String s new String(abc); String interned s.intern(); System.out.println(s interned); } }在JDK 6里输出是false因为interned指向永久代里的副本s指向堆里的对象。在JDK 7及之后输出是true因为interned和s指向同一个堆对象。这个差异在面试里经常被问到但很多人只背了结论不知道背后的原因。4.2 字面量和new String的字节码差异再来看一个经典问题String a abc; String b abc; String c new String(abc); System.out.println(a b); // true System.out.println(a c); // false为什么a b是true因为abc是字面量编译时就被放进了类文件常量池。类加载时JVM会检查字符串常量池里有没有abc没有就创建有就直接返回引用。所以a和b指向同一个对象。为什么a c是false因为new String(abc)会在堆里创建一个新的String对象即使内容相同地址也不同。c指向堆里这个新对象a指向字符串常量池里的对象所以不相等。用javap -c看字节码a abc对应的是ldc #2也就是从常量池加载。c new String(abc)对应的是new、dup、ldc、invokespecial这一串指令先创建对象再调用构造方法。字节码层面就完全不一样。4.3 intern()的底层行为与内存变化intern()的行为可以用下面这段代码来观察public class InternMemory { public static void main(String[] args) { String s1 new String(a) new String(b); String s2 s1.intern(); System.out.println(s1 s2); String s3 ab; System.out.println(s2 s3); } }在JDK 7及之后s1是堆里拼接出来的ab对象。调用intern()时字符串常量池里还没有ab所以把s1的引用记入常量池返回s1。所以s1 s2是true。接着s3 ab从常量池加载拿到的是同一个引用所以s2 s3也是true。如果把顺序换一下String s3 ab; String s1 new String(a) new String(b); String s2 s1.intern(); System.out.println(s1 s2); // false System.out.println(s2 s3); // true先有ab在常量池里intern()发现已经存在直接返回常量池里的引用。s1是堆里新创建的对象s2是常量池里的对象所以s1 s2是falses2 s3是true。这个顺序差异是很多面试题的考点。理解它的关键就是intern()返回的永远是字符串常量池里的那个引用至于那个引用指向哪里取决于常量池里是否已经有相等的字符串。注意intern()在JDK 6和JDK 7的行为差异会导致同一段代码在不同版本上输出不同结果。如果你在维护老系统升级JDK时一定要检查所有用到intern()的地方。5. 包装类缓存常量池思想的延伸5.1 Integer缓存的实现原理Integer的缓存机制本质上是常量池思想在包装类上的应用。Java对-128到127之间的整数预先创建了Integer对象并缓存起来。当你用Integer.valueOf()或者自动装箱时如果值在这个范围内就直接返回缓存对象否则新建对象。Integer a 100; Integer b 100; System.out.println(a b); // true Integer c 200; Integer d 200; System.out.println(c d); // false为什么是-128到127因为这个范围覆盖了最常见的整数使用场景缓存这些值可以显著减少对象创建。上限127是Byte.MAX_VALUE下限-128是Byte.MIN_VALUE。这个范围可以通过-XX:AutoBoxCacheMax参数调整上限但下限不能改。Integer.valueOf()的源码大致是这样的public static Integer valueOf(int i) { if (i IntegerCache.low i IntegerCache.high) return IntegerCache.cache[i (-IntegerCache.low)]; return new Integer(i); }IntegerCache是一个静态内部类在类初始化时创建缓存数组。这个初始化发生在Integer类被首次加载时所以缓存是全局共享的。5.2 其他包装类的缓存范围对比不是所有包装类都有缓存也不是所有缓存范围都一样。下面这张表可以帮你快速对比包装类缓存范围是否可调Byte-128 ~ 127否Short-128 ~ 127否Integer-128 ~ 127上限可调Long-128 ~ 127否Character0 ~ 127否Booleantrue / false否Float无缓存-Double无缓存-Float和Double没有缓存因为浮点数的值太多太分散缓存意义不大。Boolean只有两个值直接缓存了TRUE和FALSE两个常量。这里有个容易踩的坑Character的缓存范围是0到127也就是ASCII字符范围。如果你用Character比较中文或扩展字符就不会走缓存。5.3 自动装箱拆箱的陷阱与性能影响自动装箱拆箱是语法糖但糖吃多了也会蛀牙。看这段代码Long sum 0L; for (long i 0; i Integer.MAX_VALUE; i) { sum i; }这段代码看起来没问题但sum i实际上会先拆箱sum做加法再装箱成Long。每次循环都创建一个新的Long对象Integer.MAX_VALUE次循环就是二十多亿个对象。这个性能开销是灾难性的。正确的写法是用基本类型long sum 0L; for (long i 0; i Integer.MAX_VALUE; i) { sum i; }这样就没有装箱拆箱开销了。这个例子说明理解包装类缓存和装箱机制不只是为了应付面试更是为了写出高性能的代码。实操心得在循环体、高频调用的方法里尽量用基本类型。如果必须用包装类注意缓存范围避免在循环里创建大量超出缓存范围的对象。用javap -c看字节码如果看到Integer.valueOf或Long.valueOf就要警惕了。6. 常量池相关的常见问题与排查实录6.1 字符串比较结果不符合预期的排查思路字符串比较出问题排查顺序可以按这个来先确认是还是equals()。比地址equals()比内容。如果你想要比内容永远用equals()。如果是确认两个字符串的来源。是字面量、new String()、intern()、还是拼接出来的不同来源地址不同。如果是拼接确认是编译期常量折叠还是运行期拼接。a b在编译期就折叠成ab而s1 s2是运行期用StringBuilder拼接。如果是intern()确认JDK版本和调用顺序。JDK 6和JDK 7行为不同先intern()和后intern()结果也不同。下面这张表可以快速对照表达式结果原因a b abtrue编译期常量折叠s1 s2 abfalse运行期拼接新对象new String(a) afalsenew创建新对象new String(a).intern() atrueintern返回常量池引用Integer.valueOf(100) Integer.valueOf(100)true走缓存Integer.valueOf(200) Integer.valueOf(200)false超出缓存范围6.2 OutOfMemoryError: PermGen space的成因与解决在JDK 8之前如果程序大量使用intern()或者动态生成类很容易把永久代撑爆报OutOfMemoryError: PermGen space。原因是字符串常量池和类元数据都在永久代里而永久代的大小是固定的默认只有几十兆。解决办法有几个一是调大-XX:MaxPermSize但这只是拖延问题二是升级到JDK 8永久代被元空间取代元空间使用本地内存默认没有上限三是检查代码看看是不是在循环里调用intern()或者用动态代理、反射生成大量类。JDK 8之后这个错误基本消失了但取而代之的是OutOfMemoryError: Metaspace。元空间虽然默认无上限但可以通过-XX:MaxMetaspaceSize限制。如果元空间溢出通常是类加载器泄漏比如应用服务器热部署时旧类加载器没被回收。6.3 常量池相关的面试高频问题速查问题核心答案常量池在内存哪个区域运行时常量池在方法区/元空间字符串常量池在堆String s new String(abc) 创建几个对象1个或2个取决于常量池是否已有abcintern()的作用返回字符串常量池中的引用Integer缓存范围-128 ~ 127上限可调为什么String设计成不可变线程安全、可缓存哈希、常量池共享常量池和运行时常量池的区别前者在class文件里后者在内存里后者可动态扩展6.4 用参数和工具观察常量池行为JVM提供了一些参数和工具可以帮你观察常量池的行为-XX:PrintStringTableStatistics在JVM退出时打印字符串常量池的统计信息包括桶数量、条目数量、总大小等。-XX:StringTableSizexxx调整字符串常量池的桶数量。默认是60013如果程序大量使用intern()可以调大这个值减少哈希冲突。jmap -heap pid查看堆内存使用情况包括字符串常量池的占用。jcmd pid VM.stringtable查看字符串常量池的详细内容。# 启动时打印字符串常量池统计 java -XX:PrintStringTableStatistics -jar app.jar # 运行时查看字符串常量池 jcmd pid VM.stringtable -verbose这些工具在排查内存泄漏和性能问题时非常有用。比如你发现字符串常量池条目数异常增长就要检查是不是有代码在循环里调用intern()。注意-XX:StringTableSize的值最好是质数这样哈希分布更均匀。默认值60013就是一个质数。如果你要调整建议选一个接近的质数比如100003或1000003。7. 从常量池看JVM设计哲学常量池的设计体现了JVM的几个核心思想。第一是复用同样的字符串、同样的类名、同样的方法描述符在常量池里只存一份多处引用。第二是延迟符号引用在真正用到时才解析加快启动速度。第三是分层类文件常量池、运行时常量池、字符串常量池各司其职静态和动态分离。理解这些设计思想比记住具体结论更重要。因为JVM在演进具体行为可能随版本变化但设计思想是稳定的。比如字符串常量池从永久代挪到堆就是“让最活跃的数据享受最高效的回收”这一思想的体现。包装类缓存的范围可调就是“在通用性和性能之间找平衡”的体现。我在实际工作中遇到常量池相关的问题通常不是因为它本身有多复杂而是因为它的行为在不同JDK版本、不同代码路径下表现不一致。所以我的建议是不要背结论要理解机制不要只看代码要动手验证不要只关注正常路径要关注边界情况。最后分享一个我常用的验证方法写一段有疑问的代码用javap -v看常量池用javap -c看字节码再跑一遍看输出。三步下来基本没有搞不清楚的常量池问题。这个方法我用了很多年比任何文档都靠谱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑