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 的代价是:

  1. 加锁/解锁开销:每次调用都要获取和释放锁
  2. 阻塞等待:多线程并发时,可能需要等待
  3. 性能下降:比 StringBuilder 慢很多

4.3 使用场景

场景推荐选择
单线程字符串拼接StringBuilder
多线程共享的字符串StringBuffer
字符串常量String
循环内拼接StringBuilder(避免 String)

五、三者对比总结

维度StringStringBuilderStringBuffer
可变性不可变可变可变
线程安全安全(不可变)不安全安全(synchronized)
性能拼接最差最快较慢
默认容量1616
使用场景字符串常量单线程拼接多线程共享
// 场景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 扩容开销