Java String 为什么是不可变的?
简化版
String 不可变,指的是对象一旦创建,它承载的字符内容就不能再改。这样设计换来四个好处:可以安全地被多线程共享、可以放进字符串常量池复用、hashCode 能缓存、以及作为「key / 路径 / 权限标识」时的安全性。底层保证是 String 内部的字符数组是 private final 的,且不对外暴露修改入口。
详细版
先破一个误解:不可变 ≠ 变量不能重新赋值。
String s = "a";
s = "b"; // 合法!变的是「s 指向哪个对象」,不是把 "a" 这个对象改成了 "b"
s = "b" 只是让变量 s 改指向另一个字符串对象,原来的 "a" 对象一个字都没变。同理,substring、replace、toUpperCase、+ 拼接都是返回新对象,原字符串岿然不动。
为什么非得不可变?因为 Java 里字符串无处不在——类名、文件路径、URL、数据库连接串、Map 的 key。设想它可以变:
- 把一个 String 作为 key 放进
HashMap后,内容被改 → hashCode 变了 → 这个 key 再也找不回来; - 权限校验时先检查路径字符串合法,校验通过后别的引用把它改掉 → 安全漏洞(TOCTOU)。
不可变还让共享变安全:常量池里相同字面量只存一份、多个引用复用;同一个 String 被多线程读也不用加锁。此外 String 会缓存 hashCode(首次计算后存起来),作哈希 key 反复查询时不必重算。
完整版教学
一、不可变是怎么实现的
String 的字符数据存在一个 private final byte[] value(JDK 9 前是 char[])里。三道锁保证外界改不了:
- 字段
private——外部拿不到这个数组的引用; - 字段
final——value引用本身不能被重新指向别的数组; String没有任何修改内容的方法,所有「修改」类方法都返回新String;String类本身是final——不能被继承,防止子类破坏不可变性。
注意:
final只保证「value这个引用不变」,不直接保证数组内容不变。真正的不可变靠「不暴露引用 + 不提供改法」共同实现,final只是其中一环。
二、字符串常量池:不可变才敢共享
String a = "hello";
String b = "hello";
System.out.println(a == b); // true,指向常量池里同一个对象
编译期的字符串字面量会进入常量池,相同内容只存一份、被所有引用复用,省内存。正因为 String 不可变,共享才安全——否则 a 改了内容,b 莫名其妙跟着变,天下大乱。
而 new String("hello") 会在堆上强制新建一个对象,== 就为 false 了。想手动入池可以调 intern()。
三、性能坑:循环里用 + 拼接
// ❌ 反例:每次 + 都 new 一个新 String + 新 StringBuilder,产生大量临时对象
String r = "";
for (String name : names) r = r + name + ",";
因为不可变,每次 + 都要生成新对象。编译器虽然会把单条 a + b + c 优化成一个 StringBuilder,但循环里每轮都会重新创建,累积起来非常浪费。正确写法是复用一个 StringBuilder:
StringBuilder sb = new StringBuilder();
for (String name : names) sb.append(name).append(',');
return sb.toString();
StringBuilder(非线程安全,快)用于单线程拼接;StringBuffer(方法加 synchronized,慢)只在确实需要多线程共享同一个可变缓冲区时才用——这种场景其实很少。
四、hashCode 缓存与安全边界
String 经常作为 HashMap 的键。不可变保证对象放入桶后内容和哈希语义不再变化,因此可以安全缓存 hashCode;若可变字符串从 "user:42" 改成 "user:43",节点仍待在旧桶中,后续查找会失效。不可变把“计算一次、反复复用”从危险优化变成可靠优化。
安全收益来自同一原则。设想方法先校验路径 /safe/report.txt,随后把它交给文件 API;如果另一个持有相同对象的组件能在两步之间改成 /secret/key,就会出现检查时与使用时不一致。String 内容不可变,调用方拿到引用后无法悄悄改变已经验证过的值。
可变输入:检查 /safe/a ──被修改──> 使用 /secret/key (危险)
不可变 String:检查 /safe/a ────────> 使用 /safe/a (内容稳定)
| 不可变特性 | 直接收益 |
|---|---|
| 内容不会变化 | 适合作哈希键,桶位置稳定 |
| 可安全共享 | 常量池复用无需防御性复制 |
| 状态稳定 | hashCode 可以缓存 |
| 校验后不能被同对象篡改 | 降低安全检查与使用之间的风险 |
五、拼接为什么可能退化为平方级
若循环每轮把长度为 i 的旧字符串复制到新字符串,拼接 N 个单字符的总复制量约为 1 + 2 + ... + N = N(N+1)/2。N=10,000 时约需复制 50,005,000 个字符,呈 O(N²) 增长;预估容量的 StringBuilder 通常只需线性追加,接近 O(N)。
第1轮复制 1 个字符
第2轮复制 2 个字符
...
第10000轮复制 10000 个字符
总量 = 10000 × 10001 ÷ 2 = 50,005,000
编译器能优化单个表达式中的连续拼接,现代 JDK 也会使用更高效的拼接策略,但无法把跨循环迭代的旧结果自动变成一个长期复用的 builder。已知最终规模时给 StringBuilder 设置初始容量,还能减少内部数组扩容和复制。
六、常见误区与追问
- 误区:String 不可变意味着变量不能重新赋值。 变量可以指向新字符串,旧对象内容没有变化。
- 误区:
final String s与 String 不可变是一回事。 前者限制变量重绑,后者是类型本身不提供内容修改能力。 - 误区:任何
+都性能差。 常量表达式可在编译期折叠,单条拼接也会被优化;问题集中在热点循环和大量累计。 - 追问:
substring()是否一定复制底层数组? 现代 JDK 会创建只包含结果内容的新存储;早期实现曾共享原数组,行为和内存特征不同。 - 追问:
intern()做什么? 它返回字符串池中该内容的规范引用,但滥用会增加池管理和内存压力,不能当普通去重工具无脑使用。 - 追问:自定义不可变类还要做什么? 字段私有且 final、构造时完成校验、对可变入参和返回值做防御性复制,并避免子类破坏约束。
记忆钩子:不可变不是“变量钉死”,而是“拿到对象的任何人都改不了它的内容”,所以共享和缓存才可信。
七、加强记忆
String 通过隐藏内部存储、不暴露修改入口并禁止子类破坏语义,使字符内容在创建后稳定不变。稳定内容支撑了常量池共享、hashCode 缓存、并发只读和安全校验,也让 String 成为可靠的键;代价是每次逻辑修改都产生新结果。少量表达式直接拼接即可,大规模累计则用 StringBuilder 把平方级重复复制降到近似线性,这样才能同时讲清设计收益和性能成本。