【Java杂项】String 为什么不可变?从对象引用、常量池到字符串拼接讲清楚
-
- 前言
- 一、先给结论:不可变的是对象,不是引用变量
- [二、为什么 `final` 不是唯一原因](#二、为什么
final不是唯一原因) - 三、不可变为什么能支撑字符串常量池
- 四、不可变带来的三个直接收益
-
- [4.1 线程安全](#4.1 线程安全)
- [4.2 哈希值可以缓存](#4.2 哈希值可以缓存)
- [4.3 安全边界更稳定](#4.3 安全边界更稳定)
- 五、字符串拼接时到底发生了什么
-
- [5.1 少量拼接可以放心用 `+`](#5.1 少量拼接可以放心用
+) - [5.2 变量参与拼接通常会借助 `StringBuilder`](#5.2 变量参与拼接通常会借助
StringBuilder) - [5.3 循环里频繁拼接要小心](#5.3 循环里频繁拼接要小心)
- [5.1 少量拼接可以放心用 `+`](#5.1 少量拼接可以放心用
- 六、常见误区
- 总结

🎬 博主名称: 超级苦力怕
🔥 个人专栏: 《基本功修炼大全》
🚀 每一次思考都是突破的前奏,每一次复盘都是精进的开始!
文章元信息:
- 适合读者: 学过 Java 基本类型和引用类型,想理解
String设计机制的初学者- 前置知识: 知道变量、对象、引用、
==、equals()和字符串字面量的基本含义
前言
很多 Java 初学者都会听到一句话:
String是不可变的。可是再看代码,String s = "abc"; s = s + "d";明明能让s变成"abcd",这不是变了吗?其实这里真正变化的是引用变量的指向,而不是原来的字符串对象。本文会从对象引用、final的真实作用、字符串常量池、线程安全、哈希缓存和字符串拼接几个角度,把String为什么不可变这件事讲清楚。
一、先给结论:不可变的是对象,不是引用变量
理解 String 不可变,第一步要把引用变量 和字符串对象分开。
text
String 变量可以重新指向别的对象;
String 对象内部表示的字符序列不能被原地修改。
先看一个最常见的例子:
✅ 字符串变量重新赋值示例
java
String s = "abc";
s = s + "d";
System.out.println(s); // abcd
这段代码里,s 的值看起来变了,但并不是原来的 "abc" 对象被改成了 "abcd"。
更接近真实过程的是:
text
1. "abc" 是一个 String 对象;
2. 拼接产生新的 String 对象 "abcd";
3. 变量 s 从指向 "abc",改为指向 "abcd";
4. 原来的 "abc" 对象本身没有被修改。
可以用下面这张表理解:
| 层次 | 是否可变 | 说明 |
|---|---|---|
引用变量 s |
可以变 | 可以重新赋值,指向另一个 String 对象 |
String 对象内容 |
不可变 | 对象创建后,内部字符序列不能被原地修改 |

💡 核心结论: String 的不可变性约束的是对象内容,不是引用变量。变量可以改指向,但旧字符串对象不会被原地修改。
二、为什么 final 不是唯一原因
很多解释会说:String 不可变,是因为 String 类和内部数组用了 final。
这句话只说对了一部分。
在 JDK 8 中,String 的结构可以简化理解成这样:
✅ JDK 8 中 String 结构简化示意
java
public final class String {
private final char[] value;
}
到 Java 9 之后,为了节省内存,String 底层从 char[] 变成了 byte[] 加编码标记,核心思想仍然是:内部数据不向外暴露可修改入口。
✅ Java 9 之后 String 结构简化示意
java
public final class String {
private final byte[] value;
private final byte coder;
}
这里最容易混淆的是:final 并不等于"内容一定不能改"。
| 设计 | 作用 | 是否单独决定不可变 |
|---|---|---|
final class String |
防止子类继承后破坏字符串语义 | 不是唯一原因 |
private final value |
防止引用重新指向别的数组 | 不是唯一原因 |
| 不暴露修改内部数据的方法 | 外部无法原地改字符内容 | 是关键原因之一 |
| 修改类操作返回新对象 | 保证原对象内容稳定 | 是关键原因之一 |
为什么 private final char[] value 不能单独证明不可变?
因为 final 修饰引用类型变量时,只能保证这个引用不能再指向另一个数组,不能保证数组元素本身不能变。
✅ final 数组元素仍可修改示例
java
final char[] value = {'a', 'b', 'c'};
value[0] = 'x';
System.out.println(value); // xbc
所以,String 真正能做到不可变,是几个设计共同配合:
- 类本身是
final,不能被继承后改写语义。 - 内部数据是私有的,外部拿不到可修改的真实存储。
- 没有提供会修改自身内容的方法。
- 看起来像修改的操作,比如
concat()、replace()、substring(),都会返回新的String。
💡 核心结论: final 是 String 不可变设计的一部分,但不是全部;真正关键的是"不暴露可修改入口,并且修改操作返回新对象"。
三、不可变为什么能支撑字符串常量池
字符串常量池的核心目标是复用相同内容的字符串,减少重复对象。
例如:
✅ 字符串字面量复用示例
java
String a = "hello";
String b = "hello";
System.out.println(a == b); // true
这里 a 和 b 可以指向同一个字符串对象,是因为 "hello" 这个对象不会被某个变量偷偷改成别的内容。
假设 String 是可变的,就会出现下面这种危险情况:
text
a 和 b 共享同一个 "hello";
a 把内容改成 "world";
b 看到的内容也变成 "world"。
这会让字符串常量池无法安全共享对象。
所以,不可变性和常量池是互相配合的:
| 机制 | 为什么依赖不可变性 |
|---|---|
| 字符串常量池 | 多个引用可以共享同一个字符串对象,不担心其中一个引用改坏内容 |
| 字面量复用 | 相同字面量可以复用已有对象 |
intern() |
可以返回池中已有对象的引用,保证共享对象语义稳定 |
再补一个内存位置细节:
| 版本 | 字符串常量池位置 |
|---|---|
| JDK 1.6 及以前 | 永久代 |
| JDK 1.7 及以后 | Java 堆 |
现在更重要的理解不是死记位置,而是知道:字符串常量池保存的是字符串到对象引用的映射,引用最终指向具体的 String 对象。

💡 核心结论: 如果 String 可变,字符串常量池就不能安全共享对象;不可变性是池化复用成立的前提。
四、不可变带来的三个直接收益
4.1 线程安全
不可变对象天然适合共享。
多个线程同时读取同一个 String 对象时,不需要担心某个线程把它的内容改掉。
✅ 多线程共享 String 的直觉示例
java
String name = "admin";
只要这个 String 对象已经创建,它内部的字符序列就稳定不变。线程之间共享它时,主要风险来自引用变量本身怎么被赋值,而不是字符串对象内容被并发修改。
4.2 哈希值可以缓存
String 经常作为 HashMap 的 key。
如果字符串内容可变,就会出现一个严重问题:
text
放入 HashMap 时 hash 是 A;
修改字符串内容后 hash 变成 B;
再查找时可能找不到原来的桶。
因为 String 不可变,hashCode() 的结果一旦计算出来,就可以被缓存起来反复使用。
| 场景 | 不可变性的作用 |
|---|---|
HashMap<String, V> |
key 的 hash 稳定 |
HashSet<String> |
去重判断稳定 |
| 缓存结构 | 不担心 key 内容被修改后失效 |
4.3 安全边界更稳定
字符串经常承载文件路径、类名、URL、数据库连接信息、权限标识等内容。
如果字符串可以被原地修改,就可能出现"校验通过后内容又被改掉"的问题。
例如:
text
1. 校验 path 是否允许访问;
2. path 被别的地方原地改成另一个路径;
3. 后续代码继续使用这个 path。
String 不可变能降低这类风险。只要拿到的是某个 String 对象,它的内容就不会在别处被偷偷改掉。
💡 核心结论: String 不可变不是为了"语法好看",而是为了让共享、哈希和安全边界都更稳定。
五、字符串拼接时到底发生了什么
既然 String 不可变,那么拼接字符串时就不能直接改原对象。
5.1 少量拼接可以放心用 +
简单拼接通常可以直接写:
✅ 少量字符串拼接示例
java
String name = "Tom";
String message = "Hello, " + name;
这种代码可读性好,编译器和 JVM 也会做优化,不需要一看到 + 就紧张。
常量之间的拼接甚至可以在编译期直接折叠:
✅ 字符串常量拼接示例
java
String a = "he" + "llo";
String b = "hello";
System.out.println(a == b); // true
这里 "he" + "llo" 在编译期就能确定结果,所以最终等价于 "hello"。
5.2 变量参与拼接通常会借助 StringBuilder
变量参与拼接时,编译器通常会把 + 转成类似 StringBuilder 的 append 过程。
✅ 变量拼接示例
java
String a = "hello";
String b = "world";
String c = a + b;
可以粗略理解成:
java
String c = new StringBuilder()
.append(a)
.append(b)
.toString();
这说明:String 没有被原地修改,拼接结果仍然是新对象。
5.3 循环里频繁拼接要小心
问题主要出现在循环中:
✅ 不推荐的循环拼接示例
java
String result = "";
for (int i = 0; i < 1000; i++) {
result += i;
}
这种写法可能在每轮循环里产生新的中间对象,造成额外内存分配。
更合适的写法是:
✅ 推荐的循环拼接示例
java
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 1000; i++) {
builder.append(i);
}
String result = builder.toString();
StringBuilder 是可变的,它内部维护一个可扩容缓冲区,适合单线程下大量拼接。
如果需要多个线程共享同一个可变字符串缓冲区,再考虑 StringBuffer。
| 场景 | 推荐 |
|---|---|
| 少量拼接、普通表达式 | + |
| 循环内大量拼接 | StringBuilder |
| 多线程共享同一个拼接缓冲区 | StringBuffer |
💡 核心结论: String 不可变意味着拼接会产生新结果;少量拼接用 + 没问题,循环大量拼接优先用 StringBuilder。
六、常见误区
⚠️ 误区:
String变量重新赋值,说明String是可变的正确理解: 变量重新赋值只是引用指向变了,原来的字符串对象没有被原地修改。
⚠️ 误区:final char[] value就能完全保证String不可变正确理解:
final只能保证value这个引用不变,数组元素理论上仍可变。String的不可变性还依赖私有封装、不暴露修改方法、类不可继承等设计。
⚠️ 误区:字符串拼接一定不能用+正确理解: 少量拼接用
+可读性更好;真正需要警惕的是循环中的大量拼接。
总结
| 问题 | 结论 |
|---|---|
String 不可变是什么意思 |
字符串对象内容不可原地修改,引用变量可以重新指向别的对象 |
final 是不是根本原因 |
不是唯一原因,还需要私有封装、无修改入口、返回新对象等设计 |
| 为什么能有字符串常量池 | 因为共享的字符串对象不会被某个引用改坏 |
| 为什么能缓存 hash | 因为字符串内容稳定,hashCode() 结果稳定 |
| 拼接为什么可能有成本 | 原字符串不变,拼接结果通常是新对象 |
什么时候用 StringBuilder |
循环或大量拼接时优先使用 |
这个知识点可以压缩成一句话:String 不可变,是为了让字符串对象可以被安全共享、稳定比较、稳定哈希;看似修改字符串,本质上通常是在创建新对象并改变引用指向。
💡 核心结论: String 不可变并不是单靠 final 实现的,而是类设计、私有存储、不暴露修改入口和返回新对象共同形成的结果。理解这一点之后,字符串常量池、==、equals()、intern()、hash 缓存和拼接性能问题就能串成一条线。
