String 与字符串常量池:从字节码看字符串的创建与复用

举报
yd_287908631 发表于 2026/10/25 15:01:55 2026/10/25
【摘要】 从源码和字节码拆解 String 的不可变性、常量池与 intern 行为

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。

三个容易混淆的常量池

  1. Class 文件常量池:.class 文件里的一张表,存放字面量和符号引用,例如 CONSTANT_String、CONSTANT_Utf8。
  2. 运行时常量池:类加载后,Class 文件常量池进入方法区(JDK 8 起在元空间),符号引用在解析阶段被替换为直接引用。
  3. 字符串常量池(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 被缓存带来的性能收益。

常见坑 / 最佳实践

  1. 用 == 比较字符串内容。只有字面量、常量折叠结果、intern 返回值之间才可能为 true,业务比较一律用 equals,并把常量写在前面避免空指针:"ok".equals(status)。
  2. 循环里用 += 拼接。改为 StringBuilder,初始容量按预估总长度设置,避免多次扩容复制。
  3. 滥用 intern。intern 需要查表,海量唯一字符串会把 StringTable 撑大并增加 GC 扫描成本,只适合枚举型、重复率高的值。
  4. 用 String 保存密码。String 不可变,无法主动擦除,可能长期残留在常量池和堆中;敏感数据用 char[],使用后 Arrays.fill 清零。
  5. 忽略 split 的参数是正则。.、|、$ 等元字符需要转义,高频场景改用非正则的切分工具。
  6. 需要可变字符序列时选 StringBuilder(非线程安全)或 StringBuffer(方法同步),单线程优先前者。

小结

  • String 的不可变由 final 类、private final 数组和不暴露内部状态共同保证,JDK 9 起用 byte[] 加 coder 实现紧凑存储。
  • 字符串常量池在 JDK 7 移入堆,intern 从"复制对象"变为"登记引用",这是很多题目答案随版本变化的原因。
  • 字面量与编译期常量在类加载时进入常量池,new 一定在堆上创建新对象。
  • 拼接在 JDK 8 用 StringBuilder,JDK 9 及以后用 invokedynamic,循环内仍应显式使用 StringBuilder。
  • 业务比较永远用 equals,String 不适合承载需要擦除的敏感信息。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。