【Java八股|第2篇】String 为什么是不可变的?
前言
上一篇我们学习了 == 和 equals 的区别,其中反复提到了 String。
在 Java 面试中,String 是一个非常高频的知识点。
面试官可能会问:
String为什么是不可变的?String不可变体现在哪里?String不可变有什么好处?StringBuilder为什么可以修改?- 字符串拼接会不会改变原来的字符串?
这一篇我们就来单独梳理 String 不可变这个问题。
一、什么叫 String 不可变?
所谓 String 不可变,意思是:
一个
String对象一旦创建,它所表示的字符串内容就不能被修改。
比如:
public class Test {
public static void main(String[] args) {
String str = "hello";
str = str + " world";
System.out.println(str);
}
}
输出结果:
hello world
看起来好像 str 从 "hello" 变成了 "hello world"。
但实际上,原来的 "hello" 字符串对象并没有被修改。
真正发生的是:
1. 创建字符串对象 "hello"
2. 拼接后创建新的字符串对象 "hello world"
3. 让 str 指向新的字符串对象
4. 原来的 "hello" 对象内容没有改变
也就是说,变的是变量 str 的引用指向,不是原来字符串对象的内容。
二、用代码理解不可变
看下面这个例子:
public class Test {
public static void main(String[] args) {
String s1 = "hello";
String s2 = s1;
s1 = s1 + " world";
System.out.println(s1);
System.out.println(s2);
}
}
输出结果:
hello world
hello
解释一下:
一开始:
String s1 = "hello";
String s2 = s1;
s1 和 s2 都指向 "hello"。
后来:
s1 = s1 + " world";
这行代码创建了一个新的字符串 "hello world",然后让 s1 指向新对象。
但是 s2 仍然指向原来的 "hello"。
所以最后输出:
s1 = hello world
s2 = hello
这就说明原来的字符串对象没有被修改。
三、String 类本身是 final 的
String 类是 final 修饰的。
可以简单理解为:
public final class String {
// 省略源码
}
final 修饰类时,表示这个类不能被继承。
也就是说,我们不能写一个类继承 String:
public class MyString extends String {
}
这是不允许的。
为什么不允许继承?
因为如果允许继承,子类就可能破坏 String 原本的不可变设计。
所以 String 被设计成 final,从类继承层面防止被扩展和篡改。
四、String 底层保存字符的数组不能随便改
在不同 Java 版本中,String 的底层实现略有不同。
在 Java 8 中,String 底层主要使用 char[] 保存字符。
在 Java 9 之后,String 底层改成了 byte[] 加编码标记的方式,用来节省内存。
但是不管底层是 char[] 还是 byte[],核心思想都一样:
保存字符串内容的字段是私有的,并且不会对外提供直接修改内容的方法。
可以简单理解为:
public final class String {
private final char[] value;
}
这里有几个重点:
private:外部不能直接访问final:引用不能随便改变- 没有提供修改原内容的方法
所以普通代码不能直接修改 String 对象内部保存的字符数据。
五、String 没有提供修改原对象的方法
比如我们调用:
String s = "hello";
String result = s.replace("h", "H");
System.out.println(s);
System.out.println(result);
输出结果:
hello
Hello
可以看到:
s.replace("h", "H")
并没有修改原来的 s。
它返回的是一个新的字符串。
类似的方法还有:
substring()
toUpperCase()
toLowerCase()
concat()
replace()
这些方法看起来像是在修改字符串,其实都是返回新字符串。
比如:
String s = "java";
String upper = s.toUpperCase();
System.out.println(s);
System.out.println(upper);
输出结果:
java
JAVA
原来的 java 没有改变。
六、String 不可变和字符串常量池有关
String 不可变的一个重要好处是:可以安全地使用字符串常量池。
比如:
String s1 = "hello";
String s2 = "hello";
s1 和 s2 可能指向字符串常量池中的同一个 "hello" 对象。
如果 String 是可变的,那么就会出现一个严重问题。
假设 s1 修改了 "hello" 的内容:
hello -> hi
那么 s2 指向的内容也会跟着变化。
这显然是不安全的。
正因为 String 不可变,所以多个引用可以安全地共享同一个字符串对象。
这也是字符串常量池能够存在的重要原因。
七、String 不可变有什么好处?
1. 安全性更高
字符串经常用于保存重要信息,比如:
- 文件路径
- 数据库连接地址
- 用户名
- 网络请求地址
- 类名
- 配置项
- Token 的一部分
如果字符串可以随便被修改,就可能带来安全问题。
比如方法接收到一个数据库连接字符串,如果中途被其他地方修改了,就可能连接到错误地址。
String 不可变可以避免这种问题。
2. 可以安全共享
因为字符串不可变,所以多个变量可以安全地引用同一个字符串对象。
比如:
String s1 = "java";
String s2 = "java";
String s3 = "java";
它们都可以指向常量池中的同一个对象。
这样可以减少重复对象创建,节省内存。
3. 适合作为 HashMap 的 key
String 经常作为 HashMap 的 key。
比如:
Map<String, String> map = new HashMap<>();
map.put("username", "zhangsan");
map.put("password", "123456");
如果 String 是可变的,那么作为 key 放入 HashMap 后,内容一旦改变,hash 值也可能改变。
这样会导致原来的 key 找不到。
因为 String 不可变,所以它作为 key 更稳定。
4. hash 值可以缓存
String 的 hashCode 计算比较常用。
因为 String 不可变,所以它的 hash 值一旦计算出来,就可以缓存起来。
下次再调用 hashCode() 时,就不用重复计算。
如果字符串内容可以变化,那么缓存 hash 值就不安全了。
5. 线程安全
不可变对象天然更容易保证线程安全。
多个线程同时读取同一个 String 对象,不会出现一个线程修改内容影响另一个线程的问题。
所以 String 在多线程环境下可以安全共享。
八、String 拼接到底发生了什么?
看下面的代码:
String s = "hello";
s += " world";
很多初学者会以为这是在原来的 hello 后面追加内容。
其实不是。
它大致相当于创建了一个新的字符串对象:
hello world
然后让变量 s 指向这个新对象。
原来的 "hello" 对象没有改变。
如果在循环中大量拼接字符串:
String result = "";
for (int i = 0; i < 10000; i++) {
result += i;
}
这种写法性能不太好。
因为每次拼接都会产生新的字符串对象。
更推荐使用:
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 10000; i++) {
builder.append(i);
}
String result = builder.toString();
这就是为什么后面要学习 StringBuilder。
九、StringBuilder 为什么可以修改?
StringBuilder 是可变的。
比如:
StringBuilder builder = new StringBuilder("hello");
builder.append(" world");
System.out.println(builder);
输出结果:
hello world
这里的 append 会在原来的 StringBuilder 对象上追加内容。
所以 StringBuilder 适合大量字符串拼接。
但是它和 String 的定位不一样。
| 类型 | 是否可变 | 常见用途 |
|---|---|---|
String |
不可变 | 保存字符串常量、普通字符串 |
StringBuilder |
可变 | 单线程大量字符串拼接 |
StringBuffer |
可变 | 多线程环境下字符串拼接 |
后面我们会专门写一篇讲它们的区别。
十、final 一定能保证对象不可变吗?
不一定。
这是一个容易混淆的点。
比如:
final int[] arr = {1, 2, 3};
arr[0] = 100;
System.out.println(arr[0]);
输出结果:
100
虽然 arr 被 final 修饰,但是数组内容仍然可以修改。
final 修饰引用类型变量时,表示这个引用不能再指向其他对象,但不代表对象内部内容一定不能变。
所以 String 不可变不是只靠一个 final 实现的,而是多个设计共同保证的:
String类是final的- 内部保存数据的字段是私有的
- 不对外暴露修改内部数据的方法
- 修改类方法时返回新字符串
- 底层实现避免外部直接修改内部内容
十一、面试标准回答
如果面试官问:
String为什么是不可变的?
可以这样回答:
String 是不可变对象,一个字符串对象创建之后,它的内容就不能再被修改。
从设计上看,String 类被 final 修饰,不能被继承;内部保存字符数据的字段是私有的,外部不能直接访问;并且 String 没有提供修改原对象内容的方法。像 replace、substring、toUpperCase 这些方法,看起来像修改字符串,实际上都是返回新的字符串对象。
String 设计成不可变有很多好处,比如可以安全使用字符串常量池,多个变量可以共享同一个字符串对象;可以提高安全性;适合作为 HashMap 的 key;hash 值可以缓存;在多线程环境下也更安全。
十二、常见问题总结
1. String 真的完全不能被改吗?
在正常 Java 代码中,不能修改。
虽然通过反射等非常规方式可能破坏内部结构,但这不是正常开发方式,面试和项目中一般不讨论这种写法。
2. s = s + “abc” 是不是修改了原字符串?
不是。
这是创建了新的字符串对象,然后让变量 s 指向新对象。
原来的字符串对象没有被修改。
3. replace 方法会修改原字符串吗?
不会。
replace 会返回一个新的字符串。
比如:
String s = "hello";
String r = s.replace("h", "H");
s 仍然是 "hello",r 是 "Hello"。
4. String 不可变是不是只因为 final?
不是。
final 只是其中一个原因。
真正的不可变是由类设计、字段封装、没有修改方法、返回新对象等多个因素共同保证的。
5. 为什么大量拼接字符串不建议用 String?
因为 String 不可变,每次拼接都可能产生新对象。
大量拼接时更建议使用 StringBuilder。
十三、总结
这一篇主要学习了 String 为什么是不可变的。
String 不可变的意思是:字符串对象创建之后,它表示的内容不能被修改。变量可以重新指向新的字符串对象,但原来的字符串对象本身不会变。
String 的不可变设计和 final 类、私有字段、没有修改原内容的方法、返回新对象等机制有关。
它的好处也很明显:可以安全使用字符串常量池,可以作为稳定的 HashMap key,可以缓存 hash 值,也更适合多线程共享。
理解这一点之后,再看 String 拼接、字符串常量池、StringBuilder、HashMap key 等问题,就会清楚很多。
下一篇我们继续学习 String、StringBuilder、StringBuffer 的区别。
更多推荐


所有评论(0)