多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

String、StringBuilder、StringBuffer 底层原理与性能对比全解析

String、StringBuilder、StringBuffer 底层原理与性能对比全解析 先聊一个很多人在面试里都会遇到、但经常答不全面的问题Java 里 String、StringBuilder、StringBuffer 到底有什么区别我见过不少人能把“String 不可变另外两个可变”这句话背下来但一问他实际开发里什么时候用哪个反而支支吾吾。更常见的是老开发也未必说得清 StringBuilder 和 StringBuffer 的真实性能差距到底有多大String 的不可变性又究竟意味着什么。这篇文章我打算从底层原理、常用 API、性能特性、并发安全几个维度把这三个类一次讲透。内容基于我这些年在项目里实际踩过的坑和做过的小实验尽量不写教科书式的废话直接给结论、给代码、给场景。无论是准备面试还是日常写业务代码想少踩几个坑都值得往下看。1. 三者本质不可变、可变、线程安全可变先说结论String 是常量字符串底层用 final char[] 数组保存创建后内容不可变StringBuilder 和 StringBuffer 都继承自 AbstractStringBuilder底层也是 char[] 数组但数组没有 final 修饰内容可变支持动态扩容。1.1 String 的“不可变”到底意味着什么很多人对“不可变”的理解停留在“不能改”其实不准确。String 类的 value 数组被 final 修饰是指引用不可重新指向但真正保证不可变的核心是所有看似“修改”的操作比如 concat、replace、substring都不修改原数组而是每次 new 一个新 String 对象返回。我举个例子你感受一下String s hello; String t s.toUpperCase(); System.out.println(s); // 还是 hello System.out.println(t); // HELLOs从头到尾没变过toUpperCase()创建了一个全新的对象。这就是为什么下面这段代码循环拼接会特别慢String result ; for (int i 0; i 10000; i) { result i; // 每次循环都 new 一个新 String }循环一万次可能就要创建上万个中间字符串对象不仅慢还大量占用堆内存触发 GC 频繁。我当年用这段代码处理一个几万行文本拼接直接让接口响应从 200ms 飙到 3 秒后来换成 StringBuilder瞬间降到 30ms 左右。这个差距就是不可变性带来的直接代价。1.2 StringBuilder 和 StringBuffer 的可变原理StringBuilder 和 StringBuffer 都继承自 AbstractStringBuilder底层是char[] value; // 没有 final int count; // 记录有效字符数因为这个 value 数组可重新扩容所以 append 操作会直接往数组里写字符只是数组不够用时才触发扩容复制旧数组到新数组。扩容机制简单说新容量 旧容量 * 2 2不够时直接扩容到所需长度。两者的区别也是很多人最容易记混的点StringBuilder非线程安全方法不加锁性能最高。StringBuffer线程安全核心方法加了 synchronized 锁。也就是说同一时刻只允许一个线程调用 StringBuffer 的修改方法避免了多线程同时写入导致数据错乱。但代价是每次方法调用都要加锁、释放锁哪怕没有竞争也有不可忽略的开销。1.3 三者关系一句话记忆法我自己记这三个类用一句话String 适合不变的字符串StringBuilder 适合单线程拼接StringBuffer 适合多线程拼接。很多人会追问多线程场景是不是必须用 StringBuffer其实不一定。我后面会专门讲多线程下的字符串拼接很多时候用 StringBuilder 加外部同步锁反而更灵活关键看你的并发模型。2. 常用 API 盘点与对比光知道区别还不够实际写代码时 API 的熟练度才真正影响效率。我把三个类常用的 API 放在一起盘一遍重点标出容易踩坑的地方。2.1 字符串内容操作类 API先说基础的取值、比较、查找、替换这些三个类基本一致StringBuffer 和 StringBuilder 继承同样的接口// 长度与判空 int len s.length(); boolean isEmpty s.isEmpty(); // 截取 String sub s.substring(2, 5); // 注意结束索引不包含 // 查找 int idx s.indexOf(abc); int lastIdx s.lastIndexOf(abc); // 替换 String replaced s.replace(a, b); String replacedAll s.replaceAll(\\d, #); // replaceAll 支持正则 // 拆分与拼接 String[] parts s.split(,); String joined String.join(-, parts); // JDK 8内部也是 StringBuilder这里有个特别容易翻车的点substring在 JDK 7 之前是共享原字符串的 char[] 的也就是说子字符串会持有原大字符串的引用如果原字符串非常大只截取一小段可能导致内存无法释放。JDK 7 改成了复制新数组就没这个问题了。但如果你维护老项目看到大字符串截取小段后内存一直下不去就要往这个方向排查。2.2 拼接与反转类 API这是 StringBuilder 和 StringBuffer 的主场StringBuilder sb new StringBuilder(); sb.append(hello) // 追加字符串 .append(123) // 追加数字自动转字符串 .append(true) // 追加布尔 .append(!); // 追加字符 // 链式调用是 StringBuilder 最舒服的地方方法返回 this sb.insert(5, world); // 在指定位置插入 sb.delete(5, 11); // 删除 [5, 11) 区间 sb.deleteCharAt(0); // 删除单个字符 sb.reverse(); // 反转比如用来做回文判断 sb.setCharAt(0, H); // 替换单个字符 // 转成 String String result sb.toString(); // 替换子串注意区间同样左闭右开 sb.replace(0, 5, hi);这些 API 的返回值注意区分String 的 replace、substring 返回新 String原对象不变而 StringBuilder 的 append、delete、insert、reverse 基本上都返回 this原地修改不需要接收返回值。我见过新手把 StringBuilder 的链式调用结果赋给一个新变量然后拿着旧引用去 toString结果自然是空代码排查了半天。2.3 容量相关 APIStringBuilder 和 StringBuffer 的扩容机制决定了容量相关 API 在特定场景下非常重要StringBuilder sb new StringBuilder(); // 默认容量 16 StringBuilder sb2 new StringBuilder(1000); // 指定初始容量避免扩容 int cap sb.capacity(); // 当前数组容量 sb.ensureCapacity(5000); // 预扩容到至少 5000 sb.trimToSize(); // 缩减容量到当前字符数省内存如果你知道要拼接的字符串大概有多长比如日志拼接、SQL 拼接、JSON 序列化一定要指定初始容量或者调用 ensureCapacity 预扩容。原因很简单扩容需要拷贝数组每扩容一次之前的字符就要整体复制一次频繁扩容的成本很高。预估容量能提前分配好直接省掉中间的拷贝。我在处理一个批量生成 Excel 导出的场景时预估每行数据约 200 字符、共 1 万行直接new StringBuilder(2_000_000)比默认容量反复扩容快了近 60%这个优化基本零成本非常值。3. 性能对比真实数据与使用场景决策说了这么多底层原理是时候上实测数据了。我用自己的笔记本做了一个简单的基准测试分别用 String、StringBuilder、StringBuffer 拼接 5 万次短字符串结果很能说明问题。3.1 一个直观的基准测试测试逻辑很简单每个类执行 5 万次拼接最后输出耗时// String 拼接 long start System.nanoTime(); String s ; for (int i 0; i 50000; i) { s a; } long cost System.nanoTime() - start; System.out.println(String 拼接耗时: cost / 1_000_000 ms); // StringBuilder 拼接 start System.nanoTime(); StringBuilder sb new StringBuilder(); for (int i 0; i 50000; i) { sb.append(a); } cost System.nanoTime() - start; System.out.println(StringBuilder 拼接耗时: cost / 1_000_000 ms); // StringBuffer 拼接 start System.nanoTime(); StringBuffer sbf new StringBuffer(); for (int i 0; i 50000; i) { sbf.append(a); } cost System.nanoTime() - start; System.out.println(StringBuffer 拼接耗时: cost / 1_000_000 ms);我跑了三遍取平均值大致结果如下不同机器会有差异但相对关系稳定拼接方式5 万次耗时相对性能String 约 1200 - 2500 ms最慢GC 压力大StringBuilder.append约 2 - 5 ms最快StringBuffer.append约 8 - 15 ms比 StringBuilder 慢 2-5 倍String 的慢不只是量级差距是几百倍的差距。原因前面说过每次都是创建新对象、复制全部旧字符50000 次就是 50000 次复制和 50000 个中间对象。而 StringBuilder 只往同一个数组里写数组扩容次数有限自然快。StringBuffer 比 StringBuilder 慢的部分主要来自 synchronized 的锁开销。如果只有单线程访问这个锁是完全多余的。3.2 为什么别被“String 拼接会被编译器优化”骗了有人可能会说Java 编译器不是会把拼接优化成 StringBuilder 吗没错JLS 里确实有这么一条a b c这种常量编译期就合成 abc 了s x这种在方法内部的简单拼接编译器也会自动转成new StringBuilder().append(s).append(x).toString()。但注意几个前提循环体内部每次迭代都会 new 一个 StringBuilder循环越多次越浪费。跨方法的拼接、条件分支里的拼接编译器未必能形成最优的单实例 StringBuilder。拼接结果被传入其他方法时对象生命周期不可控中间对象照样产生。所以我的实践原则很简单凡是循环内拼接一律手工写 StringBuilder凡是一次性拼接五六行以内的字符串用反而更易读不必矫枉过正。比如String msg 用户ID: userId , 用户名: name , 操作: action;这种就完全没必要用 StringBuilder编译器优化后的效率和你手写差不多可读性还好得多。真正要警惕的是循环、大批量数据、动态拼 SQL 这类场景。3.3 字符串拼接选型决策表根据我的项目经验下面这个决策表基本覆盖 90% 的现实场景场景推荐方案理由固定的字符串、少量拼接String 可读性好编译器优化足够单线程循环大批量拼接StringBuilder 容量预估性能最优无锁开销多线程共享同一个字符串缓冲区StringBuffer简单安全但考虑竞争压力多线程各自拼接再合并各线程 StringBuilder 外部合并并发性能更好锁粒度更细日志框架、ORM 框架内部拼 SQL框架自身用 StringBuilder你只管传参即可这里有个很容易被忽略的点多线程场景下直接全局使用 StringBuffer虽然安全但所有线程都在抢同一把锁竞争激烈时性能可能反而很差。更好的做法是 ThreadLocal 给每个线程一个 StringBuilder或者干脆让各个线程拼好局部字符串最后再合并。这个选择本质上和“用线程安全的集合还是加锁的普通集合”是一样的思路。4. 源码层面的关键机制解析读源码是理解这三个类的最佳路径不要求你把源码背下来但几个关键机制必须清楚。4.1 String 的字符串常量池String 不可变还有一个非常重要的副产品字符串常量池。Java 会缓存一部分字符串比如用双引号直接写的字面量、用String.intern()主动入池的字符串相同内容的字符串可以复用同一个对象。String a abc; String b abc; System.out.println(a b); // true指向常量池同一个对象而new String(abc)则不同它会在堆上创建一个新对象内容相同但地址不同String c new String(abc); System.out.println(a c); // false内容相同但对象不同这也是面试里经常考的与equals的区别。实际开发里我们比较内容永远用 equals比较对象引用用千万别搞混。4.2 StringBuilder 扩容细节与容量增长AbstractStringBuilder 的 append 流程大致是判断当前 count 新增长度是否超过 value.length。如果超过调用 newCapacity 计算新容量。新容量算法是(value.length 1) 2也就是旧容量乘 2 加 2。如果算出来的还是不够直接用所需最小容量。用 Arrays.copyOf 复制到新数组。源码逻辑看起来清晰但我提醒一句扩容拷贝是 O(n) 操作扩容频繁时前面累积的字符会反复被拷贝。举个例子默认容量 16追加到 17 个字符就要扩容到 34追加到 35 又要扩容到 70每次扩容的拷贝量虽然在指数增长但次数少总体还算可控。可如果不指定容量而数据量又很大中间拷贝的字节总量约为最终字节数的两倍左右仍然是有优化空间的。因此凡是能预估长度的场景我建议都显式传容量这算是我自己的铁律。4.3 为什么 StringBuffer 的 append 线程安全StringBuffer 的线程安全很简单粗暴就是给每个修改方法加了 synchronizedpublic synchronized StringBuffer append(String str) { toStringCache null; super.append(str); return this; }锁保证了多线程同时调用 append 时同一时刻只有一个线程能写入其他线程阻塞等待。这能防止两个线程同时在数组的 count 位置写数据避免数据覆盖或数组越界。但问题来了如果多个线程各自持有一个 StringBuffer 的引用然后分别 append 再合并synchronized 就完全没有意义只会白白损失性能。这也是为什么我一再强调选型要看并发结构而不是“多线程”三个字。5. 常见面试题与易错点全解析5.1 “String 是不可变的那 StringBuilder 可变的用途”类问题这类问题通常是变着花样问为什么 String 设计成不可变这么设计有什么好处我梳理了四个核心好处字符串常量池可以安全复用节省内存。作为 HashMap 的 key 时hashCode 可以缓存查找效率高。被多个线程共享时不需要额外同步天然安全。网络传输、文件读取中大量字符串对象被安全传递不会因为某个线程修改而影响其他使用方。面试如果要答得漂亮就按这四条展开配上例子。我自己招人时听到“不可变所以安全”太笼统的回答会觉得候选人理解得不够深。5.2 常见反坑问题速查我整理了一个速查表基本覆盖我见过的所有坑问题原因解决String 循环拼接非常慢每次生成新对象GC 压力大改用 StringBuilder必要时预估容量用 比较字符串内容结果不符合预期 比较的是对象引用用 equals多线程用 StringBuilder 导致数据错乱非线程安全count 竞态用 StringBuffer 或加锁StringBuilder 默认容量不够导致频繁扩容未预估长度new StringBuilder(capacity) 指定容量把 subString 截取后的大字符串无法回收老版本JDK 7 之前共享 char[]升级 JDK 7或 new String(sub)字符串常量池导致内存增长大量 intern() 入池控制 intern 使用范围避免滥用5.3 “为什么 StringBuilder 不是线程安全的” 深度角度面试官如果继续追问你最好还能从内存模型和原子性的角度答出来。StringBuilder 的 count 读写不是原子操作两个线程同时 append 可能同时读到一个旧 count然后一个线程写入 index10 的位置另一个线程也写入 index10 的位置后写的覆盖先写的就丢了数据。另外StringBuilder 扩容时是“读旧容量、算新容量、创建新数组、复制、赋值”整套操作多个线程同时触发扩容可能创建多个临时数组有的线程复制的数据还没被主数组引用等于白干严重时还可能抛出 ArrayIndexOutOfBoundsException。所以如果你要在并发环境里用可变字符串要么用 StringBuffer要么给 StringBuilder 加外部锁要么用 ThreadLocal 隔离到线程内部总之不能让多个线程同时写同一个实例。6. 真实业务场景中的最佳实践这一节完全基于我自己的项目经验专门讲讲这三个类在真实开发中怎么选、怎么用。6.1 动态 SQL 拼接为什么别用 String我早期接手过一个老项目里面有一段动态 SQL 构造大概长这样String sql SELECT * FROM user WHERE 11; if (条件1) { sql AND age age; } if (条件2) { sql AND name LIKE % name %; }数据量小时没什么感觉但查询条件多、循环调用频繁时这段 SQL 拼接不仅慢还容易把堆内存打满。我重构时改成StringBuilder sql new StringBuilder(128); sql.append(SELECT * FROM user WHERE 11); if (条件1) { sql.append( AND age ).append(age); }性能提升立竿见影。其实现在很多公司的框架层都会禁止拼 SQL 用 String甚至代码规范里直接写死“动态拼接一律 StringBuilder”。说实话这种规范是有道理的因为拼 SQL 的字符串通常比较长且可能高频调用。6.2 日志消息构造格式化的取舍项目里的日志消息我见过两种负优化写法一种是无脑用拼接哪怕日志级别是 DEBUG 根本没输出前面的字符串拼接还是执行了。另一种是过度使用 StringBuilder把简单日志写成一大坨 append 链可读性极差。我一般建议用 SLF4J 的占位符而不是手工拼接任何形式logger.debug(用户[{}]请求订单[{}]失败原因: {}, userId, orderId, cause);如果团队手写日志又脱不开至少把日志级别判断放在前面比如if (logger.isDebugEnabled())避免无谓拼接。从另一个角度看日志框架本身的占位符底层也用 StringBuilder 做缓冲我们没必要重复造轮子。6.3 高频 HTTP 接口的响应体构造容量预估的威力有一次我优化一个高频查询接口返回的是一个 JSON 数组每次要拼将近 2000 个对象的字段。原始代码是直接用一层层拼接接口 RT 一直偏高。我的改造分两步第一步把拼接逻辑全部换成 StringBuilder并预先计算一个大致的容量避免频繁扩容。第二步对于固定 JSON 结构即使要拼 2000 条预估每条 150 字节左右直接给 30000 多容量。改造后接口 RT 从 500ms 左右降到接近 200msP99 改善更明显。这个优化不改业务逻辑只改字符串构造方式收益却非常大。我特意强调“大致预估容量”而不是精确计算是因为容量多给一点没事少了还会扩容多给一点反而更省心。当然也别夸大到十倍容量去浪费内存。6.4 文件读写场景的字符串处理技巧读文件、写 CSV、生成报告时如果每行都要拼接同样优先 StringBuilder。但有一点我想单独提醒不要每次读一行就 new 一个 StringBuilder放在循环外面复用每次写完后调用 delete(0, length) 清理或者用 setLength(0) 重置这样可以避免反复分配对象。StringBuilder sb new StringBuilder(256); for (String line : lines) { sb.setLength(0); // 复用缓冲区避免 new sb.append(line).append(\n); // 写入目标 }这个用法在很多高性能 IO 项目里很常见改一行代码就能避免多次 GC属于低投入高回报的细节。7. 总结实践要点与个人经验最后说点实在的。这些东西是我在实际项目中一点点试出来的不是背概念能换来的。第一个经验不要因为追求性能就全面抛弃 String 的。代码的可读性也很重要三五个字符串之间的拼接用没问题编译器会优化。真正需要 StringBuilder 的场景是循环、大批量、需要拼接结果被反复使用的场景。性能优化要有依据不要为了优化而优化盲目的优化有时候比不优化还糟糕。第二个经验容量预估几乎免费但能省下大量扩容拷贝。凡是处理日志、SQL、文件内容、网络报文这些可以估算大小的字符串一定要记得 new StringBuilder(预估长度) 或 ensureCapacity。我在自己的代码里基本形成了习惯写 StringBuilder 时第一反应就是评估大小。第三个经验多线程环境下先想清楚线程模型再决定用 StringBuffer 还是加锁 StringBuilder。孤立的线程安全不如结构性的隔离安全如果每个线程都有自己的 StringBuilder完全不需要 StringBuffer更不需要 synchronized。如果你真的需要在多个线程间共享同一个可更新的缓冲区那 StringBuffer 比你自己加锁更省心因为它已经足够简单可靠。关于这三个类我和很多开发聊过大家都会有自己的一套判断逻辑。但归根到底就是一句话不可变的 String 是交付结果用的可变的 StringBuilder 是生产过程用的而 StringBuffer 是在多线程生产时需要的保险丝。理解到这个层面不管面试怎么问场景怎么换你都能做出正确的选择。
返回列表