🔤 字符串
String 不可变性 · String/StringBuilder/StringBuffer · 常量池与 intern · 拼接与 == 比较
1. String 为什么是不可变的?不可变有什么好处?
不可变的三重保证:
- final class:String 不能被继承,防止子类篡改行为
- private final char[] value(JDK8;JDK9+ 改为 byte[] 用于紧凑字符串):引用和内容都不可变
- 不暴露修改入口:所有"修改"方法(concat、replace、substring)都返回新对象,不改变自身
好处:
- 线程安全:不可变对象天然安全,无需加锁
- 常量池缓存:值相同的 String 可复用同一对象,节省内存(HashMap key 不被误改,安全性同理)
- 适合做 HashMap 的 key:hashCode 只算一次且永不失效
- 安全性:网络地址、类名、文件路径等场景不被恶意篡改
🎯 面试要点
- JDK9+ String 用 byte[] + coder 字段,Latin-1 字符只占 1 字节(紧凑字符串),内存减半
- 任何看似"修改"的操作都是生成新对象,频繁修改会大量产生垃圾 → 用 StringBuilder
2. String、StringBuilder、StringBuffer 的区别?
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 安全(不可变) | 不安全 | 安全(方法 synchronized) |
| 性能 | 拼接慢(建新对象) | 最快 | 较慢(加锁开销) |
| 适用 | 值固定/做 key | 单线程循环拼接 | 多线程共享拼接(极少) |
StringBuffer 的加锁在现代业务中几乎无用武之地:方法内局部变量天然线程私有,直接用 StringBuilder;真正的跨线程共享用不可变 String 或消息队列。
🎯 面试要点
- 底层实现相同:都是 char[]/byte[],StringBuffer 只是方法上加了 synchronized
- StringBuilder 初始容量 16,append 扩容策略:newCap = old * 2 + 2(超过则取实际长度)
- 循环里拼字符串必须用 StringBuilder,否则每次 + 都生成中间 String
3. 字符串常量池、intern() 与 == 比较(经典面试题)
字符串常量池(JDK7+ 移入堆):保存字符串字面量和 intern 的字符串。相同内容的字面量只存一份。
两种创建方式的区别:
String s1 = "abc":字面量,从常量池取(不存在则创建),只可能有一个对象String s2 = new String("abc"):先在常量池放 "abc"(若没有),再在堆中 new 一个副本,共两个对象s1 == s2→ false(引用不同:常量池对象 vs 堆对象)s1.equals(s2)→ true(内容相同,String 重写了 equals)
intern():把当前字符串内容放入常量池,若已存在则返回池中对象。
运行结果判断
String s1 = "hello";
String s2 = new String("hello");
String s3 = s2.intern();
System.out.println(s1 == s2); // false:常量池 vs 堆
System.out.println(s1 == s3); // true:intern 返回池中对象
// 拼接:字面量 + 字面量 → 编译期折叠,还是常量池对象
String s4 = "he" + "llo";
System.out.println(s1 == s4); // true(编译期常量折叠)
// 拼接:含变量的表达式 → 运行期 new,堆中新建
String s5 = "he" + new String("llo");
System.out.println(s1 == s5); // false
🎯 面试要点
- JDK7 前常量池在方法区(永久代);JDK7 起移入堆中
- 字符串内容比较永远用 equals / Objects.equals,绝不用 ==(除非比较的是 intern 过的对象)
- 大量重复字符串场景用 intern 可去重省内存,但 intern 操作本身有性能开销
4. "+"拼接字符串的性能真相
编译期优化:
- 纯字面量拼接("a" + "b")→ 编译期直接折叠为 "ab"
- 含变量的拼接 → 编译器自动改写为
new StringBuilder().append(...)链式调用 - 但:在循环体内拼接,每次迭代都会 new 一个 StringBuilder → 大量垃圾对象,性能极差。此时必须手动复用 StringBuilder(或 String.join / Stream)
循环拼接的两种写法对比
// ❌ 每轮循环 new StringBuilder,O(n²) 复制开销
String s = "";
for (int i = 0; i < 10000; i++) {
s += i;
}
// ✅ 复用同一个 StringBuilder,O(n)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String s2 = sb.toString();
🎯 面试要点
- JDK9 起 javac 用 invokedynamic(StringConcatFactory)替代了 StringBuilder 拼接,但循环内问题依旧
- 大数据量拼接还可以考虑 String.join()(内部用 StringBuilder)或 Stream.collect(joining())