【JVM原理详解】15-运行时常量池

发布时间:2026/7/25 22:47:14
【JVM原理详解】15-运行时常量池 运行时常量池上一篇我们梳理了方法区的演进。方法区中有一个特殊的数据结构——运行时常量池Runtime Constant Pool它与class文件中的常量池、以及开发者常说的字符串常量池有千丝万缕的联系也常常被混淆。本篇将理清Class常量池、运行时常量池、字符串常量池三者的关系与区别深入讲解String.intern()的动态性和JDK版本差异帮助你彻底弄清这个面试高频考点。三种常量池的关系概念全景图┌──────────────────────────────────────────────────────────┐ │ 编译期 │ │ │ │ Class常量池 (Constant Pool Table) │ │ ┌────────────────────────────────────────────┐ │ │ │ #1 Methodref Object.hashCode │ │ │ │ #2 String hello │ │ │ │ #3 Class Demo │ │ │ │ #4 Utf8 Ljava/lang/String; │ │ │ │ ... │ │ │ └────────────────────────────────────────────┘ │ │ │ 类加载 │ │ ▼ │ ├──────────────────────────────────────────────────────────┤ │ 运行期 │ │ │ │ 运行时常量池 (Runtime Constant Pool) [方法区/元空间] │ │ ┌────────────────────────────────────────────┐ │ │ │ #1 → 直接引用 (Object.hashCode的入口地址) │ │ │ │ #2 → String对象引用 hello │ │ │ │ #3 → Klass指针 (Demo的Class对象) │ │ │ │ ... │ │ │ └────────────────────────────────────────────┘ │ │ │ │ 字符串常量池 (String Pool) [堆, JDK 7] │ │ ┌────────────────────────────────────────────┐ │ │ │ hello → String对象引用 │ │ │ │ world → String对象引用 │ │ │ │ java → String对象引用 │ │ │ └────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘Class常量池Class常量池Constant Pool Table是class文件的一部分存在于磁盘上的.class文件中。它在编译期生成存放编译期可知的字面量Literal和符号引用Symbolic Reference。字面量包括文本字符串如hello基本类型值如final int x 10中的10方法/字段的名称和描述符符号引用包括类和接口的全限定名字段的名称和描述符方法的名称和描述符用javap -v查看一个class文件的常量池Constant pool: #1 Methodref #6.#20 // java/lang/Object.init:()V #2 String #21 // hello #3 Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream; #4 Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 Class #26 // Demo #6 Class #27 // java/lang/Object ... #21 Utf8 hello ...关键点Class常量池中的内容都是符号不是真正的Java对象。#2 String #21表示一个字符串常量引用了#21的Utf8条目hello但这里没有实际的String对象。运行时常量池运行时常量池是Class常量池在运行时的内存表示。当class文件被加载到JVM后Class常量池的内容被加载到方法区JDK 8的元空间中形成运行时常量池。运行时常量池相比Class常量池的重要区别是动态性——它可以在运行时添加新内容最典型的就是String.intern()方法。此外运行时常量池中的符号引用在解析阶段会被替换为直接引用方法引用 → 方法的实际入口地址字段引用 → 字段的内存偏移量类引用 → Klass指针Class常量池 (符号): #1 Methodref Object.hashCode 运行时常量池 (解析后): #1 → Object.hashCode的直接入口地址 (0x7f8a2b3c0000)字符串常量池字符串常量池String Pool / String Table是一个专门存储字符串对象引用的数据结构。它是一个哈希表key是字符串的hash值value是指向String对象的引用。字符串常量池的位置随JDK版本变化JDK版本字符串常量池位置JDK 6及以前永久代PermGenJDK 7Java堆JDK 6: ┌────────────────────────────────┐ │ 永久代 │ │ ┌──────────────────────────┐ │ │ │ 运行时常量池 │ │ │ │ ┌──────────────────────┐ │ │ │ │ │ 字符串常量池 │ │ │ │ │ │ hello → String对象 │ │ │ │ │ └──────────────────────┘ │ │ │ └──────────────────────────┘ │ └────────────────────────────────┘ JDK 7: ┌────────────────────────────────┐ │ Java堆 │ │ ┌──────────────────────────┐ │ │ │ 字符串常量池 │ │ │ │ hello → String对象引用 │ │ │ └──────────────────────────┘ │ │ ┌──────────────────────────┐ │ │ │ 普通String对象 │ │ │ └──────────────────────────┘ │ └────────────────────────────────┘ ┌────────────────────────────────┐ │ 元空间 (方法区) │ │ ┌──────────────────────────┐ │ │ │ 运行时常量池 (无字符串池) │ │ │ └──────────────────────────┘ │ └────────────────────────────────┘JDK 7将字符串常量池移到堆中的原因永久代空间有限大量intern()容易OOM字符串常量池的GC与堆一起进行回收更高效为后续移除永久代JDK 8做准备三者关系总结对比维度Class常量池运行时常量池字符串常量池存在位置class文件磁盘方法区/元空间Java堆JDK 7生成时间编译期类加载时运行时动态内容符号引用字面量解析后的直接引用动态内容String对象引用动态性静态动态intern动态intern生命周期永久class文件随类加载/卸载随JVM生命周期一个常见误区字符串常量池不是运行时常量池的一部分JDK 7。它们是两个独立的数据结构只是有协作关系——class文件中的字符串字面量在类加载时会被加入到字符串常量池。String.intern() 的动态性intern()的工作原理String.intern()是一个native方法其行为是如果字符串常量池中已存在equals该字符串的String对象返回常量池中的引用如果不存在将当前String对象的引用加入常量池返回该引用String s1 new String(hello); String s2 s1.intern(); // 如果常量池中没有hello: // s2 s1.intern() → 常量池中存入s1的引用, s2 s1 // 如果常量池中已有hello (如通过字面量hello触发): // s2 常量池中的引用, s2 ! s1 (s1是堆中新对象)JDK 6 vs JDK 7 的intern()差异intern()在不同JDK版本中行为不同这是面试经典考点JDK 6字符串常量池在永久代。intern()时如果常量池没有会复制一份字符串到永久代返回永久代中的引用。JDK 7字符串常量池在堆中。intern()时如果常量池没有只存储当前String对象的引用不复制返回该引用。// 适用: JDK 8/11/17publicclassInternDemo{publicstaticvoidmain(String[]args){// 场景1: 仅intern, 无字面量Strings1newString(hello);// JDK 8: 常量池中存入s1的引用, s2 s1为true?// 实际上: new String(hello)中hello字面量已导致常量池有hello// 所以s1.intern()返回的是之前字面量产生的引用, s2 ! s1Strings2s1.intern();Strings3hello;System.out.println(s1s2);// false (s1是堆中新对象, s2/s3是常量池引用)System.out.println(s2s3);// true (都是常量池引用)// 场景2: 使用StringBuilder构造, 避免字面量预入池Strings4newStringBuilder(world).toString();// 此时world字面量已在常量池(因为StringBuilder的参数)// 改用: String s4 new StringBuilder(wor).append(ld).toString();// 这样wor和ld入池, 但world不在池中Strings5s4.intern();// JDK 7: 常量池存入s4的引用System.out.println(s4s5);// JDK 7: true// JDK 6: false (复制到永久代)}}经典面试题解析// 适用: JDK 8/11/17publicclassInternQuiz{publicstaticvoidmain(String[]args){// 第一组Strings1newString(1);// new String(1)做了两件事:// 1. 1字面量 → 字符串常量池创建1 (假设之前没有)// 2. new String() → 堆中创建新String对象, 内容为1// s1指向堆中的对象, 常量池中有1s1.intern();// 常量池已有1, intern返回常量池引用, 但没有赋给变量, 无影响Strings21;// s2 常量池中的1System.out.println(s1s2);// false (s1是堆对象, s2是常量池引用)// 第二组Strings3newString(2)newString(2);// new String(2) new String(2):// 2字面量入常量池(只一次)// 拼接生成的新String 22在堆中, 不在常量池// s3指向堆中的22Strings4s3.intern();// JDK 7: 常量池没有22, 存入s3的引用, s4 s3// JDK 6: 常量池没有22, 复制到永久代, s4 ! s3Strings522;// s5 常量池中的22// JDK 7: 常量池中的22就是s3的引用, s5 s3// JDK 6: 常量池中的22是永久代中的副本, s5 ! s3System.out.println(s3s4);// JDK 7: true, JDK 6: falseSystem.out.println(s3s5);// JDK 7: true, JDK 6: false}}这组测试的输出在JDK 6和JDK 7中不同JDK 6:false, false, falseJDK 7:false, true, true差异的根源就是JDK 7的intern()存储对象引用而非复制对象。字符串常量池的存储细节底层实现字符串常量池在HotSpot中由StringTable类实现它是一个哈希表默认大小1009JDK 6/ 60013JDK 7。StringTable (哈希表) ┌────┬────┬────┬────┬────┐ │ 0 │ 1 │ 2 │ 3 │... │ ├────┼────┼────┼────┼────┤ │null│ ●──┼──→ │null│ │ └────┘│ │ └────┴────┘ │ │ 链表节点 │ │ ┌──────────────┐ │ └──→ │ hello │ → String对象 │ │ hash99162322│ └─────→ │ world │ → String对象 │ hash11331880│ └──────────────┘JDK 7可以通过-XX:StringTableSize调整哈希表桶数量# 设置字符串常量池大小为1000003 (质数, 减少冲突)java-XX:StringTableSize1000003-jarapp.jar字符串入池的时机字符串进入常量池有几种途径字面量代码中hello这种形式类加载时入池intern()运行时主动入池常量表达式final String s he llo编译期常量折叠为hello入池// 适用: JDK 8/11/17publicclassStringPoolDemo{publicstaticvoidmain(String[]args){Stringahello;// 字面量, 入池Stringbhello;// 常量池已有, 复用System.out.println(ab);// trueStringchello;// 编译期常量折叠为helloSystem.out.println(ac);// trueStringdhe;Stringedllo;// 运行期拼接, 生成新对象System.out.println(ae);// falseStringf(dllo).intern();// intern后回到常量池引用System.out.println(af);// true// final的常量折叠finalStringghe;Stringhgllo;// final变量参与拼接, 编译期折叠System.out.println(ah);// true}}字符串拼接的字节码理解拼接行为需要看字节码Stringdhe;Stringedllo;字节码JDK 80: ldc #2 // String he 2: astore_1 // d he 3: new #3 // class StringBuilder 6: dup 7: invokespecial #4 // StringBuilder.init 10: aload_1 11: invokevirtual #5 // StringBuilder.append(String) 14: ldc #6 // String llo 16: invokevirtual #5 // StringBuilder.append(String) 19: invokevirtual #7 // StringBuilder.toString() 22: astore_2 // e 新String对象d llo在编译期无法确定d的值非final因此编译为StringBuilder.append生成新的String对象。而he llo在编译期就能折叠为hello直接用ldc加载常量池中的hello。JDK 9使用invokedynamic替代StringBuilder优化字符串拼接JEP 280但行为上仍是生成新对象。代码示例观察常量池intern()性能测试// 适用: JDK 8/11/17// 运行: java -XX:StringTableSize10000003 InternPerfimportjava.util.UUID;publicclassInternPerf{publicstaticvoidmain(String[]args){// 生成100万个随机字符串String[]strsnewString[1_000_000];for(inti0;istrs.length;i){strs[i]UUID.randomUUID().toString();}// 测试不intern的内存占用longstartSystem.currentTimeMillis();for(Strings:strs){s.hashCode();// 确保字符串被使用}System.out.println(不intern: (System.currentTimeMillis()-start)ms);// 测试intern的耗时startSystem.currentTimeMillis();for(Strings:strs){s.intern();// 每个字符串都入池}System.out.println(intern: (System.currentTimeMillis()-start)ms);// intern后内存变化可用jmap -histo观察}}intern()的内存优化应用在读取大量重复字符串的场景如日志处理、CSV解析intern()可以节省内存// 适用: JDK 8/11/17publicclassInternMemoryDemo{// 模拟读取大量重复状态码的场景publicvoidprocessLogs(String[]rawStatuses){// 假设rawStatuses中有大量重复的200/404/500// 不intern: 每个状态码都是独立的String对象// 100万条日志, 每条1个状态码 → 100万个String对象// intern: 重复的状态码共享同一个String对象// 100万条日志, 实际只有几种状态码 → 几个String对象for(inti0;irawStatuses.length;i){rawStatuses[i]rawStatuses[i].intern();}}}注意intern()适合重复率高的有限集合字符串。如果字符串大量不重复如UUIDintern反而增加常量池负担。实践要点不要滥用intern()intern()适合重复字符串多的场景如枚举值、状态码。对于大量不同的字符串intern会让StringTable膨胀降低查找性能甚至OOM。-XX:StringTableSize调优如果应用大量使用intern()适当增大StringTableSize如1000003减少哈希冲突。可以通过JMX观察StringTable的使用情况。JDK 6到JDK 7的intern行为差异升级JDK时依赖intern()返回值的比较可能行为变化。虽然不该用比较字符串内容应用equals但遗留代码可能受影响。字符串常量池的GCJDK 7字符串常量池在堆中可以被Full GC回收。如果常量池中的字符串不再被引用包括StringTable的引用可以被GC。但只要StringTable中有引用就不会回收。new String(xxx)创建了几个对象这是一个经典问题。new String(hello)最多创建2个对象——常量池中的hello如果之前不存在和堆中的新String对象。如果hello已在常量池则只创建1个堆中的。答案取决于常量池的初始状态。JDK 9的字符串紧凑存储JEP 254在JDK 9引入了Compact StringsString内部用byte[]而非char[]Latin-1字符只用1字节存储。这减少了50%的字符串内存占用但不改变字符串常量池的行为逻辑。避免String.intern()在并行类的陷阱如果两个不同的类加载器加载了同一个类它们的字符串常量池是共享的StringTable全局唯一。但类的其他元数据是隔离的。这在Tomcat等容器中可能导致意外行为。小结三种常量池各有定位Class常量池磁盘上的符号表编译期生成、运行时常量池方法区/元空间中的运行时表示支持解析和动态添加、字符串常量池堆中的String对象引用哈希表JDK 7从永久代移至堆。运行时常量池的动态性相比Class常量池它可以在运行时通过String.intern()等方法动态添加内容符号引用在解析阶段转为直接引用。String.intern()的JDK差异JDK 6会复制字符串到永久代JDK 7只存储对象引用——这导致new String(xx).intern() new String(xx)在两个版本中结果不同。字符串入池时机字面量类加载时、常量表达式折叠编译期、intern()运行时final变量参与拼接可触发编译期折叠。intern()的使用原则适合高重复字符串场景节省内存不适合大量不同字符串-XX:StringTableSize可调整哈希表大小优化性能。下一篇进入运行时数据区的最后一个区域——直接内存与NIO探讨堆外内存的管理机制。更多内容JVM调优实战