学完这篇你能分清什么时候该用 String、什么时候必须换 StringBuilder,并且搞明白 == 为什么在字符串上总给出让你意外的结果——这两件事搞清楚了,字符串相关的大部分 bug 和性能坑就都躲开了。
第一步:先接受 String 是不可变的
String 一旦创建,内容就不能改。所有看起来"修改"的方法(concat、replace、substring、+)都是返回一个新对象,原对象纹丝不动。
String a = "hello";
String b = a.concat(" world"); // a 还是 "hello",b 是新对象
这意味着循环里做 s += i 时,每次迭代都会新建一个字符串并复制全部旧内容,复杂度是 O(n²)。一万次拼接就是几千万次字符复制——这是新手最常见的性能事故。
第二步:理解字符串池到底池了谁
JVM 里有一块叫"字符串常量池"的区域(Java 7 之后位于堆中),用来复用内容相同的字符串字面量。规则很简单:
- 字面量(
"abc")编译后就进池,相同内容只存一份;
new String("abc") 强制在堆上新建对象,内容一样但地址不同;
intern() 手动把字符串放进池并返回池中的那个引用。
String a = "abc";
String b = "abc";
String c = new String("abc");
String d = c.intern();
System.out.println(a == b); // true,同一个池对象
System.out.println(a == c); // false,堆上新对象
System.out.println(a == d); // true,intern 拿回池里那个
所以判断字符串内容永远用 equals(),== 比的是引用。这一点没有例外。
注意:new String("abc") 其实创建了两个对象——字面量 "abc" 先进池,然后堆上再复制一份。这也是为什么它几乎总是没必要的写法。
第三步:分清编译期拼接和运行期拼接
+ 的行为取决于编译器能不能在编译期算出结果:
String s1 = "ja" + "va"; // 编译期折叠成 "java",直接进池
final String P = "ja";
String s2 = P + "va"; // final 常量,仍折叠,equals 且 == 都成立
String v = "va";
String s3 = "ja" + v; // 含变量,运行期用 StringBuilder 拼,不入池
System.out.println(s1 == s3); // false
结论:+ 只在"全是常量"时白嫖池,一旦掺了变量就是运行期新建对象。
第四步:循环拼接改写成 StringBuilder
单个表达式里的 a + b + c,javac 会自动转成 StringBuilder,不用你操心。但循环内的拼接编译器优化不了,因为它每次迭代都新建一个 builder。写法要改:
StringBuilder sb = new StringBuilder(1 << 14); // 预估容量,避免扩容
for (int i = 0; i < 10000; i++) {
sb.append(i).append(',');
}
String result = sb.toString();
append 返回 this,可以一路链式调用。默认容量 16,满了按 旧容量 * 2 + 2 扩容并整体复制,所以能预估长度就传进构造函数,省掉多次数组复制。
StringBuilder 的常用手法:
setLength(0) 清空复用,比在循环里反复 new 更省;
reverse()、insert()、deleteCharAt() 都是原地操作,不产生中间对象;
toString() 才真正生成不可变字符串,别在循环里调用它。
注意:StringBuilder 是可变对象,不要把它当方法返回值或在多线程间共享。一旦它"逃逸"出去,别人随时能改你的内容,排查起来非常痛苦。需要交付结果就 toString()。
第五步:StringBuffer 什么时候才用
StringBuffer 的方法都加了 synchronized,线程安全但每次调用有锁开销。实际情况是:绝大多数场景根本不需要跨线程共享一个拼接缓冲区,每个线程用自己的 StringBuilder 就行。只有在确实多个线程往同一个缓冲区写的罕见场景下才选 StringBuffer。
另外两个更容易被忽略的选择:
- 固定分隔符拼接,用
String.join(",", list) 或 StringJoiner,可读性比手写 builder 好;
- 格式化输出,用
String.format 或 formatted(),别用 + 硬拼。
注意:StringBuilder.append(String) 传 null 会写入字面量 "null",不会抛异常。如果你要区分"空串"和"没值",先自己判空。
小结
String 不可变,任何"修改"都产生新对象,循环里 += 是性能陷阱。
- 字面量进字符串池,
new String() 不进,intern() 手动进;判内容用 equals,别用 ==。
+ 只在纯常量时编译期折叠,含变量就是运行期新建。
- 循环拼接用
StringBuilder,构造函数给个预估容量,循环内别调 toString()。
- 单线程用
StringBuilder,StringBuffer 只在真正跨线程共享时才需要。
- 可变对象不要逃逸出方法,交付结果一律
toString()。