问"Java 有几种常量池",答"三种"的不少;接着问 new String("a") + new String("b") 之后 s.intern() == s 是真是假,能默写引用走向的就很少。混淆点不在名字多,而在这三者住在三个区域、三种生命周期、三种触发机制。
1. 三个"常量池"住在哪
| 名字 | 位置 | 谁创建 | 生命周期 | 存什么 |
|---|---|---|---|---|
| Class 文件常量池 | .class 字节流里 |
javac 编译期 |
文件级,静态 | 字面量 + 符号引用 |
| 运行时常量池 | 方法区(JDK 8+ 元空间) | JVM 加载时解析生成 | 跟随 Class 对象,类卸载才消失 |
Class 常量池内容 + 可解析动态引用 |
| 字符串池 StringTable | 堆(JDK 7 起) | JVM 维护的哈希表 | 跟随堆,条目由 GC 决定 | 字面量与 intern() 结果的引用 |
关键认知:运行时常量池是 Class 常量池在内存中的运行时表示,不是另一份副本;字符串池根本不在方法区,它是堆里一张独立 StringTable,只存 String 对象引用。
版本差异:JDK 6 字符串池在永久代;JDK 7 起移入堆;JDK 8 永久代被元空间取代,字符串池仍在堆。JDK 17 按"堆内 StringTable"理解。
这一位置差异决定了 JDK 6 的 intern() 把字符串复制进永久代,JDK 7+ 只在池里插一个指向已有堆对象的引用。
2. Class 常量池条目与 javap -v 读法
| tag | 条目 | 存的什么 | 举例 |
|---|---|---|---|
| 1 | CONSTANT_Utf8 |
真正字节序列(MUTF-8) | 字符串内容、类名、描述符 |
| 3/4 | Integer / Float |
数值字面量 | 100、1.5f |
| 5/6 | Long / Double |
数值字面量,各占两槽 | 1L、1.0 |
| 7 | CONSTANT_Class |
指向一个 Utf8 |
java/lang/String |
| 8 | CONSTANT_String |
指向一个 Utf8 |
"abc" |
| 9–12 | Fieldref / Methodref / InterfaceMethodref / NameAndType |
类 + 名字 + 描述符 | 成员引用 |
| 15/16/18 | MethodHandle / MethodType / InvokeDynamic |
句柄、类型、动态调用点 | lambda、字符串拼接 |
javap -v 里编号跳号不是文件损坏,是 Long/Double 占两槽。
$ javac -d /tmp/out Triad.java
$ javap -v -p /tmp/out/Triad.class | sed -n '/Constant pool:/,/^{/p'
JDK 17 输出截取,形态如下:
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#5 = Utf8 <init>
#7 = String #8 // abc
#8 = Utf8 abc
#7 = String #8 说明 CONSTANT_String 只是个指针,字符在 #8 = Utf8;#2 = Class #4 同理;同一 Utf8 全表只有一份。
小整数根本不进池,看指令就知道:
$ javap -c -p /tmp/out/Triad.class | grep -A2 'static {}'
看到 bipush 10 / sipush 1000 / ldc #12,说明 -128..127 走 bipush、-32768..32767 走 sipush 内联在指令里——这是"字面量一定进常量池"的来源。
3. CONSTANT_String 与字面量:解析阶段发生了什么
| 阶段 | 动作 | 结果落在哪 |
|---|---|---|
| 加载 | 读字节流,生成 Class 结构 |
元空间 |
| 链接-验证 | 校验字节码与 StackMapTable |
元空间 |
| 链接-解析 | 符号引用转直接引用 | 运行时常量池条目被改写 |
| 初始化 | 执行 <clinit> |
静态字段赋真值 |
CONSTANT_String 的解析是关键:JVM 拿 Utf8 内容去堆里 StringTable 查一次,查到复用、查不到就创建堆内 String 并登记,再把运行时常量池该项替换成该堆对象的直接引用。这就是字面量入池的时机。
由此两条事实:同一份 Class 在同一加载器下只解析一次,池查找不是每次执行都做;不同类里同样的 "abc" 解析后指向同一个堆对象——这才是 "abc" == "abc" 为 true 的机制,与 intern() 无关。
$ javap -c -p /tmp/out/Triad.class | sed -n '/main/,/^$/p'
0: ldc #7 // String abc
2: astore_1
7: invokevirtual #19 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
ldc #7 里的 #7 就是那个 CONSTANT_String 项。ldc_w(索引超 255)与 ldc2_w(long/double)语义一致。
4. intern() 语义:JDK 6 与 JDK 7+ 完全不同
| 版本 | 池位置 | intern() 实现 |
引用关系 |
|---|---|---|---|
| JDK 6 | 永久代 | 池中无此串则把内容复制进永久代,返回副本 | 原对象与池对象不同 |
| JDK 7 / 8 / 17 | 堆 | 池中无此串则把调用者自身引用登记进 StringTable | 原对象与池对象同一 |
JDK 7 改动动机:永久代小、回收代价高,复制既浪费内存又易 OutOfMemoryError: PermGen space;搬到堆里后池条目只是引用,字符串跟着堆一起被 GC 管。
public native String intern();
// JDK 17 语义等价(非真实实现):
// if (StringTable.contains(this)) return StringTable.get(this);
// else { StringTable.put(this); return this; }
"返回 this"就是所有推演的关键。
5. 经典推演:new String("a") + new String("b")
String s = new String("a") + new String("b");
System.out.println(s == s.intern());
JDK 9 起字符串拼接由 invokedynamic + StringConcatFactory 实现(JDK 8 及之前是 StringBuilder.append),常量池里不存在 "ab":
0: new #7 // class java/lang/StringBuilder
4: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
7: new #11 // class java/lang/String
11: ldc #13 // String a
13: invokespecial #15 // Method java/lang/String."<init>":(Ljava/lang/String;)V
16: invokevirtual #18 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/String;
28: invokevirtual #24 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
逐条推演(JDK 17):
| 步骤 | 动作 | 池里有无 "ab" |
堆上对象 |
|---|---|---|---|
| 1 | 解析 ldc #13,创建并登记 "a" |
无关 | "a" 与 new String("a") |
| 2 | new String("b") 同理,登记 "b" |
无关 | "b" 与 new String("b") |
| 3 | 拼接产生结果 | 不产生池登记 | 堆上新增 "ab",未入池 |
| 4 | s.intern() 查池无 "ab" → 登记 s 自身 |
新增条目指向 s |
无新对象 |
| 5 | s == s.intern() |
同一引用 | 输出 true |
JDK 7+ 是 true,JDK 6 是 false——答案随版本变,这是考点。
| 写法(JDK 17) | == intern() |
机制 |
|---|---|---|
"a" + "b" |
true |
编译期折叠成 ldc "ab",解析时已入池 |
new String("a") + new String("b") |
true |
运行期拼接,首次 intern() 登记自身 |
new String("ab") |
false |
池中已有 "ab",intern() 返回旧对象 |
new String("a") |
false |
同上,池中已有 "a" |
"a" + "b" 在编译期折叠(JLS 15.29),走的是第 3 节解析路径而非 intern() 路径。结论相同、机制不同,最易被追问。
6. StringTable 容量与调参
| 参数 | 含义 | JDK 17 默认 | 备注 |
|---|---|---|---|
-XX:StringTableSize=N |
桶数量,须为质数 | 60013 |
JDK 7 默认 1009,JDK 8 起提升 |
-XX:+PrintStringTableStatistics |
退出时打印统计 | 关闭 | 需启动参数 |
-XX:+UseStringDeduplication |
G1 堆内字符串去重 | 关闭 | 不等于 intern() |
-XX:StringDeduplicationAgeThreshold |
去重年龄门槛 | 3 |
仅 G1 生效 |
$ java -XX:+PrintStringTableStatistics -cp /tmp/out Triad
典型输出形态:
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, each 8
Number of entries : 913 = 14608 bytes, each 16
Average bucket size : 0.015
Maximum bucket size : 2
Average bucket size 超过 1 说明桶偏少,链长了 intern() 查找就退化。
$ jinfo -flag StringTableSize <pid> # 实际生效值,比看启动脚本可靠
7. 常见坑
| 坑 | 表现 | 真实原因 | 正确做法 |
|---|---|---|---|
| 把三者当同一个东西 | 说"字符串在方法区不会 GC" | 字符串池在堆,条目可被 GC | 分清三张表的区域与生命周期 |
把 static final String 当池内容 |
说它"一定进常量池" | 只有编译期常量表达式才进 Class 常量池 | javap -v 看有无 ConstantValue 属性 |
intern() 当去重缓存 |
运行久后停顿变长,条目只涨 | 池条目被主加载器持有几乎不回收;且池只存引用,字符串对象仍在堆 | 用有界缓存或 G1 去重;只在解析入口 intern() |
用 == 判字符串相等 |
有时真有时假 | == 比引用 |
一律 equals |
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
s == s.intern() 与预期不符 |
版本差异或拼接方式差异 | javap -v 确认是 ldc 还是拼接,再按版本推 |
运行数小时后 intern() 变慢、停顿变长 |
StringTable 条目膨胀、触长链并触发清理 | PrintStringTableStatistics 看桶均长,改用有界缓存 |
手写字节码插入新常量项是最常见错误来源:跨类拷方法却没搬方法引用,就会出现"索引指向错的 Utf8"。
9. 边界
| 场景 | 是否深挖 | 理由 |
|---|---|---|
| 排查字符串 OOM / 停顿 | 该 | StringTable 条目与 intern() 是直接嫌疑 |
| 写字节码增强、AOP、Java Agent | 该 | 常量池索引与 StackMapTable 是硬约束 |
| 面试与原理理解 | 该 | 三池区分是 JVM 内存模型基本功 |
| 普通业务优化 / 选 GC 参数 | 不必 | javac 已做折叠复用,手动 intern() 多半有害 |
常量池是三张表的关系问题,不是"内存里放着几个常量"的问题。
10. 自测
- 三个"常量池"各存在于哪个区域,生命周期由什么决定?
CONSTANT_String条目本身存的是字符串内容吗?如果不是,它指向什么?- JDK 6 与 JDK 17 的
intern()实现差异是什么,这个差异如何改变new String("a") + new String("b")之后s == s.intern()的结果? "a" + "b"与new String("a") + new String("b")都能得到== intern()为 true 的结论,两者机制为什么完全不同?- 字面量进入字符串池的时机是类加载的哪个阶段?如何解释不同类里相同字面量指向同一对象?
-XX:StringTableSize关的是桶数还是条目数?把intern()当缓存滥用会在这个参数上暴露成什么现象?