String 与字符串常量池:从字节码看字符串的创建与复用
String 是日常使用频率最高的类,也是面试中版本差异最多的一类题。很多结论在 JDK 6、JDK 7、JDK 9 之间已经变了,只背答案很容易答错。
本文从 String 的字段定义、常量池的位置变迁和 javap 字节码三个角度,把字符串的创建、拼接与复用讲清楚。适合已经会写 Java、但想弄明白"为什么"的读者。
不可变性来自哪里
String 的不可变由三处共同保证:类声明为 final,无法被继承后改写行为;内部字符存储是 private final;所有"修改"方法(substring、replace、concat)都返回新对象,从不暴露可变内部数组。
JDK 8 的字段是 private final char[] value。JDK 9 起为了节省内存改为 byte[] 加一个 coder 标志:Latin-1 范围内的字符每字符只占 1 字节,其余仍用 UTF-16,这就是 Compact Strings(JEP 254)。
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
private final byte[] value; // JDK 9+
private final byte coder; // 0 表示 LATIN1,1 表示 UTF16
private int hash; // 懒计算并缓存
}
final 修饰数组只保证引用不变,数组元素本身仍可被修改。真正的不可变来自"没有把 value 暴露出去"这一封装,而不是 final 关键字本身。
hash 字段缓存哈希值,第一次计算后直接复用,这让 String 非常适合作为 HashMap 的 key。
三个容易混淆的常量池
- Class 文件常量池:
.class文件里的一张表,存放字面量和符号引用,例如CONSTANT_String、CONSTANT_Utf8。 - 运行时常量池:类加载后,Class 文件常量池进入方法区(JDK 8 起在元空间),符号引用在解析阶段被替换为直接引用。
- 字符串常量池(StringTable):HotSpot 内部的一张哈希表,专门用于复用 String 实例。JDK 6 及之前它位于永久代,JDK 7 起移入堆,JDK 8 移除永久代后依然在堆中。
关键点在于,StringTable 持有的是堆中 String 对象的引用,不是字符数据。可以通过 -XX:StringTableSize=100003 调整桶数量(JDK 8 默认 60013),用 jcmd <pid> VM.stringtable 查看当前内容。
一行代码创建几个对象
String s = new String("abc");
如果常量池中还没有 "abc",会先创建常量池中的字面量对象,再在堆上 new 一个 String,共 2 个;如果已存在,则只创建 1 个堆对象。
用 javap 看主方法:
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // String abc
6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
ldc 负责把常量池中的字符串引用压栈,new 加 <init> 才是在堆上创建对象。区分这两步,就能判断"创建了几个对象"。
intern 到底做了什么
intern() 的语义是:如果字符串常量池中已存在 equals 相等的字符串,返回池中的引用;否则把当前对象的引用登记进池,并返回当前对象。
JDK 6 与 JDK 7 及以后的实现不同。JDK 6 会把字符数组复制一份到永久代,返回的是另一个对象;JDK 7 起只把堆中对象的引用放进 StringTable,不再复制。
String s1 = new StringBuilder("ja").append("va").toString();
System.out.println(s1.intern() == s1); // false
String s2 = new StringBuilder("计算").append("机").toString();
System.out.println(s2.intern() == s2); // true(JDK 7 及以后)
"java" 在 JVM 启动阶段就被系统类加载并驻留,所以 intern 返回的是池中已有的那个对象;"计算机" 此前不存在,intern 把 s2 的引用登记进池并返回 s2 本身。同样的代码在 JDK 6 上两行都是 false。
注意:不要用这段代码判断 JDK 版本,不同 JVM 启动时驻留的字符串集合可能不同,结论只对 HotSpot 的常见发行版成立。
拼接发生在编译期还是运行期
编译期常量折叠:"a" + "b"、以及 final String A = "a" 这类编译期常量参与的拼接,会直接折叠成一个字符串字面量,javap 里只剩一条 ldc。
运行期拼接则要看版本。JDK 8 由 javac 生成 StringBuilder 的 append 与 toString:
String a = "hello";
String b = "world";
String c = a + b;
6: new #4 // class java/lang/StringBuilder
10: invokespecial #5 // StringBuilder."<init>":()V
14: invokevirtual #6 // StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
18: invokevirtual #6
21: invokevirtual #7 // StringBuilder.toString:()Ljava/lang/String;
JDK 9 起改为 invokedynamic 调用 StringConcatFactory.makeConcatWithConstants,由 JVM 在运行时选择策略,通常生成一段字节码,避免创建中间 StringBuilder:
invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(...)
这带来两个实践结论。第一,循环里的 s += i 在 JDK 8 每次都新建 StringBuilder,复杂度退化为 O(n²);JDK 9 及以后虽有优化,但每次仍产生新字符串,循环内应显式使用 StringBuilder 并预估容量。第二,判断"拼接是否在常量池中"要看操作数是否为编译期常量,final 变量若不是编译期常量(例如由方法返回值初始化),仍然走运行期拼接。
substring 的两次实现
JDK 6 的 substring 返回的新 String 与原字符串共享同一个 char[],只是偏移量和长度不同:
// JDK 6
String substring(int beginIndex, int endIndex) {
return new String(offset + beginIndex, endIndex - beginIndex, value);
}
这带来一个隐蔽的内存泄漏:从一个 10 MB 的报文里截取 10 个字符,只要这个小字符串还活着,整个 10 MB 的数组就无法回收。JDK 7u6 起改为复制数组,substring 的时间复杂度从 O(1) 变成 O(n),但内存安全。
这个案例说明"不可变对象共享内部数组"是有代价的。今天写代码时如果需要在长生命周期对象里保存大字符串的一小段,仍然建议显式 new String(sub) 或调用 intern,避免持有不必要的引用链。
switch 对 String 的支持
JVM 的 switch 指令只接受 int。编译器的做法是先对字符串调用 hashCode() 做一次 int switch,再在每个分支里用 equals 校验,防止哈希碰撞导致误匹配。这解释了为什么 case 必须是编译期常量,也解释了 String 的 hashCode 被缓存带来的性能收益。
常见坑 / 最佳实践
- 用
==比较字符串内容。只有字面量、常量折叠结果、intern返回值之间才可能为 true,业务比较一律用equals,并把常量写在前面避免空指针:"ok".equals(status)。 - 循环里用
+=拼接。改为 StringBuilder,初始容量按预估总长度设置,避免多次扩容复制。 - 滥用
intern。intern需要查表,海量唯一字符串会把 StringTable 撑大并增加 GC 扫描成本,只适合枚举型、重复率高的值。 - 用 String 保存密码。String 不可变,无法主动擦除,可能长期残留在常量池和堆中;敏感数据用
char[],使用后Arrays.fill清零。 - 忽略
split的参数是正则。.、|、$等元字符需要转义,高频场景改用非正则的切分工具。 - 需要可变字符序列时选 StringBuilder(非线程安全)或 StringBuffer(方法同步),单线程优先前者。
小结
- String 的不可变由 final 类、private final 数组和不暴露内部状态共同保证,JDK 9 起用
byte[]加 coder 实现紧凑存储。 - 字符串常量池在 JDK 7 移入堆,
intern从"复制对象"变为"登记引用",这是很多题目答案随版本变化的原因。 - 字面量与编译期常量在类加载时进入常量池,
new一定在堆上创建新对象。 - 拼接在 JDK 8 用 StringBuilder,JDK 9 及以后用
invokedynamic,循环内仍应显式使用 StringBuilder。 - 业务比较永远用
equals,String 不适合承载需要擦除的敏感信息。
- 点赞
- 收藏
- 关注作者
评论(0)