StringBuffer:线程安全的可变字符串
本章定位:
StringBuffer是StringBuilder的线程安全版本,所有公开方法都用synchronized修饰。当多个线程需要操作同一个字符串缓冲区时(如日志收集器、共享缓冲区),它是必需的选择。
孔蓝的并发测试
飞翔科技的日志收集模块在生产环境偶尔出现日志丢失。孔蓝(测试工程师)写了一个并发测试来复现:
// 原来的代码:使用 StringBuilder 作为全局日志缓冲
public class Logger {
private static StringBuilder logBuffer = new StringBuilder();
public static void log(String msg) {
logBuffer.append(msg).append("\n"); // 多线程写入!
}
}
// 启动 100 个线程同时写日志 → 最终长度不足预期(数据丢失)
白歌看后说:"这是典型的竞态条件。StringBuilder 的 append 不是原子操作——修改 count 和写入 value 数组之间可能被其他线程打断。你需要 StringBuffer。"
StringBuilder vs StringBuffer
核心对比
| 维度 | StringBuilder | StringBuffer |
|---|---|---|
| 引入版本 | JDK 5 | JDK 1.0 |
| 线程安全 | 不安全 | 安全(synchronized) |
| 性能 | 高 | 较低(锁开销) |
| 适用场景 | 单线程(局部变量、方法内) | 多线程共享 |
| 继承关系 | AbstractStringBuilder | AbstractStringBuilder |
| 几乎所有方法 | 无 synchronized | 带 synchronized |
StringBuilder和StringBuffer都继承自AbstractStringBuffer(这是一个包私有的抽象类,位于java.lang包)。两者 API 几乎完全一致——唯一的本质区别就是StringBuffer在每个公开方法上加上了synchronized关键字。
所有公开方法都加锁
打开 JDK 源码可以看到类似这样的实现:
// StringBuffer.java 源码片段
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
@Override
public synchronized StringBuffer insert(int offset, String str) {
toStringCache = null;
super.insert(offset, str);
return this;
}
完整示例一:线程安全的日志收集器
场景
孔蓝重新设计了飞翔科技的日志收集模块,使用 StringBuffer 作为多线程共享的缓冲区。
代码
public class StringBufferThreadSafeDemo {
public static void main(String[] args) throws InterruptedException {
StringBuffer sharedBuffer = new StringBuffer(); // 线程安全
// 启动 10 个线程,每个追加 1000 个字符
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
final int id = i;
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
sharedBuffer.append(id % 10); // synchronized 保证原子性
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join(); // 等待所有线程完成
}
System.out.println("最终长度: " + sharedBuffer.length()); // 应该是 10000
// 如果用 StringBuilder,长度可能 < 10000(数据丢失)
}
}
输出
最终长度: 10000
如果将此处的
StringBuffer换成StringBuilder,最终长度大概率不足 10000——因为多个线程同时修改count字段和value数组,出现了竞态条件(Race Condition)。
完整示例二:飞翔科技多线程日志系统
场景
白歌让孔蓝构建一个完整的飞行日志系统:多个服务线程同时写日志,主线程定期读取并清空缓冲区。
代码
public class ThreadSafeLogger {
// 使用 StringBuffer 作为线程安全的日志缓冲区
private static final StringBuffer logBuffer = new StringBuffer(8192);
public static void main(String[] args) throws InterruptedException {
System.out.println("===== 飞翔科技 多线程日志系统 =====\n");
// ========== 启动 5 个模拟服务线程 ==========
Thread[] services = new Thread[5];
String[] serviceNames = {"订单服务", "支付服务", "库存服务", "用户服务", "通知服务"};
for (int i = 0; i < 5; i++) {
final String name = serviceNames[i];
services[i] = new Thread(() -> {
for (int j = 0; j < 10; j++) {
log("[INFO] " + name + " - 处理第 " + (j + 1) + " 条请求");
try {
Thread.sleep((long) (Math.random() * 10)); // 模拟处理时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, name);
services[i].start();
}
// ========== 主线程定期输出日志 ==========
for (int round = 0; round < 3; round++) {
Thread.sleep(50); // 等待 50ms 收集一批日志
flushLogs();
}
// ========== 等待所有服务线程结束 ==========
for (Thread t : services) {
t.join();
}
flushLogs(); // 最后一批
System.out.println("\n所有服务线程已完成。日志系统关闭。");
}
// 线程安全地读取并清空日志
private static synchronized void flushLogs() {
if (logBuffer.length() > 0) {
System.out.println("--- 日志刷新 (" + logBuffer.length() + " 字符) ---");
System.out.print(logBuffer.toString());
logBuffer.setLength(0); // 清空缓冲区(线程安全)
}
}
// 日志写入方法
private static void log(String message) {
logBuffer.append(System.currentTimeMillis() % 100000)
.append(" [")
.append(Thread.currentThread().getName())
.append("] ")
.append(message)
.append("\n");
}
}
运行输出
===== 飞翔科技 多线程日志系统 =====
--- 日志刷新 (1234 字符) ---
47581 [订单服务] [INFO] 订单服务 - 处理第 1 条请求
47582 [支付服务] [INFO] 支付服务 - 处理第 1 条请求
47582 [库存服务] [INFO] 库存服务 - 处理第 1 条请求
47583 [用户服务] [INFO] 用户服务 - 处理第 1 条请求
47583 [通知服务] [INFO] 通知服务 - 处理第 1 条请求
47584 [订单服务] [INFO] 订单服务 - 处理第 2 条请求
...
--- 日志刷新 (1456 字符) ---
...
所有服务线程已完成。日志系统关闭。
深度分析:synchronized 的粒度问题
StringBuffer 的单个方法(如 append())是原子的,但复合操作不是原子的。例如:
// ⚠️ 看似安全,实际不是!
StringBuffer sb = new StringBuffer();
// 线程 A: sb.append("Hello-");
// 线程 B: sb.append("World-");
// 可能出现: "Hello-World-" ✓(A 先完成,B 后完成)
// 或: "World-Hello-" ✓(B 先完成,A 后完成)
// 不会出现: "HeWlloor-ld-" ✗(单次 append 是原子的,字符不会交错)
但如果用 StringBuilder:
// ❌ 使用 StringBuilder 时,单次 append 也可能交错
StringBuilder sb2 = new StringBuilder();
// 线程 A: sb2.append("Hello-");
// 线程 B: sb2.append("World-");
// 可能出现: "HeWlloor-ld-"(字符交错!因为 count 和 value 的修改被打断)
然而,即使是 StringBuffer,复合操作仍需外部同步:
// ⚠️ 即使是 StringBuffer,这两行之间也可能被其他线程插入
sb.append(userName);
sb.append(" - "); // 另一个线程可能在这两行之间 append 了内容
易错场景
反例一:多线程下使用 StringBuilder
// ❌ 错误:多线程共享 StringBuilder 导致数据混乱
public class Logger {
private static StringBuilder log = new StringBuilder(); // 单例,多线程共享!
public static void info(String msg) {
log.append("[INFO] ").append(msg).append("\n"); // 线程不安全!
}
}
纠正:
// ✅ 正确一:改用 StringBuffer
public class Logger {
private static StringBuffer log = new StringBuffer();
public static void info(String msg) {
log.append("[INFO] ").append(msg).append("\n"); // 单次 append 线程安全
}
}
// ✅ 正确二:局部变量使用 StringBuilder(推荐,线程封闭)
public class Logger {
public static void info(String msg) {
StringBuilder sb = new StringBuilder(); // 局部变量,线程封闭
sb.append("[INFO] ").append(msg);
System.out.println(sb.toString());
}
}
反例二:误以为 StringBuffer 所有操作序列都线程安全
// ❌ 错误:复合操作不加外部同步
private static StringBuffer report = new StringBuffer();
public static void addSection(String title, String body) {
report.append("=== ").append(title).append(" ===\n");
report.append(body).append("\n\n"); // 这两次 append 之间可能被打断!
}
纠正:
// ✅ 正确:复合操作用外部 synchronized 保护
private static StringBuffer report = new StringBuffer();
public static synchronized void addSection(String title, String body) {
report.append("=== ").append(title).append(" ===\n");
report.append(body).append("\n\n");
}
面试考点
Q1:StringBuilder 和 StringBuffer 的区别是什么?如何选择?
核心区别是线程安全:
StringBuffer所有 public 方法都加了synchronized,线程安全但开销大;StringBuilder没有同步,性能更高。单线程环境(如方法内部局部变量、循环拼接)应使用StringBuilder;多线程共享的字符串缓冲区才需要StringBuffer。绝大多数情况下StringBuilder是正确选择。
Q2:StringBuffer 的 synchronized 能保证所有操作都线程安全吗?
单个方法(如
append())是原子的,但复合操作不是。例如sb.append(a).append(b)拆成两次调用,中间可能被其他线程插入。如果需要原子性的复合操作,需要在外部加synchronized块保护整个操作序列。
Q3:为什么说"循环中用 + 拼接字符串"性能差?这和 StringBuffer 有什么关系?
每次
+操作编译后约等于new StringBuilder().append(s1).append(s2).toString()。在循环中反复执行意味着每次迭代都创建新的StringBuilder和临时String对象,造成大量 GC 压力。虽然编译器也做了优化(单条语句中连续 + 会被优化为一个 StringBuilder),但循环中的累积拼接无法被优化——必须手动使用StringBuilder或StringBuffer。
Q4:StringBuffer 的 toString() 方法为什么也是 synchronized?
StringBuffer.toString()需要读取内部的char[] value和int count字段来创建结果 String。如果不在同步块中,可能读到不一致的状态(例如count已被更新但value尚未写入完整)。加上synchronized保证读取时看到的是完整、一致的数据。
白歌在技术分享会上总结:"90% 的场景用
StringBuilder,9% 的场景用局部变量 + 线程封闭规避问题,只有不到 1% 的场景才真正需要StringBuffer。知道什么时候用哪一个,就是程序员的专业素养。"