跳到主要内容
约 9 分钟3 张卡

String

String 表示不可变字符序列,也就是对象创建后,保存的文本内容保持不变。本文先解释不可变性为什么支持共享与常量池复用,再比较字符串拼接方式,最后区分存储编码与字符计数。

1. 面试核心​

先记住这几条,再读正文
  • 不可变性。 String 的字符序列保持不变,因此可以在线程间安全共享,并稳定地作为 HashMap 的键。
  • 常量共享。 内容相同的字符串字面量和字符串常量表达式,会共享常量池中的同一个对象。卡 · 字符串常量池
  • 规范引用。 intern() 返回常量池中内容相同的规范字符串引用,业务代码比较内容仍应使用 equals。
  • 文本构建。 StringBuilder 适合单线程逐步构建文本,StringBuffer 的主要公开操作提供单次调用的同步保证。卡 · String 的不可变性
  • 字符计数。 String.length() 返回 UTF-16 代码单元数。一个 Unicode 码点可能占两个代码单元,所以这个长度不一定等于码点数或读者看到的字符数。卡 · 字符串存储与字符计数

2. String 的不可变性​

String 的操作保持原对象不变。结果内容改变时返回另一个字符串,内容不变时可复用原对象:

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

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

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

要让已有内容保持不变,String 的设计提供了以下不可变性约束:

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

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

2.1 不可变性的作用​

不可变字符串可以被多个对象和线程安全共享,因为其他调用方无法修改同一个字符串的内容。它也适合作为 HashMap 的键:参与 equals 和 hashCode 计算的字符序列保持不变,查找依据就能保持稳定。

产生不同内容时,需要得到另一个字符串。一次短表达式的拼接开销有限,但循环反复拼接大量内容,会不断产生中间结果。因此,要留意这些中间字符串带来的分配和复制成本。

3. 字符串常量池​

字符串常量池让相同内容的字面量复用同一个对象,这种处理称为驻留。其中供相同内容复用的对象,称为规范字符串对象。

String first = "Java";
String second = "Java";
String created = new String("Java");

System.out.println(first == second); // true
System.out.println(first.equals(second)); // true
System.out.println(first == created); // false
System.out.println(first.equals(created)); // true
  • first 和 second 来自相同内容的字面量,所以指向同一个对象,== 返回 true。
  • new String("Java") 明确创建新对象,所以 first == created 返回 false。
  • 三个字符串内容相同,因此两次 equals 都返回 true。业务判断应比较内容,使用 equals。

统计 new String(...) 涉及的对象数量时,还要说明字面量是否已经加载。如果池中已有 "Java",该字面量就可以直接复用,所以对象数量取决于执行前的状态。

3.1 编译期常量折叠​

编译期常量折叠会在编译时算出表达式的结果。字符串常量拼接可以因此直接使用驻留对象,而包含非恒定变量的拼接需要在运行时计算。

String folded = "Coding" + " 101";
String literal = "Coding 101";
String prefix = "Coding";
String runtime = prefix + " 101";

System.out.println(folded == literal); // true
System.out.println(runtime == literal); // false
System.out.println(runtime.equals(literal)); // true
  • folded 的两个操作数都是字面量,编译时就能得到 "Coding 101",所以与 literal 共享对象。
  • prefix 是非恒定变量,runtime 在运行时得到拼接结果,因此与 literal 指向不同对象。
  • 两种拼接的文本内容相同,所以内容比较都应使用 equals,而非依赖对象是否驻留。

3.2 intern()​

运行时创建的字符串也可以通过 intern() 取得规范引用。池中已有相同内容时,它返回已有对象;否则将当前字符串加入池,并返回当前对象:

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

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

示例中的字面量 "Java" 已有对应的驻留对象,所以 canonical 与它的引用相同。

内容重复度高、生命周期明确时,可以测量驻留是否节省内存。对任意外部输入都调用 intern() 会增加池的查找和维护工作。如果应用长期持有驻留结果,也会延长这些对象的寿命。普通业务按内容比较即可,无需主动驻留所有字符串。

4. StringBuilder 与 StringBuffer​

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

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

String result = builder.toString();

连续 append 复用同一个构建器,最终通过 toString() 得到 "Coding 101"。选择类型时,主要比较可变性和同步策略:

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

构建器仅供当前方法使用时,选择 StringBuilder。

StringBuffer 的主要公开方法使用 synchronized,同步保护的是单次方法调用。比如,检查长度后再追加包含两次操作,其他线程可以在两次操作之间修改内容。如果要求这两步整体不可分割,就要在外层增加同步。也可以让每个线程独立构建结果,从而避免共享缓冲区。

5. 字符串拼接的选择​

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

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

Java 规范允许编译器选择具体拼接实现,所以单个 + 表达式可以得到整体优化。编译结果并非固定使用 StringBuilder,使用者应依赖拼接后的文本结果。

具体的编译机制见字符串存储与字符计数。

循环中的 result = result + item 会反复复制前面已经累积的文本。因此,逐步构建较长文本时,可以显式复用一个 StringBuilder:

StringBuilder result = new StringBuilder();

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

return result.toString();

示例把所有元素用逗号连接,最后只取一次完整字符串。固定分隔符连接也可以使用 String.join 或 Collectors.joining,直接表达连接意图。

热点路径再通过基准测试比较容量预估与构建方式。预估容量可以减少缓冲区扩容,但具体收益取决于文本长度和调用频率。

6. 字符串存储与字符计数​

字符串的内部存储方式,与 length() 的计数规则需要分开理解。Java 17、21 的 Compact Strings 使用 byte[] 和编码标记保存文本。全部内容都能用 Latin-1 表示时,每个字符占一个字节;否则整个字符串使用 UTF-16 表示。

公开 API 仍按 UTF-16 代码单元索引,也就是按 16 位编码单位定位文本。Unicode 码点是字符的编号,一个码点可以占一个或两个 UTF-16 代码单元。比如,下面的表情占两个代码单元:

String face = "😀";
System.out.println(face.length()); // 2
System.out.println(face.codePointCount(0, face.length())); // 1

这个表情由一对代理项表示,也就是两个配合表示一个码点的 UTF-16 代码单元。因此,length() 返回 2,codePointCount() 返回 1。需要遍历码点时,可以使用 codePoints(),避免把代理项拆开。

多个码点还可以组合成一个用户看到的字符,这样的文本单位称为字素簇。因此,截断文本前要明确计数单位:按代码单元、按码点,还是按字素簇处理。

Java 17、21 的 javac 在默认编译目标下,使用 invokedynamic 支持运行时字符串拼接。它让 JVM 在运行时连接具体的拼接实现,所以单个 + 表达式的实现取决于编译目标版本。循环拼接仍要关注反复生成中间字符串的成本。

7. 常见问题​

7.1 String 被 final 修饰就一定不可变吗?​

final 类阻止继承,不保证内部状态不可变。String 还需保护内部表示,且公开操作保持原内容不变。

7.2 final String 与不可变字符串有什么区别?​

7.3 StringBuilder 一定比 + 快吗?​

7.4 String.length() 返回用户看到的字符数吗?​

8 · 面试题卡片​

8.1 String、StringBuilder 和 StringBuffer 有什么区别​

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

考察重点

  • 解释不可变字符串与可变字符序列在修改时有什么差异。
  • 说明 StringBuffer 的同步保证覆盖单次调用还是多步操作。
  • 根据一次性拼接、循环构建或共享缓冲区的需求选择类型。

相关内容:String 的不可变性、StringBuilder 与 StringBuffer、字符串拼接的选择。

参考回答

String 内容不可变,StringBuilder 和 StringBuffer 可变,两种构建器的区别主要在同步策略。

String 产生不同内容时会得到另一个字符串,因此适合保存和共享最终文本。两种构建器则能通过连续 append 复用同一个对象,适合逐步构建。

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

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

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

考察重点

  • 解释不可变性为什么让字符串字面量可以安全共享。
  • 区分 == 比较对象身份与 equals 比较文本内容。
  • 说明 new String(...)、编译期常量和 intern() 怎样影响对象复用。

相关内容:字符串常量池。

参考回答

字符串常量池让相同内容的字面量复用一个对象,减少重复对象。

因为 String 内容不可变,多个使用者可以安全读取同一个字符串。编译期字符串常量拼接也会折叠并共享对应的驻留对象。

new String(...) 则明确创建新对象,即使内容相同,引用也与字面量不同。intern() 返回同内容的规范引用,调用方可以据此复用对象。常量池影响的是对象身份,业务代码比较文本内容仍应使用 equals。

8.3 为什么一个表情的 String.length() 是 2?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分内部存储、UTF-16 代码单元与 Unicode 码点,解释示例为什么有两种长度。

相关内容:字符串存储与字符计数。

参考回答

因为 length() 统计 UTF-16 代码单元,而示例表情需要一对代理项,所以结果是 2。

这两个代码单元共同表示一个 Unicode 码点,因此 codePointCount() 返回 1。Java 17、21 的内部压缩存储保留了这套 UTF-16 索引规则,内部使用 byte[] 也不会改变计数结果。

9. 参考资料​