1. 一个看似简单却容易踩坑的操作StringBuilder这个类写过 Java 的人都不陌生。字符串拼接、循环体里累积内容、动态生成 SQL 或者 JSON几乎处处都有它的身影。但有一个操作看起来简单到不值得单独拿出来说实际用起来却经常让人犹豫一下——清空 StringBuilder。你可能会想清空有什么难的sb new StringBuilder()不就完了或者sb.setLength(0)再或者sb.delete(0, sb.length())方法确实不止一种问题是这几种写法到底有没有区别性能差多少内存上有什么影响在循环里反复清空复用的场景下选错了会不会拖慢整个程序我在实际项目里就遇到过这样的情况一个批量数据处理的模块需要在循环里反复拼接字符串然后输出每次循环结束都要清空缓冲区。最初用的是最直觉的new一个新对象单次跑没什么感觉但当循环次数上到百万级别GC 日志就开始变得很难看。后来换成setLength(0)整体耗时直接降了一截。这件事让我意识到清空方式的选择不是风格问题而是实打实的性能问题。这篇内容就围绕 Java 中清空StringBuilder的四种常见方法展开把每种方式的底层行为、效率差异、适用场景和踩坑点讲清楚。不管你是刚接触 Java 的新手还是写了几年业务代码想抠一抠性能的老手都能从中找到可以直接拿去用的结论。核心关键词就一个StringBuilder 清空但围绕它展开的细节值得花点时间说透。2. 四种清空方式逐一拆解在动手对比之前先把四种方法摆出来分别是new新建对象、setLength(0)、delete(0, length)、replace(0, length, )。这四种写法在代码里都有人用但它们的内部行为差别不小理解了这个差别后面的效率对比才有依据。2.1 new 一个新对象最直觉但最容易被误用sb new StringBuilder()这种方式最好理解就是丢掉旧的建一个新的。代码写起来也最顺手尤其是刚从字符串拼接转到 StringBuilder 的人第一反应往往就是这个。但它的代价在于每次清空都会产生一个新的对象。旧的那个 StringBuilder 以及它内部持有的字符数组就变成了垃圾等着被垃圾回收器处理。单次操作看不出来可一旦放进循环里比如循环十万次、百万次就会产生同样数量的临时对象。这些对象会加重年轻代的分配压力触发更频繁的 Minor GC。这里有个细节值得说清楚。StringBuilder 内部维护的是一个char[]在较新的 JDK 里可能是byte[]取决于实现和压缩字符串特性默认容量是 16。当你new一个空 StringBuilder 时它同样会分配一个默认容量的数组。也就是说新建不只是创建一个对象壳子还附带一次数组分配。在循环复用的场景下这等于把原本可以复用的缓冲区反复丢弃重建非常浪费。注意new方式并不是一无是处。如果你的 StringBuilder 只是偶尔用一次用完就扔那用new完全没问题代码清晰比什么都重要。问题只出在“反复清空复用”这个特定场景。2.2 setLength(0)复用缓冲区的标准做法sb.setLength(0)是我个人最推荐的方式也是大多数性能敏感场景下的首选。它的行为是把 StringBuilder 的逻辑长度设为 0但底层的字符数组容量保持不变。这句话是关键。容量不变意味着什么意味着下次再往里 append 内容时只要总长度不超过原有容量就不会触发数组扩容也就不会有新的数组分配和元素拷贝。对于循环复用的场景这几乎是最优解——缓冲区一直在只是把“有效内容”的边界挪回起点。从实现角度看setLength在长度变小时主要工作就是把 count 字段置为 0并且把超出新长度的位置填充为\0这一步是为了帮助 GC 回收可能存在的引用虽然对 char 数组意义不大但实现上确实做了。整体开销极小基本就是几个字段赋值。有一个容易被忽略的点setLength(0)之后之前 append 的内容在逻辑上不可见了但底层数组里可能还残留着旧数据。如果你有安全方面的顾虑比如缓冲区里曾经装过敏感信息那单纯setLength(0)并不会擦除数组内容。这种场景下需要额外处理不过对于绝大多数业务代码来说这不是问题。2.3 delete(0, length)语义清晰但开销略高sb.delete(0, sb.length())的语义非常明确把从索引 0 到末尾的所有字符删掉。读代码的人一眼就能看懂意图可读性上是有优势的。它的内部实现会走delete的逻辑涉及边界检查、数组元素移动虽然这里是删到空移动量为零、以及 count 的更新。相比setLength(0)它多了一些方法调用和检查的开销。单次差距微乎其微但在极端高频的循环里累积起来还是能测出区别。另外要注意delete的第二个参数是结束索引不包含。所以delete(0, sb.length())才是删除全部写成delete(0, sb.length() - 1)就会留下最后一个字符这是个很常见的低级错误。用这种方式清空务必确认索引范围。2.4 replace(0, length, )最不推荐的一种sb.replace(0, sb.length(), )用的人相对少但确实存在。它的语义是“把指定区间的内容替换成空字符串”效果上等同于清空。问题是这个方法的内部逻辑比前几种都重。它需要处理替换字符串的长度、计算数组需要移动的偏移量、可能触发扩容或缩容判断等等。虽然替换成空串时最终结果和清空一样但走的代码路径更长开销自然更大。在实际测试中replace方式的耗时通常是setLength(0)的数倍。除非你有特殊的语义需求否则没有理由用它来清空。把它列出来主要是为了让你在看到别人这么写时知道它并不是一个等价的高效选择。3. 效率对比数据说话光讲原理不够得看实测。我搭了一个简单的基准测试用 JMH 的思路做对比实际项目里用 JMH 更严谨这里为了说明问题用循环加时间统计的方式也能看出趋势。测试场景是在循环中反复 append 一段固定内容然后分别用四种方式清空统计总耗时。3.1 测试设计与参数说明测试环境选的是常见的 JDK 8 和 JDK 17 各跑一遍因为不同版本对 StringBuilder 的实现有优化。循环次数设为 100 万次每次 append 的内容长度约 50 个字符这样能保证缓冲区会被实际使用而不是空转。关键参数有这么几个初始容量、append 内容长度、循环次数、是否预热。预热很重要JIT 编译需要时间不预热的话前几万次循环跑的是解释执行数据会失真。我一般先跑 10 万次预热再跑正式的 100 万次。测试代码大致长这样public class ClearBenchmark { public static void main(String[] args) { int warmup 100_000; int iterations 1_000_000; String content abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKL; // 预热 runTest(warmup, content, setLength); // 正式测试 long t1 runTest(iterations, content, new); long t2 runTest(iterations, content, setLength); long t3 runTest(iterations, content, delete); long t4 runTest(iterations, content, replace); System.out.println(new: t1 ms); System.out.println(setLength: t2 ms); System.out.println(delete: t3 ms); System.out.println(replace: t4 ms); } static long runTest(int iterations, String content, String mode) { long start System.nanoTime(); StringBuilder sb new StringBuilder(64); for (int i 0; i iterations; i) { sb.append(content); switch (mode) { case new: sb new StringBuilder(64); break; case setLength: sb.setLength(0); break; case delete: sb.delete(0, sb.length()); break; case replace: sb.replace(0, sb.length(), ); break; } } return (System.nanoTime() - start) / 1_000_000; } }3.2 实测结果与解读跑下来的结果趋势很稳定具体数值会因机器而异但相对关系基本一致。下面这张表是我在一台普通开发机上跑出来的典型值供参考清空方式100 万次耗时ms相对倍数内存分配情况new约 1803.0x每次新建对象和数组setLength(0)约 601.0x无额外分配delete(0, length)约 751.25x无额外分配replace(0, length, )约 2103.5x无额外分配但逻辑重从数据能看出几个结论。setLength(0)是最快的这符合预期因为它做的事情最少。delete稍慢一点差距不大在可接受范围内。new和replace明显更慢其中new的慢主要来自对象分配和 GC 压力replace的慢来自方法内部复杂的处理逻辑。有意思的是new在 JDK 17 上的表现比 JDK 8 好一些因为新版本的 GC 和对象分配优化更成熟。但即便如此它依然比setLength(0)慢不少。这说明复用缓冲区带来的收益是跨版本稳定的。还有一个现象值得注意如果 append 的内容更长比如每次 500 个字符new方式的劣势会更明显因为每次新建的数组需要扩容到更大容量分配和拷贝的成本都上去了。而setLength(0)因为保留了容量后续 append 直接复用优势进一步拉大。3.3 内存层面的差异耗时只是一方面内存分配同样重要。new方式在 100 万次循环里会产生 100 万个 StringBuilder 对象和对应的字符数组。这些对象大部分在年轻代就被回收了但分配和回收本身都要消耗 CPU。用-Xlog:gc或者 VisualVM 观察能看到new方式下 Minor GC 的次数明显更多。setLength(0)、delete、replace这三种方式都不会产生新的对象缓冲区始终是同一个。区别只在于方法调用的开销。所以从内存友好度来说后三者是一个梯队new单独一档。提示如果你的应用对延迟敏感比如高频交易、实时计算这类场景减少不必要的对象分配是基本功。setLength(0)这种复用思路本质上和对象池的思想是一致的。4. 到底该怎么选场景化建议知道了原理和数据接下来就是落地。不同场景下最优解并不完全一样我按几种典型情况分别说说。4.1 循环内反复清空复用首选 setLength(0)这是最典型的场景也是性能差异最明显的场景。比如批量处理数据、逐行读取文件拼接、循环生成报文等等。这种情况下无脑选setLength(0)。理由前面已经说透了复用底层数组零额外分配方法开销最小。你唯一需要确认的是缓冲区容量是否足够覆盖单次最大内容长度。如果不够append 时还是会扩容但那是 append 的正常行为和清空方式无关。可以在初始化时给一个合理的初始容量比如预估单次内容的最大长度避免反复扩容。StringBuilder sb new StringBuilder(1024); for (Data data : dataList) { sb.append(data.toFormattedString()); process(sb.toString()); sb.setLength(0); }这段代码就是标准写法可以直接抄。初始容量给 1024循环里 append、消费、清空干净利落。4.2 一次性使用new 也无妨如果你的 StringBuilder 只是在一个方法里用一次用完就随着方法结束被回收那用new完全没问题。这种情况下代码的可读性和直观性比那点性能差异更重要。硬要用setLength(0)反而显得刻意。比如public String buildMessage(String user, String action) { StringBuilder sb new StringBuilder(); sb.append(用户 ).append(user).append( 执行了 ).append(action); return sb.toString(); }这种场景下new是自然的选择没有任何问题。不要为了性能而性能把简单的事情复杂化。4.3 需要保留容量但语义要清晰delete 可以接受有些团队或项目对代码可读性要求很高觉得setLength(0)不够直观那用delete(0, sb.length())也完全可以。性能差距在大多数业务场景下可以忽略除非你的循环次数真的到了千万级别。用delete的时候记得第二个参数是sb.length()不要手滑写成sb.length() - 1。这个错误我见过不止一次排查起来还挺费劲因为结果只是多了一个字符不容易第一时间发现。4.4 什么时候都不推荐 replacereplace(0, sb.length(), )这种方式我想不出有什么场景是非用它不可的。它的语义是替换用来做清空属于“用错了工具”。性能又差可读性也没有优势。如果你在代码里看到这种写法建议直接改成setLength(0)。4.5 一张表总结选择策略场景推荐方式理由循环内高频清空复用setLength(0)零分配开销最小一次性使用new直观无复用需求团队规范要求语义清晰delete(0, length)可读性好性能可接受任何场景避免 replace无优势开销大5. 常见问题与排查技巧实录实际用起来除了选哪种方式还有一些零碎的问题经常冒出来。我把遇到过的高频疑问整理了一下配上排查思路。5.1 setLength(0) 之后内存会释放吗不会。setLength(0)只是把逻辑长度置零底层数组的容量保持不变。这是它的优点也是它的“缺点”——如果你清空后长时间不再使用这个 StringBuilder那块数组内存就一直占着。如果你的场景是“清空后可能很久不用甚至不再用”那可以考虑把它置为 null让 GC 回收。但如果是循环复用保留容量正是我们想要的。所以这个问题没有绝对答案取决于你的使用模式。5.2 为什么我用了 setLength(0) 还是慢如果换了setLength(0)但性能没改善大概率问题不在清空方式上而在别处。常见的原因有几个一是 append 的内容本身太长导致频繁扩容二是toString()调用太频繁每次都会创建新字符串三是循环体里还有其他耗时操作清空只是其中一小环。排查的时候建议用 profiler 工具看一下热点到底在哪不要想当然地认为是清空的问题。我见过一个案例开发者以为是清空慢结果发现是每次循环都在做正则匹配跟 StringBuilder 一点关系没有。5.3 多线程环境下能复用 StringBuilder 吗不能。StringBuilder 不是线程安全的多个线程同时操作同一个实例结果不可预期。如果你在多线程环境里需要拼接字符串要么每个线程用自己的 StringBuilder要么用StringBuffer线程安全但性能差一些要么用ThreadLocal给每个线程分配独立的缓冲区。用ThreadLocal的思路是这样的private static final ThreadLocalStringBuilder SB_HOLDER ThreadLocal.withInitial(() - new StringBuilder(1024)); public void process(String data) { StringBuilder sb SB_HOLDER.get(); sb.setLength(0); sb.append(data); // 使用 sb }这样每个线程都有自己的缓冲区既复用了对象又避免了线程安全问题。用完记得在合适的时机清理 ThreadLocal防止内存泄漏。5.4 初始容量设多少合适这个问题没有标准答案取决于你的内容长度分布。我的经验是取单次内容最大长度的 1.2 到 1.5 倍。比如单次最多拼 800 个字符那初始容量给 1024 就挺合适。这样既能覆盖绝大多数情况又不会浪费太多内存。如果内容长度波动很大比如有时候 10 个字符有时候 10000 个字符那可以给一个中等偏上的值比如 2048让偶尔的长内容触发一次扩容总比每次都扩容强。5.5 常见问题速查表问题现象可能原因解决方向循环内耗时高GC 频繁用了 new 清空改用 setLength(0)换了 setLength 仍慢热点不在清空用 profiler 定位真正瓶颈多线程结果错乱StringBuilder 非线程安全每线程独立实例或 ThreadLocal内存占用居高不下缓冲区容量过大且长期不用评估是否置 null 或调整容量delete 后残留字符索引范围写错确认用 sb.length() 而非减一5.6 几个容易忽略的实操心得第一个心得清空和复用的思路可以推广到其他可变对象。比如ByteArrayOutputStream的reset()、集合的clear()本质都是复用内部缓冲区。养成这种思维对写出低 GC 压力的代码很有帮助。第二个心得不要过早优化但要知道优化的方向。如果你的循环只有几百次用哪种清空方式都无所谓代码清晰最重要。只有当循环次数上到万级、十万级性能差异才值得关注。判断标准很简单跑一下看 GC 日志和耗时数据会告诉你答案。第三个心得JMH 是验证性能的可靠工具。我上面用的简单计时方式适合快速看趋势但要做严谨结论还是得上 JMH。它能处理预热、死代码消除、多轮迭代等问题结果更可信。如果团队里有人对性能数据有争议用 JMH 跑一遍比争论半天管用。第四个心得关注 JDK 版本差异。StringBuilder 在不同 JDK 版本上的实现有细微差别尤其是涉及压缩字符串和底层数组类型的变化。如果你的项目跨多个 JDK 版本性能结论最好在目标版本上实测不要直接套用网上的数据。6. 写在最后的一点个人体会关于 StringBuilder 清空这件事我最大的体会是很多性能问题不是出在复杂的算法上而是出在这些看起来不起眼的小操作上。一个new和setLength(0)的区别单次看微不足道放大到百万次循环就是几倍的差距。我现在写代码的习惯是只要看到 StringBuilder 出现在循环里就会下意识检查清空方式。如果是new条件反射地改成setLength(0)。这个习惯帮我省下了不少排查 GC 问题的时间。另外别把性能优化想得太玄乎。大多数时候你不需要什么高深技巧只需要知道每个常用操作的代价然后在合适的场景选合适的那个。setLength(0)就是这样一个“知道就能用上”的小知识成本几乎为零收益却很实在。如果你手头正好有循环拼接字符串的代码不妨现在就翻出来看一眼清空方式是不是最优的。改一行代码的事说不定就能让程序跑得更顺一点。