跳到主要内容

String 不可变性与字符串常量池

String 对象创建后,字符序列不会再改变。不可变性让字符串可以安全共享,也让字符串常量池能够复用内容相同的字面量;需要持续拼接时,则应使用可变的构建器。

1. String 对象为什么不可变

String 提供的操作不会修改原对象,而是返回结果字符串:

String original = "Coding";
String result = original.concat(" 101");

System.out.println(original); // Coding
System.out.println(result); // Coding 101

original 变量仍然指向内容为 "Coding" 的对象。concat 的结果需要由调用方接收,否则结果会被丢弃。

不可变性由完整的类设计保证,不能只归因于某一个 final 字段:

  • String 类不能被继承,子类无法改变它的语义。
  • 内部字符表示不向调用方直接暴露。
  • 公开 API 不提供修改已有字符序列的方法。
  • 构造方法会保护内部表示,不让外部可变数组反向修改字符串。

实现细节可以随 JDK 版本调整,例如内部存储不必永远是 char[]。对使用者稳定的契约是字符串值创建后保持不变。

1.1 不可变性带来的结果

同一个 String 对象可以被多个对象和线程共享,调用方不需要担心另一处代码修改它的内容。字符串还能稳定地作为 HashMap 的键,因为参与 equalshashCode 的字符序列不会变化。

代价也很直接:每次产生不同内容,都需要得到另一个字符串。一次短表达式通常不成问题,在循环中反复拼接大量内容则可能产生不必要的中间结果。

2. 字符串字面量与常量池

Java 会驻留字符串字面量。内容相同的字面量引用同一个规范字符串对象:

String first = "Java";
String second = "Java";

System.out.println(first == second); // true
System.out.println(first.equals(second)); // true

这里的 ==true,是字面量驻留产生的对象身份结果。业务代码判断字符串内容时仍然应使用 equals,因为字符串也可能在运行时创建:

String created = new String("Java");

System.out.println(first == created); // false
System.out.println(first.equals(created)); // true

new String(...) 明确创建了一个新的 String 对象。表达式中涉及的字面量是否早已加载,取决于类和运行时状态,因此不应把“这行代码总共创建几个对象”当作脱离上下文的固定数字。

2.1 编译期常量会被折叠

由字符串字面量和常量表达式组成的拼接,可以在编译阶段得到结果:

String first = "Coding" + " 101";
String second = "Coding 101";

System.out.println(first == second); // true

如果表达式包含运行时变量,结果通常在运行时计算:

String prefix = "Coding";
String first = prefix + " 101";
String second = "Coding 101";

System.out.println(first == second); // false
System.out.println(first.equals(second)); // true

不要根据常量池行为选择 ==。两段代码的内容判断都应写成 equals

2.2 intern() 返回规范表示

String.intern() 会返回内容相同字符串的规范表示:

String created = new String("Java");
String canonical = created.intern();

System.out.println(canonical == "Java"); // true

intern() 适合内容重复度高、生命周期明确,并且经过测量确认能够节省内存的场景。对任意外部输入无条件驻留,可能让大量字符串长期保留。普通业务代码通常不需要主动调用它。

3. StringBuilder 与 StringBuffer

StringBuilderStringBuffer 保存可变字符序列,append 等操作修改当前对象并返回自身:

StringBuilder builder = new StringBuilder();
builder.append("Coding");
builder.append(' ');
builder.append(101);

String result = builder.toString();

两者的主要差异是同步策略:

类型内容是否可变同步策略常见用途
String不需要为内容变化加锁保存和传递最终文本
StringBuilder不提供同步保证方法内部或单线程构建文本
StringBuffer主要公开操作带同步多线程共享同一个可变缓冲区

绝大多数字符串构建器都只存在于一个方法内部,不会在线程间共享,此时使用 StringBuilder 更直接。

StringBuffer 的单个方法具有同步保护,不代表多步组合自动具备原子性。例如“先判断长度,再追加内容”包含两次调用,如果整体必须不可分割,仍然需要在更高层定义同步边界。很多场景也可以避免共享构建器,让每个线程独立构建结果。

4. 字符串拼接怎样选择

少量、直观的一次性拼接可以直接使用 +

String message = "order " + orderId + " created";

Java 规范允许编译器选择具体的拼接实现,现代 JDK 不应简单概括为“所有 + 都会翻译成 StringBuilder”。对调用方重要的是表达式结果,不要依赖某个编译器版本的字节码形态。

循环或逐步构建较长文本时,显式复用一个 StringBuilder

StringBuilder result = new StringBuilder();

for (String item : items) {
if (!result.isEmpty()) {
result.append(',');
}
result.append(item);
}

return result.toString();

如果目标只是用固定分隔符连接现有字符串,String.joinCollectors.joining 通常比手写循环更清楚。只有性能真的重要时,再用基准测试验证容量预估和构建方式。

5. 常见问题

5.1 Stringfinal 修饰,所以一定不可变吗

final 类只能阻止继承,不能自动保证对象内部状态不变。String 的不可变性还依赖私有内部表示、不暴露可变引用,以及所有公开操作都不修改已有字符序列。

5.2 final String 与不可变字符串有什么区别

final String name = "Java";

这里的 final 限制变量 name 不能重新赋值。即使变量不声明为 final,它指向的 String 对象仍然不可变。变量能否重新指向别处,与对象内容能否修改是两件事。

5.3 StringBuilder 一定比 + 快吗

不能脱离上下文判断。单个表达式的 + 可读性更好,编译器也会优化常量和运行时拼接。循环中重复执行 result = result + item 会不断产生新结果,复用 StringBuilder 通常更合适。最终差异还取决于 JDK、数据量和调用方式。

5.4 String.length() 返回字符数量吗

它返回 UTF-16 代码单元数量。部分 Unicode 字符由一对代理项表示,会占用两个位置。需要按 Unicode 码点处理时,使用 codePoints()codePointCount() 等 API,而不是假设一个 char 就对应一个用户看到的字符。

6. 面试题

6.1 String、StringBuilder 和 StringBuffer 有什么区别

出现公司:字节跳动、蚂蚁金服、中兴

考察重点

  • 不可变字符串与可变字符序列的差异。
  • StringBuffer 的同步保证覆盖到什么粒度。
  • 一次性拼接、循环构建和共享缓冲区分别怎样选择。

相关内容:第 1 节“String 对象为什么不可变”、第 3 节“StringBuilder 与 StringBuffer”、第 4 节“字符串拼接怎样选择”。

参考回答

String 表示不可变字符序列,所有产生不同内容的操作都会返回另一个字符串,因此适合保存和共享最终文本。StringBuilderStringBuffer 是可变字符序列,连续 append 可以复用同一个构建器。

StringBuilder 不提供同步保证,适合方法内部等不共享场景。StringBuffer 的主要操作带同步,但多个方法组合仍不自动成为一个原子操作。少量拼接可以直接使用 +,循环中持续构建文本通常使用一个 StringBuilder

6.2 字符串常量池解决了什么问题

出现公司:华为、网易互娱

考察重点

  • 字符串字面量为什么能够共享。
  • ==equals 在字符串比较中的含义。
  • new String(...)、编译期常量和 intern() 的差异。

相关内容:第 2 节“字符串字面量与常量池”。

参考回答

Java 会驻留字符串字面量,让内容相同的字面量复用同一个规范字符串对象。因为 String 不可变,共享对象不会被任意调用方改坏。

new String(...) 会明确创建新对象,所以即使内容相同,也不保证与池中的对象引用相等。intern() 可以取得内容对应的规范表示。常量池会影响对象身份,但业务代码比较字符串内容仍然应使用 equals