【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及以前 | 永久代(PermGen) |
| JDK 7+ | Java堆 |
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, 无字面量Strings1=newString("hello");// JDK 8: 常量池中存入s1的引用, s2 == s1为true?// 实际上: new String("hello")中"hello"字面量已导致常量池有"hello"// 所以s1.intern()返回的是之前字面量产生的引用, s2 != s1Strings2=s1.intern();Strings3="hello";System.out.println(s1==s2);// false (s1是堆中新对象, s2/s3是常量池引用)System.out.println(s2==s3);// true (都是常量池引用)// 场景2: 使用StringBuilder构造, 避免字面量预入池Strings4=newStringBuilder("world").toString();// 此时"world"字面量已在常量池(因为StringBuilder的参数)// 改用: String s4 = new StringBuilder("wor").append("ld").toString();// 这样"wor"和"ld"入池, 但"world"不在池中Strings5=s4.intern();// JDK 7+: 常量池存入s4的引用System.out.println(s4==s5);// JDK 7+: true// JDK 6: false (复制到永久代)}}经典面试题解析
// 适用: JDK 8/11/17publicclassInternQuiz{publicstaticvoidmain(String[]args){// 第一组Strings1=newString("1");// new String("1")做了两件事:// 1. "1"字面量 → 字符串常量池创建"1" (假设之前没有)// 2. new String() → 堆中创建新String对象, 内容为"1"// s1指向堆中的对象, 常量池中有"1"s1.intern();// 常量池已有"1", intern返回常量池引用, 但没有赋给变量, 无影响Strings2="1";// s2 = 常量池中的"1"System.out.println(s1==s2);// false (s1是堆对象, s2是常量池引用)// 第二组Strings3=newString("2")+newString("2");// new String("2") + new String("2"):// "2"字面量入常量池(只一次)// 拼接生成的新String "22"在堆中, 不在常量池// s3指向堆中的"22"Strings4=s3.intern();// JDK 7+: 常量池没有"22", 存入s3的引用, s4 == s3// JDK 6: 常量池没有"22", 复制到永久代, s4 != s3Strings5="22";// s5 = 常量池中的"22"// JDK 7+: 常量池中的"22"就是s3的引用, s5 == s3// JDK 6: 常量池中的"22"是永久代中的副本, s5 != s3System.out.println(s3==s4);// JDK 7+: true, JDK 6: falseSystem.out.println(s3==s5);// JDK 7+: true, JDK 6: false}}这组测试的输出在JDK 6和JDK 7+中不同:
- JDK 6:
false, false, false - JDK 7+:
false, true, true
差异的根源就是JDK 7+的intern()存储对象引用而非复制对象。
字符串常量池的存储细节
底层实现
字符串常量池在HotSpot中由StringTable类实现,它是一个哈希表,默认大小1009(JDK 6)/ 60013(JDK 7+)。
StringTable (哈希表) ┌────┬────┬────┬────┬────┐ │ 0 │ 1 │ 2 │ 3 │... │ ├────┼────┼────┼────┼────┤ │null│ ●──┼──→ │null│ │ └────┘│ │ └────┴────┘ │ │ 链表节点 │ │ ┌──────────────┐ │ └──→ │ "hello" │ → String对象 │ │ hash=99162322│ └─────→ │ "world" │ → String对象 │ hash=11331880│ └──────────────┘JDK 7+可以通过-XX:StringTableSize调整哈希表桶数量:
# 设置字符串常量池大小为1000003 (质数, 减少冲突)java-XX:StringTableSize=1000003-jarapp.jar字符串入池的时机
字符串进入常量池有几种途径:
- 字面量:代码中
"hello"这种形式,类加载时入池 intern():运行时主动入池- 常量表达式:
final String s = "he" + "llo",编译期常量折叠为"hello"入池
// 适用: JDK 8/11/17publicclassStringPoolDemo{publicstaticvoidmain(String[]args){Stringa="hello";// 字面量, 入池Stringb="hello";// 常量池已有, 复用System.out.println(a==b);// trueStringc="he"+"llo";// 编译期常量折叠为"hello"System.out.println(a==c);// trueStringd="he";Stringe=d+"llo";// 运行期拼接, 生成新对象System.out.println(a==e);// falseStringf=(d+"llo").intern();// intern后回到常量池引用System.out.println(a==f);// true// final的常量折叠finalStringg="he";Stringh=g+"llo";// final变量参与拼接, 编译期折叠System.out.println(a==h);// true}}字符串拼接的字节码
理解拼接行为需要看字节码:
Stringd="he";Stringe=d+"llo";字节码(JDK 8):
0: 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:StringTableSize=10000003 InternPerfimportjava.util.UUID;publicclassInternPerf{publicstaticvoidmain(String[]args){// 生成100万个随机字符串String[]strs=newString[1_000_000];for(inti=0;i<strs.length;i++){strs[i]=UUID.randomUUID().toString();}// 测试不intern的内存占用longstart=System.currentTimeMillis();for(Strings:strs){s.hashCode();// 确保字符串被使用}System.out.println("不intern: "+(System.currentTimeMillis()-start)+"ms");// 测试intern的耗时start=System.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(inti=0;i<rawStatuses.length;i++){rawStatuses[i]=rawStatuses[i].intern();}}}注意:intern()适合重复率高的有限集合字符串。如果字符串大量不重复(如UUID),intern反而增加常量池负担。
实践要点
不要滥用
intern():intern()适合重复字符串多的场景(如枚举值、状态码)。对于大量不同的字符串,intern会让StringTable膨胀,降低查找性能,甚至OOM。-XX:StringTableSize调优:如果应用大量使用intern(),适当增大StringTableSize(如1000003)减少哈希冲突。可以通过JMX观察StringTable的使用情况。JDK 6到JDK 7+的intern行为差异:升级JDK时,依赖
intern()返回值的==比较可能行为变化。虽然不该用==比较字符串内容(应用equals),但遗留代码可能受影响。字符串常量池的GC:JDK 7+字符串常量池在堆中,可以被Full GC回收。如果常量池中的字符串不再被引用(包括StringTable的引用),可以被GC。但只要StringTable中有引用,就不会回收。
new String("xxx")创建了几个对象:这是一个经典问题。new String("hello")最多创建2个对象——常量池中的"hello"(如果之前不存在)和堆中的新String对象。如果"hello"已在常量池,则只创建1个(堆中的)。答案取决于常量池的初始状态。JDK 9+的字符串紧凑存储:JEP 254在JDK 9引入了Compact Strings,String内部用
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调优实战
