StringBuilder:可变的字符串构建器
本章定位:当需要频繁修改字符串时,
StringBuilder提供了高效的可变字符序列。它是单线程环境下字符串拼接的首选工具。理解它的扩容机制、链式调用和与 String 的性能差异,是写出高性能 Java 代码的必修课。
白歌的 Code Review
小崔写了一个生成员工工号的函数,代码是这样:
public String generateEmployeeId(String dept, int year, int seq) {
return dept + "-" + year + "-" + seq; // 看似没问题
}
白歌在 Code Review 时指出:"这种单条拼接没问题,编译器会优化为 StringBuilder。但你看看你写的另一个方法——"
// 小崔写的:批量生成 100 条 SQL 插入语句
public String buildBatchInsert(List<Employee> employees) {
String sql = "INSERT INTO employee VALUES ";
for (Employee emp : employees) {
sql += "(" + emp.getId() + ",'" + emp.getName() + "'),"; // 性能灾难!
}
return sql;
}
"这个方法在 10000 个员工时会创建上万个临时对象。你需要显式使用 StringBuilder。"
核心方法详解
速查表格
| 方法 | 功能 | 返回值 |
|---|---|---|
append(xxx) | 追加任意类型数据 | this(支持链式调用) |
insert(int offset, xxx) | 在指定位置插入 | this |
delete(int start, int end) | 删除 [start, end) 的字符 | this |
deleteCharAt(int index) | 删除指定位置的字符 | this |
replace(int start, int end, String str) | 替换 [start, end) 为 str | this |
reverse() | 反转字符串 | this |
charAt(int index) | 获取指定位置字符 | char |
setCharAt(int index, char ch) | 修改指定位置字符 | void |
length() | 返回有效字符数 | int |
capacity() | 返回当前容量 | int |
toString() | 转为 String | String |
setLength(int newLength) | 设置有效字符数(可用于清空) | void |
继承关系
StringBuilder 继承自 AbstractStringBuilder,这是一个包级私有的抽象类,提供了可变字符序列的底层实现(char[] value 数组存储、扩容逻辑等)。
StringBuilder和StringBuffer都继承自AbstractStringBuilder,差别仅在于StringBuffer的方法上加了synchronized。绝大多数情况下StringBuilder是正确选择。
完整示例一:StringBuilder 常用方法
场景
小崔需要处理飞翔科技的员工工牌模板:拼接姓名、职位、部门,并在中间插入分隔符。
代码
public class StringBuilderDemo {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
// ========== append:追加 ==========
sb.append("飞翔科技");
sb.append(" - ");
sb.append("技术部");
sb.append(" - ");
sb.append("高级工程师");
System.out.println("append 后: " + sb);
// 输出: 飞翔科技 - 技术部 - 高级工程师
// ========== 链式调用 ==========
sb.setLength(0); // 清空(比 new 高效)
sb.append("姓名: ").append("小崔")
.append(", 部门: ").append("技术部")
.append(", 薪资: ").append(15000);
System.out.println("链式调用: " + sb);
// 输出: 姓名: 小崔, 部门: 技术部, 薪资: 15000
// ========== insert:插入 ==========
sb.insert(0, "【员工信息】");
System.out.println("insert 后: " + sb);
// 输出: 【员工信息】姓名: 小崔, 部门: 技术部, 薪资: 15000
// ========== replace:替换 ==========
// 将 "小崔" 替换为 "大翔"
int nameStart = sb.indexOf("小崔");
sb.replace(nameStart, nameStart + 2, "大翔");
System.out.println("replace 后: " + sb);
// 输出: 【员工信息】姓名: 大翔, 部门: 技术部, 薪资: 15000
// ========== delete:删除 ==========
// 删除 【员工信息】前缀
sb.delete(0, 7);
System.out.println("delete 后: " + sb);
// ========== reverse:反转 ==========
StringBuilder palindrome = new StringBuilder("上海自来水来自海上");
System.out.println("原文: " + palindrome);
System.out.println("反转: " + palindrome.reverse());
// 输出: 海上来自来水自海上
// ========== setCharAt:修改单个字符 ==========
StringBuilder code = new StringBuilder("FX-0001");
code.setCharAt(3, '9');
System.out.println("修改工号: " + code); // FX-9001
}
}
运行输出
append 后: 飞翔科技 - 技术部 - 高级工程师
链式调用: 姓名: 小崔, 部门: 技术部, 薪资: 15000
insert 后: 【员工信息】姓名: 小崔, 部门: 技术部, 薪资: 15000
replace 后: 【员工信息】姓名: 大翔, 部门: 技术部, 薪资: 15000
delete 后: 姓名: 大翔, 部门: 技术部, 薪资: 15000
原文: 上海自来水来自海上
反转: 海上来自来水自海上
修改工号: FX-9001
扩容机制(深度原理)
StringBuilder 底层使用 char[] value 存储字符,默认初始容量为 16。当 append() 时容量不足,会触发扩容。
扩容算法(JDK 8 源码)
// AbstractStringBuilder 中的扩容逻辑
private void ensureCapacityInternal(int minimumCapacity) {
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value, newCapacity(minimumCapacity));
}
}
private int newCapacity(int minCapacity) {
// 新容量 = 旧容量 * 2 + 2
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity) : newCapacity;
}
| 扩容次数 | 容量变化 | 公式 |
|---|---|---|
| 初始 | 16 | new StringBuilder() |
| 第 1 次 | 34 | 16 × 2 + 2 |
| 第 2 次 | 70 | 34 × 2 + 2 |
| 第 3 次 | 142 | 70 × 2 + 2 |
性能提示:如果能预估最终字符串长度,构造时指定初始容量可避免多次扩容和数组拷贝:
StringBuilder sb = new StringBuilder(1024); // 直接分配 1024 容量
完整示例二:String vs StringBuilder 性能对比
场景
白歌让小崔做一个基准测试:10 万次字符串拼接,对比 String(+)和 StringBuilder 的性能差距。
代码
public class StringConcatBenchmark {
public static void main(String[] args) {
final int TIMES = 100_000;
// ========== 方式一:String + 拼接(反例) ==========
long start1 = System.currentTimeMillis();
String s = "";
for (int i = 0; i < TIMES; i++) {
s += "a";
}
long time1 = System.currentTimeMillis() - start1;
System.out.println("String + 拼接耗时: " + time1 + " ms");
// ========== 方式二:StringBuilder(推荐) ==========
long start2 = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < TIMES; i++) {
sb.append("a");
}
String result = sb.toString();
long time2 = System.currentTimeMillis() - start2;
System.out.println("StringBuilder 耗时: " + time2 + " ms");
// ========== 方式三:StringBuilder 预分配容量(最优) ==========
long start3 = System.currentTimeMillis();
StringBuilder sb2 = new StringBuilder(TIMES); // 预分配容量
for (int i = 0; i < TIMES; i++) {
sb2.append("a");
}
String result3 = sb2.toString();
long time3 = System.currentTimeMillis() - start3;
System.out.println("StringBuilder(预分配) 耗时: " + time3 + " ms");
System.out.println("\n性能倍数:");
System.out.println("StringBuilder 比 + 快: " + (time1 / Math.max(time2, 1)) + " 倍");
}
}
运行输出
String + 拼接耗时: 342 ms
StringBuilder 耗时: 2 ms
StringBuilder(预分配) 耗时: 1 ms
性能倍数:
StringBuilder 比 + 快: 171 倍
在实际测试中,StringBuilder 比循环中的
+拼接快数十到数百倍不等。数据量越大,差距越明显。
易错场景
反例一:无视 capacity 导致的频繁扩容
// ❌ 错误:默认 16 容量,存 100KB 数据会扩容约 10 次
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100_000; i++) {
sb.append("data,");
}
纠正:
// ✅ 正确:预估容量,一次分配到位
int estimatedSize = 100_000 * 5; // "data," 约 5 字节
StringBuilder sb = new StringBuilder(estimatedSize);
for (int i = 0; i < 100_000; i++) {
sb.append("data,");
}
反例二:用 == 比较 StringBuilder 内容
// ❌ 错误:StringBuilder 没有重写 equals(),== 比较的是引用地址
StringBuilder sb1 = new StringBuilder("飞翔");
StringBuilder sb2 = new StringBuilder("飞翔");
System.out.println(sb1.equals(sb2)); // false!(继承自 Object 的 equals)
纠正:
// ✅ 正确:转成 String 再比较内容
StringBuilder sb1 = new StringBuilder("飞翔");
StringBuilder sb2 = new StringBuilder("飞翔");
System.out.println(sb1.toString().equals(sb2.toString())); // true
反例三:delete 参数的边界误解
// ❌ delete(start, end) 删除 [start, end),end 处的字符不会被删
StringBuilder sb = new StringBuilder("ABCDEF");
sb.delete(1, 3); // 删除索引 1,2 → "ADEF"
System.out.println(sb); // "ADEF",不是 "AEF"!
记法:
delete(start, end)的行为和String.substring(start, end)一致——左闭右开。
面试考点
Q1:StringBuilder 和 StringBuffer 的区别是什么?如何选择?
核心区别是线程安全:
StringBuffer所有 public 方法都加了synchronized,线程安全但开销大;StringBuilder没有同步,性能更高。单线程环境(如方法内部局部变量、循环拼接)应使用StringBuilder;多线程共享的字符串缓冲区才需要StringBuffer。绝大多数情况下StringBuilder是正确选择。
Q2:StringBuilder 的扩容机制是怎样的?
初始容量 16。需要扩容时,新容量 = 旧容量 × 2 + 2。如果该值仍小于所需最小容量,则直接用所需最小容量。扩容通过
Arrays.copyOf()创建新数组并拷贝原数据。频繁扩容会影响性能,因此建议预估容量并在构造时指定。
Q3:为什么说"循环中用 + 拼接字符串"性能差?
每次
+操作编译后约等于new StringBuilder().append(s1).append(s2).toString()。在循环中反复执行意味着每次迭代都创建新的StringBuilder和临时String对象,造成大量 GC 压力。而显式使用一个StringBuilder在整个循环中复用,只产生最终一个String对象。
Q4:StringBuilder 的 reverse() 方法是如何实现的?时间复杂度是多少?
内部使用双指针法:一个指向头部,一个指向尾部,交换字符后向中间移动,直到相遇。时间复杂度 O(n),空间复杂度 O(1)(原地反转)。底层实现位于
AbstractStringBuilder.reverse(),直接操作char[] value数组。
大翔常说:"选择正确的字符串工具类,是程序员的职业素养。StringBuilder 是默认选项,而循环里的 + 号则是'永远不要用'。"