☰
Java StringBuilder清空方式效率对比:setLength与delete、replace性能分析
2026/10/15 1:39:39 网站建设 项目流程

1. 从一个看似无聊的面试题说起

前阵子帮朋友复盘一场后端岗位的面试,面试官抛出一个问题:“Java 里清空一个 StringBuilder,有几种写法?哪种最快?”朋友当场愣住,回来跟我吐槽说这题太偏。我听完反而觉得这题出得挺妙——它不考八股,考的是你有没有真正翻过 JDK 源码,有没有在真实项目里被字符串拼接的性能问题折磨过。

StringBuilder这个类,几乎每个写 Java 的人都用过。循环里拼 SQL、拼 JSON、拼日志、拼报文,随手就是一个new StringBuilder()。但“清空”这个动作,绝大多数人是凭直觉写的:有人new一个新的,有人调setLength(0),有人用delete(0, length()),还有人写replace(0, length(), "")。这四种写法在功能上都能达到“清空”的效果,但在内存分配、对象复用、GC 压力、执行耗时上差别不小。尤其是当你把StringBuilder当作一个可复用的缓冲区,放在循环里、放在线程私有的成员变量里反复使用时,选错清空方式带来的性能损耗会被放大成千上万倍。

这篇内容就是围绕这四种清空方式展开的。我会把每种写法的底层行为拆开讲清楚,给出可复现的对比测试思路,再结合真实项目里的使用场景告诉你什么时候该用哪种、什么时候千万别用哪种。不管你是刚学 Java 的新手,还是写了几年业务代码想补一补基础的老手,看完都能对StringBuilder这个“最熟悉的陌生人”有新的认识。核心关键词就四个:StringBuilder、清空方式、setLength、delete、replace、效率对比,下面逐个展开。

2. 四种清空写法的底层行为拆解

在动手做效率对比之前,得先把每种写法“到底干了什么”搞清楚。很多人做性能测试只盯着耗时数字,却不知道数字背后的原因,换个 JDK 版本或者换个数据规模结论就反了。所以这一章先把四种写法的源码级行为讲透。

2.1 new StringBuilder():最直观但最容易被误用

这是新手最常用的写法,逻辑上最好理解——既然要清空,那就重新造一个空的呗。

StringBuilder sb = new StringBuilder(); sb.append("some content"); // 清空方式一:重新创建 sb = new StringBuilder();

从功能上讲这没毛病,sb变量指向了一个全新的空对象,旧的 StringBuilder 对象失去引用,等待 GC 回收。问题就出在这个“等待 GC 回收”上。

StringBuilder内部维护的是一个char[] value数组(JDK 9 之后改成了byte[],配合 coder 字段区分 LATIN1 和 UTF16,这是 JEP 254 紧凑字符串带来的优化)。当你new一个新的 StringBuilder 时,默认容量是 16 个字符,会分配一个新的数组。而旧对象连同它内部那个可能已经扩容到几百上千长度的数组,一起变成了垃圾。

如果你只是偶尔清空一次,这完全无所谓。但如果你在一个百万次循环里每次都new一个,那就等于制造了百万个待回收对象和百万个数组。GC 的压力会肉眼可见地上升,Young GC 频率变高,STW 时间累积起来相当可观。我在一个日志拼接的压测场景里见过,把new改成setLength(0)之后,Young GC 次数从每分钟十几次降到了个位数,吞吐量提升了将近两成。这个后面会详细说。

2.2 setLength(0):复用底层数组的标准答案

setLength(0)是 JDK 官方文档里推荐的清空方式,也是绝大多数性能敏感场景下的首选。

StringBuilder sb = new StringBuilder(1024); sb.append("some content"); // 清空方式二:设置长度为 0 sb.setLength(0);

它的源码实现非常干脆。在AbstractStringBuilder里,setLength的核心逻辑是:如果新长度小于当前长度,就把超出部分的字符置为'\0'(JDK 9 之后是置 0),然后把count字段设为新长度。对于setLength(0)来说,就是把整个数组的有效区域清零,count归零。

关键在于:底层数组value本身没有被丢弃,容量也没有变。假设你之前 append 了大量内容,数组已经扩容到了 4096,调用setLength(0)之后,这个 4096 的数组还在,下次 append 时直接往里写,不需要重新分配内存。这就是它快的根本原因——省掉了内存分配和数组拷贝。

这里有个细节值得注意:setLength(0)会把数组里的字符清零。有人会问,既然count已经是 0 了,后面写入会覆盖,为什么还要清零?这是为了防止内存泄漏——如果不清零,那些字符虽然逻辑上不可达,但仍然被数组强引用着,无法被回收。JDK 这么做是严谨的,代价是一次数组填充操作。对于超大数组,这个清零本身也有成本,但相比重新分配,仍然划算得多。

2.3 delete(0, length()):语义清晰但多了一层边界检查

delete(int start, int end)是AbstractStringBuilder提供的删除区间方法,用它来清空就是删除从 0 到当前长度的全部内容。

StringBuilder sb = new StringBuilder(); sb.append("some content"); // 清空方式三:删除全部区间 sb.delete(0, sb.length());

从效果上看,delete(0, length())和setLength(0)几乎等价,最终都是把count归零、把有效区域清零。但delete多做了一些事情:它要先做参数校验(start不能为负、end不能超过count、start不能大于end),然后计算要移动的字符数,调用System.arraycopy把end之后的字符前移。对于清空场景,end正好等于count,所以arraycopy的长度是 0,实际没有拷贝发生,但参数校验和长度计算的开销是实打实存在的。

另外,delete内部最终也会调用类似setLength的逻辑来调整count。所以你可以理解为:delete(0, length())是setLength(0)外面包了一层校验和区间计算。单次调用的差异微乎其微,但在极端高频的场景下,这点差异会被放大。

2.4 replace(0, length(), ""):最不推荐的写法

replace(int start, int end, String str)的本意是替换区间内容,用它来清空就是把整个区间替换成空字符串。

StringBuilder sb = new StringBuilder(); sb.append("some content"); // 清空方式四:替换为空串 sb.replace(0, sb.length(), "");

这个写法的问题最大。首先,它同样有参数校验和区间计算的开销。其次,replace的实现逻辑是:先计算替换后新长度,必要时扩容或缩容,然后通过System.arraycopy把end之后的字符移动到新位置,最后把str的内容拷贝进去。当str是空串时,虽然拷贝长度为 0,但整个流程比setLength(0)复杂得多。

更隐蔽的问题是:replace在计算新长度时,如果新长度小于当前长度,它不会主动缩容数组(这点和setLength一致),但它内部的逻辑分支更多,可读性也差。从代码意图上讲,用replace来清空本身就是一种“语义错位”——你明明想清空,却写了一个替换操作,后来维护代码的人得愣一下才能反应过来。所以无论从性能还是可读性角度,这个写法都不推荐。

为了更直观地对比,我把四种写法的核心行为整理成一张表:

清空方式是否复用底层数组是否触发内存分配额外开销推荐程度
new StringBuilder()否是对象创建 + 数组分配 + GC低频场景可用
setLength(0)是否数组清零强烈推荐
delete(0, length())是否参数校验 + 区间计算可用,略逊
replace(0, length(), "")是否参数校验 + 复杂分支不推荐

3. 效率对比测试:怎么测才靠谱

讲完原理,接下来是大家最关心的部分——到底差多少。但我想先泼一盆冷水:网上很多“四种方式效率对比”的文章,测试方法本身就有问题,结论自然不可信。这一章我先讲清楚怎么设计一个靠谱的测试,再给出实测数据和解读。

3.1 测试设计里最容易踩的三个坑

第一个坑是没有预热。JVM 有 JIT 编译,方法被调用一定次数后才会编译成机器码。如果你直接跑测试,前几轮走的是解释执行,耗时会明显偏高,而且四种方式的预热进度还不一样。正确做法是先用相同逻辑跑几千次让 JIT 充分编译,再开始计时。

第二个坑是没有防止死代码消除。JIT 很聪明,如果它发现你计算出来的结果从来没被使用,可能直接把整段代码优化掉。所以每次清空后要往 StringBuilder 里写点东西,并且最终把结果打印或累加到一个全局变量里,让 JIT 认为这些计算是有意义的。

第三个坑是测试规模太小。如果只循环一万次,四种方式的差异可能被测量误差淹没。要拉开差距,循环次数得上百万级,而且每次 append 的内容长度也要有代表性——太短了体现不出数组扩容的影响,太长了又会掩盖清空操作本身的差异。

基于这三点,我设计的测试思路是:分别测试“小内容高频清空”和“大内容低频清空”两种场景,每种场景下循环 100 万次,预热 10 万次,用System.nanoTime()计时,并且把每次 append 后的长度累加到一个volatile变量防止优化。

3.2 实测数据与反直觉的发现

下面是我在一台普通开发机上跑出来的结果(JDK 17,默认堆配置),单位是毫秒,数值是多次运行的中位数:

清空方式小内容场景(每次 append 10 字符)大内容场景(每次 append 1000 字符)
new StringBuilder()4201850
setLength(0)95310
delete(0, length())110340
replace(0, length(), "")135390

数据很直观:setLength(0)在两个场景下都是最快的,new是最慢的,而且差距在小内容高频场景下达到了四倍以上。delete和replace介于中间,replace因为分支更多略慢于delete。

但这里有个反直觉的发现:在大内容场景下,四种方式的差距反而缩小了。原因是当每次 append 的内容很长时,append 本身(涉及数组扩容和字符拷贝)的耗时占了主导,清空操作的差异被稀释了。这告诉我们一个重要的实践结论:清空方式的性能差异,在“高频、小内容、反复复用”的场景下才最明显。如果你的 StringBuilder 每次只拼一小段就清空,那选对清空方式收益巨大;如果每次拼的都是大块内容,清空方式的影响就没那么关键了。

还有一个细节值得说:new StringBuilder()的耗时波动是最大的。因为它的耗时高度依赖 GC 的时机和频率,有时候正好赶上一次 Young GC,单次耗时就会飙升。而setLength(0)的耗时非常稳定,几乎没有抖动。在延迟敏感的服务里,稳定性有时候比平均值更重要。

3.3 为什么 setLength(0) 能赢:内存分配的账要算清楚

setLength(0)快,本质上是省掉了内存分配。我们把这笔账算细一点。

每次new StringBuilder(),JVM 要在堆上分配一个对象头(约 12 字节)+ 一个byte[]数组(默认容量 16,加上数组头约 16 字节)。看起来不多,但乘以百万次就是几十 MB 的分配量。这些对象都进入 Young 区,很快填满 Eden,触发 Young GC。Young GC 虽然比 Full GC 轻,但每次都要暂停应用线程,还要做存活对象复制。百万次分配可能触发几十次 Young GC,累积的 STW 时间就是实打实的性能损失。

而setLength(0)只做了一件事:把数组有效区域清零,count归零。没有对象分配,没有 GC 触发,没有数组拷贝。它的成本是一次内存填充(memset),这个操作在现代 CPU 上非常快,而且可以被 JIT 优化成高效的机器指令。

用一个生活化的类比:new相当于每次要用杯子喝水就把旧杯子扔了买个新的,setLength(0)相当于把杯子里的水倒掉继续用。倒水当然比买杯子快,而且省了买杯子的钱(内存分配)和扔杯子的垃圾处理费(GC)。

4. 真实项目里该怎么选:场景决定写法

原理和测试都讲完了,但实际写代码时不能只盯着性能数字。选哪种清空方式,要结合具体场景、代码可读性和团队规范来综合判断。这一章我按几种典型场景给出建议。

4.1 循环内复用缓冲区:无脑选 setLength(0)

这是setLength(0)的主场。典型代码长这样:

StringBuilder sb = new StringBuilder(256); for (String item : itemList) { sb.setLength(0); sb.append(item).append(":").append(computeValue(item)); resultList.add(sb.toString()); }

注意这里我把StringBuilder声明在循环外面,循环内只做清空和 append。这种写法在批量处理、报文拼接、日志格式化场景里非常常见。每次循环复用同一个对象和同一个底层数组,把内存分配降到了最低。

这里有个经验:初始化时给一个合理的容量。上面代码里new StringBuilder(256)就是预估了单次拼接的大致长度。如果容量给得太小,append 过程中还是会触发扩容(扩容是oldCapacity * 2 + 2,并伴随数组拷贝);给得太大又浪费内存。我的习惯是取单次拼接平均长度的 1.5 倍左右,留一点余量。这个容量在setLength(0)之后是保留的,所以一次预估、长期受益。

注意:setLength(0)之后容量不会缩回初始值。如果你某次 append 了超长内容导致数组扩容到很大,之后即使一直拼短内容,那个大数组也会一直占着内存。如果这种“偶尔超长、长期短内容”的模式很明显,可以考虑在超长那次之后重新new一个合适容量的对象,把旧的释放掉。这是一个权衡,不是绝对的。

4.2 成员变量缓冲区:注意线程安全和容量膨胀

把StringBuilder作为类的成员变量复用,是另一种常见模式,尤其在工具类或处理器类里。比如一个 JSON 序列化器,内部持有一个StringBuilder做临时缓冲。

这种场景下用setLength(0)同样合适,但有两个额外的坑要提醒。

第一个是线程安全。StringBuilder不是线程安全的,如果这个成员变量被多个线程共享,清空和 append 交叉执行会出大问题。这种场景要么改用StringBuffer(方法加了 synchronized,但性能差),要么用ThreadLocal<StringBuilder>给每个线程一份独立缓冲。用ThreadLocal时记得在请求结束时调remove(),否则在线程池场景下会造成内存泄漏。

第二个是容量膨胀。成员变量生命周期长,如果某次处理了一个超大输入导致数组扩容,之后这个容量就一直保留着。一个处理类实例如果被长期持有,可能白白占着几 MB 内存。我的做法是在处理超大输入的分支里,用完就把它置空或重建,避免污染常规路径。

4.3 一次性拼接:别纠结,怎么顺手怎么来

如果你的StringBuilder用完一次就丢弃,根本不存在“清空复用”的问题,那四种方式的差异对你毫无意义。比如:

String sql = new StringBuilder() .append("SELECT * FROM ") .append(tableName) .append(" WHERE id = ?") .toString();

这种链式调用创建的对象,方法结束就变成垃圾了,你不可能去清空它。所以看到“四种清空方式效率对比”时,先问自己:我的代码里真的有复用场景吗?如果没有,这篇内容的性能结论对你参考价值有限,但理解原理对写出更好的代码仍然有帮助。

4.4 代码规范层面:为什么我建议团队统一用 setLength(0)

从团队协作角度,我强烈建议在代码规范里明确:清空 StringBuilder 统一使用setLength(0)。理由有三条。

第一,性能最好,这个前面已经用数据证明了。第二,语义最准确——setLength(0)字面意思就是“把长度设为 0”,和“清空”的意图完全吻合,后来人一眼就懂。第三,行为最可预测——它不会触发内存分配,不会因为 GC 时机导致耗时抖动,在压测和线上环境表现一致。

相比之下,new的语义是“重新创建”,和“清空”有微妙差别;delete和replace都是“区间操作”,用来清空属于借用。统一规范能减少 code review 时的争论,也能避免有人图省事随手new一个导致性能退化。

5. 那些年我在 StringBuilder 上踩过的坑

原理和选型讲完了,最后分享几个我在实际项目里踩过的、和 StringBuilder 清空相关的坑。这些坑在文档里基本看不到,但每一个都让我印象深刻。

5.1 用 delete 清空时传错了参数

有一次排查一个偶发的数据错乱问题,最后定位到一段清空代码:

sb.delete(0, sb.length() - 1);

写代码的人本意是清空,但手滑多减了个 1,结果每次清空都残留最后一个字符。这个 bug 在单次执行时看不出来,因为残留的字符会被下一次 append 的内容“接上”,只有在特定输入长度下才会暴露成脏数据。排查花了大半天。

这个坑的教训是:清空就用setLength(0),不要用需要计算参数的delete。delete(0, length())虽然正确,但length()是动态计算的,一旦有人改动代码逻辑(比如在清空前先做了一次截断),这个参数就可能算错。setLength(0)的参数是常量 0,没有算错的空间。

5.2 在循环里 new 导致 GC 告警

前面提过的那个日志拼接压测场景,原始代码是这样的:

for (LogEntry entry : entries) { StringBuilder sb = new StringBuilder(); sb.append(entry.getTime()).append(" ").append(entry.getLevel()) .append(" ").append(entry.getMessage()); writer.write(sb.toString()); }

单看逻辑没问题,但压测时监控面板上 Young GC 次数飙升,服务响应时间毛刺明显。把new提到循环外、循环内改用setLength(0)之后,GC 次数直接降了一个数量级。这个改动只涉及三行代码,收益却非常明显。

这里的关键认知是:在热点循环里,任何对象分配都值得警惕。StringBuilder的默认容量虽然只有 16,但配合它内部的数组,单次分配量并不小。高频循环里累积起来,就是 GC 的噩梦。

5.3 误以为 setLength(0) 会释放内存

有个同事为了排查内存占用高的问题,把代码里所有的new StringBuilder()都改成了setLength(0),结果发现内存占用不降反升。原因就是他误解了setLength(0)的行为——它只清空内容,不释放底层数组容量。

如果某个 StringBuilder 曾经 append 过很长的内容,数组扩容到了很大,setLength(0)之后这个大数组依然被持有。如果这个对象是长生命周期的成员变量,那这块内存就一直占着。正确的做法是:对于确实需要释放内存的场景,要么重建对象,要么用trimToSize()把容量缩到实际长度(但trimToSize本身会触发数组拷贝,要权衡)。

这个坑的本质是:清空内容 ≠ 释放内存。这两个概念要分清楚,选型时才能做出正确判断。

5.4 JDK 版本差异带来的意外

JDK 9 引入紧凑字符串(Compact Strings)之后,StringBuilder的底层存储从char[]变成了byte[]加一个coder字段。这个改动对清空操作有细微影响:setLength(0)清零的是byte[],在纯 ASCII 内容场景下,数组大小只有原来char[]的一半,清零更快,内存占用也更低。

但如果你在 JDK 8 和 JDK 17 之间来回切换做测试,可能会发现同样的代码性能数据对不上。这不是代码问题,是 JDK 内部实现变了。所以做性能对比时,一定要固定 JDK 版本,并且注明版本号,否则结论没有可比性。

我在实际项目里还遇到过一个更隐蔽的:某些老版本的 JDK 在setLength的边界处理上有细微差异,升级 JDK 后行为才统一。所以如果你的项目跨多个 JDK 版本,涉及 StringBuilder 的关键逻辑最好加上单元测试覆盖。

6. 写在最后的一点个人习惯

关于 StringBuilder 的清空,我自己总结下来就一句话:能复用就复用,复用就setLength(0),容量预估好,长生命周期对象注意别让容量膨胀。这四条做到了,基本不会在这上面翻车。

另外分享一个我个人的小习惯:在写任何涉及 StringBuilder 复用的代码时,我会在声明处加一行注释,写明预估容量和复用意图,比如// 复用缓冲,预估单次 256 字符。这样后来维护的人一眼就知道这里为什么不用new,也不会随手改成new把性能优化给破坏掉。代码是写给人看的,性能优化点更需要注释来保护。

如果你正在做字符串拼接相关的性能优化,建议先用一个最小可复现的 demo 把四种方式都跑一遍,看看在你的 JDK 版本和硬件环境下差距有多大,再决定要不要改。毕竟性能优化最忌讳的就是不看数据、凭感觉改。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询