2556 字
约 8 分钟
13
S05-X10 · Class 常量池、运行时常量池与字符串池
2024-12-31
2026-11-08

问"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. 自测

  1. 三个"常量池"各存在于哪个区域,生命周期由什么决定?
  2. CONSTANT_String 条目本身存的是字符串内容吗?如果不是,它指向什么?
  3. JDK 6 与 JDK 17 的 intern() 实现差异是什么,这个差异如何改变 new String("a") + new String("b") 之后 s == s.intern() 的结果?
  4. "a" + "b" 与 new String("a") + new String("b") 都能得到 == intern() 为 true 的结论,两者机制为什么完全不同?
  5. 字面量进入字符串池的时机是类加载的哪个阶段?如何解释不同类里相同字面量指向同一对象?
  6. -XX:StringTableSize 关的是桶数还是条目数?把 intern() 当缓存滥用会在这个参数上暴露成什么现象?
S05-X10 · Class 常量池、运行时常量池与字符串池
https://www.clxhxhhr.top/posts/5761/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。