← 返回题目列表

Java String 为什么是不可变的?

高频 中等 第 17 / 32 题 更新于 2026/07/25
StringJava 基础不可变对象

简化版

String 不可变,指的是对象一旦创建,它承载的字符内容就不能再改。这样设计换来四个好处:可以安全地被多线程共享、可以放进字符串常量池复用、hashCode 能缓存、以及作为「key / 路径 / 权限标识」时的安全性。底层保证是 String 内部的字符数组是 private final 的,且不对外暴露修改入口。

详细版

先破一个误解:不可变 ≠ 变量不能重新赋值

String s = "a";
s = "b";   // 合法!变的是「s 指向哪个对象」,不是把 "a" 这个对象改成了 "b"

s = "b" 只是让变量 s 改指向另一个字符串对象,原来的 "a" 对象一个字都没变。同理,substringreplacetoUpperCase+ 拼接都是返回新对象,原字符串岿然不动。

为什么非得不可变?因为 Java 里字符串无处不在——类名、文件路径、URL、数据库连接串、Map 的 key。设想它可以变:

  • 把一个 String 作为 key 放进 HashMap 后,内容被改 → hashCode 变了 → 这个 key 再也找不回来;
  • 权限校验时先检查路径字符串合法,校验通过后别的引用把它改掉 → 安全漏洞(TOCTOU)。

不可变还让共享变安全:常量池里相同字面量只存一份、多个引用复用;同一个 String 被多线程读也不用加锁。此外 String缓存 hashCode(首次计算后存起来),作哈希 key 反复查询时不必重算。

完整版教学

一、不可变是怎么实现的

String 的字符数据存在一个 private final byte[] value(JDK 9 前是 char[])里。三道锁保证外界改不了:

  1. 字段 private——外部拿不到这个数组的引用;
  2. 字段 final——value 引用本身不能被重新指向别的数组;
  3. String 没有任何修改内容的方法,所有「修改」类方法都返回 String
  4. 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 把平方级重复复制降到近似线性,这样才能同时讲清设计收益和性能成本。