为什么 Java 的 String 类被设计成不可变的?
学完这篇你能得到:能用代码和源码验证 Java 的 String 不可变,并说清它为什么这样设计、什么时候该改用 StringBuilder。
第一步:先确认 String 真的不可变
这一步要做什么:写一段最小测试代码,做完得到的结果是:你能看到“修改字符串”的方法返回新对象,而不是改变原对象。
入口位置:安装 JDK 17 或更高版本,打开终端。新建目录和文件:
mkdir string-immutability
cd string-immutability
创建 StringImmutableDemo.java:
public class StringImmutableDemo {
public static void main(String[] args) {
String a = "hello";
String b = a.toUpperCase();
System.out.println(a);
System.out.println(b);
System.out.println(a == b);
System.out.println(System.identityHashCode(a));
System.out.println(System.identityHashCode(b));
}
}
编译并运行:
javac StringImmutableDemo.java
java StringImmutableDemo
输出类似:
hello
HELLO
false
12345678
87654321
这说明 a 仍然是 hello,toUpperCase() 返回了新的 String 对象。
注意:这里用
==只是为了观察对象地址,证明a和b不是同一个对象。业务代码比较字符串内容要用equals(),不要用==。
第二步:打开 String 源码看关键设计
这一步要做什么:查看 JDK 里的 java.lang.String 源码,做完得到的结果是:你能看到不可变的核心实现方式:final 类、final 字段、修改方法返回新对象。
入口位置:在 IntelliJ IDEA 2023.2 中,按 Ctrl + N(macOS 是 Cmd + O),输入 java.lang.String,选择当前项目 SDK,例如 17 或 21,回车打开源码。
源码里你会看到类似结构:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
private final byte[] value;
}
String 类被 final 修饰,表示不能被继承。value 字段被 private final 修饰,表示对象创建后不能重新指向另一个数组。toUpperCase()、substring()、trim() 这类方法不会修改 value,而是创建新的 String 返回。
注意:Java 8 里
String内部是char[],Java 9 以后改成byte[]加coder来节省内存,但“不可变”的设计思想没有变。
第三步:理解常量池和 hashCode 缓存
这一步要做什么:观察字符串字面量和 hashCode(),做完得到的结果是:你能理解为什么不可变 String 适合放进 HashMap,也能理解字符串常量池为什么能共享对象。
在同一目录创建 StringPoolDemo.java:
public class StringPoolDemo {
public static void main(String[] args) {
String x = "java";
String y = "java";
String z = new String("java");
System.out.println(x == y);
System.out.println(x == z);
System.out.println(x.equals(z));
System.out.println(x.hashCode());
}
}
运行:
javac StringPoolDemo.java
java StringPoolDemo
输出类似:
true
false
true
101450093
x == y 是 true,因为两个字符串字面量 "java" 指向常量池中的同一个对象。String z = new String("java") 会在堆上创建新对象,所以 x == z 是 false,但 x.equals(z) 是 true。
不可变让 String 可以安全缓存 hashCode()。第一次调用 hashCode() 后,结果可以存在对象里;以后再次调用不用重新计算。如果字符串内容能被改变,hashCode() 就会失效,放进 HashMap、HashSet 后会非常危险。
注意:
intern()可以把字符串放入常量池,但不要为了省内存到处调用。大量字符串intern()可能增加 GC 压力。
第四步:理解线程安全和参数传递
这一步要做什么:验证 String 作为方法参数时不会被外部修改,做完得到的结果是:你能理解不可变对象为什么适合多线程共享。
创建 StringThreadDemo.java:
public class StringThreadDemo {
static void change(String s) {
s = s + "-suffix";
}
public static void main(String[] args) {
String a = "base";
change(a);
System.out.println(a);
}
}
运行:
javac StringThreadDemo.java
java StringThreadDemo
输出:
base
change() 方法里的 s 只是局部变量。Java 参数传递是“传值”,对引用类型来说,传的是对象引用的副本。因为 String 不可变,外部拿到引用也没法改变原对象内容。
注意:不可变不是线程安全的唯一原因,但它是
String可以安全共享的重要原因。日志、配置、URL、文件名、数据库连接参数这类地方,通常不希望被中途偷偷改掉。
第五步:知道不可变的代价和替代方案
这一步要做什么:对比循环拼接字符串的性能,做完得到的结果是:你能知道高频修改字符串时不要继续用 String,要改用 StringBuilder。
创建 StringBuilderDemo.java:
public class StringBuilderDemo {
public static void main(String[] args) {
String s = "";
for (int i = 0; i < 100000; i++) {
s += i;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append(i);
}
System.out.println(s.length());
System.out.println(sb.length());
}
}
运行:
javac StringBuilderDemo.java
java StringBuilderDemo
这里重点是:String 不可变,每次 s += i 都可能产生新的字符串对象。数据量小的时候不明显,循环十万次、百万次时,GC 压力和耗时都会变差。StringBuilder 内部是可变的 byte[] 或 char[],追加时通常只修改缓冲区。
注意:Java 编译器会对简单字符串拼接做一定优化,但不能依赖编译器解决所有性能问题。需要频繁拼接时,直接写
new StringBuilder()或new StringBuffer();多线程共享可变缓冲区时再考虑StringBuffer,一般局部变量用StringBuilder就够了。
小结
String不可变体现在:String是final类,内部数组字段是final,修改方法返回新对象。- 不可变带来好处:线程安全、适合做
HashMapkey、可以缓存hashCode()、可以安全共享字符串常量池。 - 不可变带来代价:频繁修改字符串会产生大量临时对象。
- 实际选择:固定文本、配置、URL、文件名用
String;循环拼接、动态文本用StringBuilder。
原文链接:https://www.gj0.com/thread-1622.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。