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 与不可变字符串有什么区别?
final 限制变量重新赋值,不可变性则约束对象内容。比如:
final String name = "Java";
这里的 final 让变量 name 初始化后保持原来的引用。即使变量不声明为 final,它指向的 String 对象仍然不可变。变量能否重新指向别处,与对象内容能否修改是两件事。
7.3 StringBuilder 一定比 + 快吗?
是否更快取决于 JDK、数据量与调用方式。单个表达式用 + 更直观,编译器也能整体优化。循环逐步累积文本时,复用 StringBuilder 则能减少中间字符串和重复复制。
7.4 String.length() 返回用户看到的字符数吗?
length() 返回 UTF-16 代码单元数,用户看到的一个字符可以占多个代码单元。需要按码点计数或遍历时,可以用 codePointCount() 或 codePoints()。
代理项与码点示例见字符串存储与字符计数。
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[] 也不会改变计数结果。