String/StringBuilder/StringBuffer:我该怎么选?
写 Java 代码的时候,字符串操作太常见了。拼接字符串、替换字符、格式化输出...这三个类你每天都在用,但你真的理解它们之间的区别吗?
我见过太多候选人在面试中说"StringBuilder 比 String 快,因为 String 是不可变的"。追问一句"为什么 String 设计成不可变的",很多人就答不上来了。还有人问"StringBuffer 是线程安全的,那它用在什么场景",答不上来。
今天我们就来把这个知识点彻底讲透。
一、真实面试场景
候选人小张在面试某大厂时,被问到这样一个问题:
"我们在日志拼接的时候,写了这样的代码:"
String log = "";
for (int i = 0; i < 1000; i++) {
log += "第" + i + "条日志";
}
"这段代码有什么性能问题吗?"
小张说:"String 的拼接每次都会创建新对象,影响性能。应该用 StringBuilder。"
面试官点点头,继续追问:"那为什么 String 要设计成不可变的?StringBuilder 和 StringBuffer 的区别是什么?"
小张说:"StringBuffer 是线程安全的,StringBuilder 不是..."
面试官追问:"那 String 是线程安全的吗?它为什么是线程安全的?"
小张开始支支吾吾。
【面试官心理】
我问他这个,不是想听他背结论。我是想知道:有没有理解 String 不可变的真正原因、能不能说出安全性/缓存/类加载器之间的关系、能不能在实际场景中做出正确的选型。知道"是什么"和知道"为什么",是两码事。
二、String:不可变的本质
2.1 String 真的不可变吗?
看一段源码:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
// String 内部存储字符的数组
private final char value[];
// ...
}
String 类被 final 修饰,所以不能被继承。内部存储字符的数组 value 也被 final 修饰,而且没有提供公开的修改方法。所以 String 一旦创建,内容就不能改变。
2.2 ❌ 错误示范:以为 String 可以修改
String s = "hello";
s = "world"; // 这不是修改 String,这是让 s 指向新的 String 对象
+= 操作实际上是这样实现的:
String s = "hello";
s += "world";
// 实际等价于:
s = new StringBuilder().append(s).append("world").toString();
每次 += 都会创建新的 String 对象,旧的 String 被回收。如果循环中频繁拼接,性能会急剧下降。
2.3 为什么 String 要设计成不可变的?
String 设计成不可变,有五个核心原因:
1. 安全性(Security)
字符串广泛应用于网络连接、文件路径、数据库连接等场景。如果 String 可变,可能会导致:
- 连接字符串被恶意修改
- 反射修改 String 导致安全漏洞
- 类加载器加载的类名被篡改
// 数据库连接示例
String url = "jdbc:mysql://localhost:3306/test";
// 如果 url 可变,攻击者可能修改它连接到恶意数据库
2. 字符串常量池(Caching)
不可变才能实现字符串常量池。当创建一个 String 字面量时,JVM 会在常量池中查找是否有相同的字符串,如果有就返回引用,而不是创建新对象。
String s1 = "hello";
String s2 = "hello";
// s1 和 s2 指向常量池中的同一个对象,节省内存
如果 String 可变,修改 s1 就会影响到 s2,字符串常量池就无法工作了。
3. 线程安全(Thread Safety)
不可变的对象天然线程安全。因为状态不能改变,所以不需要任何同步措施。
// String 可以被多个线程安全地共享
public void process(String data) {
// 不需要同步,因为 String 不可变
}
4. 类加载器信任(ClassLoader Trust)
类加载器在加载类时使用 String 作为类名、字段名等。如果 String 可变,可能导致类加载安全问题。
5. 哈希计算(H比如 HashMap 的 key)
String 经常作为 HashMap 的 key,因为它的 hashCode 可以被缓存。如果 String 可变,hashCode 就会变化,导致哈希表失效。
String key = "user:1001";
Map<String, Object> cache = new HashMap<>();
cache.put(key, userData);
// key 的 hashCode 被缓存了,如果 key 变了,get 不到数据
💡
String 的不可变性是 Java 设计中的经典权衡。通过牺牲可变性,换来了安全性、缓存优势、线程安全性和可靠的哈希行为。这是一个典型的"用约束换自由"的例子。
三、StringBuilder:性能优先
3.1 为什么 StringBuilder 更快?
StringBuilder 不是不可变的,它内部维护了一个可变的字符数组:
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence {
// 继承自 AbstractStringBuilder,可变数组
}
abstract class AbstractStringBuilder {
char[] value; // 可变的字符数组
int count; // 当前字符数量
}
当调用 append() 方法时,StringBuilder 直接往数组里加字符,不需要创建新对象:
public StringBuilder append(String str) {
if (str == null) {
appendNull();
} else {
// 扩容并复制
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(srcBegin, srcEnd, value, count);
count += len;
}
return this;
}
3.2 【直观类比】StringBuilder vs String
想象你在建一堵墙:
- String:每放一块砖都要拆掉重建
- StringBuilder:直接在现有墙上加砖
// String 的拼接:每次都创建新对象
String s = "";
s += "a"; // 创建新 String
s += "b"; // 创建新 String
s += "c"; // 创建新 String
// StringBuilder:直接追加
StringBuilder sb = new StringBuilder();
sb.append("a"); // 直接加
sb.append("b"); // 直接加
sb.append("c"); // 直接加
3.3 扩容机制
StringBuilder 内部使用动态数组,当容量不够时会自动扩容:
private void ensureCapacityInternal(int minimumCapacity) {
// 如果需要的容量大于当前容量,扩容
if (minimumCapacity - value.length > 0) {
// 扩容为原来的 2 倍 + 2
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));
}
}
private int newCapacity(int minCapacity) {
int newCapacity = (value.length << 1) + 2; // 2 倍 + 2
if (newCapacity < 0) {
newCapacity = Integer.MAX_VALUE;
}
return (minCapacity <= 0) ? minCapacity : newCapacity;
}
默认初始容量是 16,每次扩容 2 倍 + 2。如果能预估容量,可以在构造时传入,避免频繁扩容:
// 预估容量,避免扩容开销
StringBuilder sb = new StringBuilder(1024);
四、StringBuffer:线程安全
4.1 StringBuffer 如何保证线程安全?
StringBuffer 的核心区别是:所有关键方法都加了 synchronized 关键字。
@Override
public synchronized StringBuffer append(String str) {
if (str == null) {
appendNull();
} else {
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(srcBegin, srcEnd, value, count);
count += len;
}
return this;
}
@Override
public synchronized String toString() {
return new String(value, 0, count);
}
append() 方法是 synchronized 的,同一时刻只有一个线程能执行,保证了线程安全。
4.2 性能代价
synchronized 的代价是:
- 加锁/解锁开销:每次调用都要获取和释放锁
- 阻塞等待:多线程并发时,可能需要等待
- 性能下降:比 StringBuilder 慢很多
4.3 使用场景
五、三者对比总结
// 场景1:字符串常量,用 String
String name = "张三";
// 场景2:单线程循环拼接,用 StringBuilder
StringBuilder sb = new StringBuilder();
for (Order order : orders) {
sb.append(order.getId()).append(",");
}
// 场景3:多线程共享,用 StringBuffer
class LogBuffer {
private StringBuffer buffer = new StringBuffer();
public synchronized void append(String log) {
buffer.append(log);
}
public synchronized String getLogs() {
return buffer.toString();
}
}
六、生产场景与避坑
6.1 ❌ 错误示范:循环内使用 String 拼接
// 性能极差,禁止这样做
String result = "";
for (String item : items) {
result += item + ","; // 每次都创建新 String 对象
}
正确做法:
// 使用 StringBuilder
StringBuilder sb = new StringBuilder();
for (String item : items) {
sb.append(item).append(",");
}
String result = sb.toString();
// 或者 JDK 8 的 String.join
String result = String.join(",", items);
// 或者 JDK 11+ 的 String.join
String result = items.stream().collect(Collectors.joining(","));
6.2 ❌ 错误示范:在高并发场景使用 StringBuilder
// 线程不安全的场景
class SharedLogger {
private StringBuilder logs = new StringBuilder(); // 危险!
public void log(String message) {
logs.append(message).append("\n"); // 多线程并发会有问题
}
}
正确做法:
class SharedLogger {
private StringBuffer logs = new StringBuffer(); // 线程安全
public synchronized void log(String message) {
logs.append(message).append("\n");
}
}
或者更好的是使用其他并发方案:
class SharedLogger {
private ConcurrentLinkedQueue<String> logs = new ConcurrentLinkedQueue<>();
public void log(String message) {
logs.offer(message);
}
}
6.3 ✅ 正确示范:预分配容量
// 预估容量,减少扩容
public String formatOrders(List<Order> orders) {
// 假设每个订单平均 100 字符,预估总容量
StringBuilder sb = new StringBuilder(orders.size() * 150);
for (Order order : orders) {
sb.append("订单号:").append(order.getId())
.append(", 金额:").append(order.getAmount())
.append("\n");
}
return sb.toString();
}
6.4 String 的 intern 方法
String 还有一个 intern() 方法,可以手动将字符串放入常量池:
String s1 = new String("hello"); // 堆中对象
String s2 = s1.intern(); // 将 s1 的内容放入常量池,返回常量池中的引用
String s3 = "hello"; // 直接使用常量池中的字符串
System.out.println(s1 == s2); // false
System.out.println(s2 == s3); // true
当需要大量重复字符串时,手动调用 intern() 可以节省内存。但要注意 intern 有副作用:常量池大小受限于堆内存。
⚠️
频繁调用 intern() 而不控制常量池大小,可能导致 OutOfMemoryError: Metaspace(Java 8+)或 PermGen space(Java 7)。使用时要谨慎。
七、面试追问链
第一层:基础概念
面试官问:"String 和 StringBuilder 的区别是什么?"
标准回答:String 是不可变的,每次拼接都会创建新对象;StringBuilder 是可变的,直接在内部数组上操作,性能更好。
第二层:原理深入
面试官追问:"为什么 String 要设计成不可变的?"
需要说出:安全性(防止被恶意修改)、字符串常量池(不可变才能缓存)、线程安全(不可变对象天然线程安全)、哈希缓存(HashMap key 的 hashCode 可以缓存)。
第三层:线程安全
面试官追问:"StringBuilder 和 StringBuffer 的区别是什么?"
标准回答:StringBuffer 的方法都加了 synchronized,所以是线程安全的;StringBuilder 没有 synchronized,所以性能更好,但线程不安全。
第四层:选型场景
面试官追问:"那如果我想在多线程环境下拼接字符串,该怎么选型?"
可以回答:如果是单线程内拼接,用 StringBuilder;如果是多线程共享同一个缓冲区,用 StringBuffer;如果并发量很大,考虑使用 ConcurrentLinkedQueue 或其他并发结构。
【面试官心理】
问这个问题,我最想听到的是候选人能从"不可变性"讲到"线程安全"再到"实际选型"。只背结论的候选人会说"StringBuilder 快";真正理解的候选人会解释为什么 String 设计成不可变,以及在不同场景下如何选择。
【学习小结】
- String 不可变:安全性、字符串常量池、线程安全、哈希缓存
- StringBuilder:单线程首选,性能最好
- StringBuffer:多线程共享,线程安全但有 synchronized 开销
- 循环内拼接不要用 String,会频繁创建对象
- 预分配容量可以避免 StringBuilder 扩容开销