Android 面试里,String、StringBuilder 与 StringBuffer 的性能问题最能试出工程功底:它没有标准答案,考的是"先量化、再归因、后验证"的思路。多数候选人败在张口就是结论——"String 慢,用 StringBuilder"——却说不清慢在哪、慢多少、什么时候其实不慢。把三者的区别从一个真实案例里讲透,比背十个概念得分高。
先把结论立住:三者解决的是不同问题
一句话:String 是不可变对象,每次"修改"都产生新对象;StringBuilder 是可变字符序列、非线程安全、速度快;StringBuffer 与 StringBuilder 接口一致,但方法加了 synchronized、线程安全、代价是并发下的同步开销。选型的关键不是"谁快",而是"要不要在线程间共享同一个可变序列"。
这里要先立住一个前提:StringBuilder 的"可变"指的是它内部持有可扩容的字符数组,append 大多在原数组上操作,不每次新建对象。这正是它比 String 拼接快的根本原因;而 StringBuffer 在此基础上多了一层锁,单线程下纯属多余的开销。
机制拆解:不可变、扩容与同步
String 不可变的根因在前一轮已经讲清:所有修改方法返回新对象,原对象冻结。StringBuilder 内部维护一个 char[] 和一个记录已用长度的 count,容量不够时按"旧容量 *2 + 2"扩容并拷贝。默认构造时容量为 16,如果预先知道大概长度,传入初始容量能少几次扩容拷贝。
StringBuffer 几乎逐方法加 synchronized,保证多线程同时 append 不会乱序或丢字符。但代价是:即使只有一个线程在用,每次调用也要走一遍锁的获取与释放。JVM 的偏向锁优化能缓解一部分,但比 StringBuilder 仍慢一截。所以结论很明确:单线程拼接用 StringBuilder,多线程共享同一个可变序列才考虑 StringBuffer——而后者在大多数业务代码里其实用不到,因为字符串拼接大多发生在单个请求或单条线程内。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// String / StringBuilder / StringBuffer 的取舍
String a = "hello";
String b = a + " world"; // 编译期会改成 StringBuilder.append,单次拼接无妨
System.out.println(a == b); // false:产生新对象
// 循环里用 += 是反例:每次都新建 StringBuilder 再 toString
String slow = "";
for (int i = 0; i < 1000; i++) slow += i; // 产生约 1000 个临时对象
// 正确:一次分配,反复 append
StringBuilder sb = new StringBuilder(64); // 预给容量,少扩容
for (int i = 0; i < 1000; i++) sb.append(i);
String fast = sb.toString(); // 只在最后生成一次 String
// 多线程共享才用 StringBuffer
StringBuffer sbuf = new StringBuffer();
sbuf.append("a").append("b"); // 方法加了 synchronized
这段代码值得盯三处:第一处,a + " world" 在单次拼接时编译器本就会优化成 StringBuilder,不必谈之色变;第二处,循环里的 += 才是真问题——每轮都新建再销毁,临时对象数量随循环次数线性增长;第三处,new StringBuilder(64) 预给容量,把扩容拷贝从"可能发生"变成"基本不发生",热路径上这是可观测的差别。
最常见的几个坑
最常见的坑是以为"用 StringBuilder 总比 String 快",于是在单条语句的少量拼接上也层层封装,反而让代码更绕。单次或常量拼接用 + 即可,编译器会处理好;只有循环、多次累积、或长度未知时才值得上 StringBuilder。选型看场景,不看口号。
其次是忽略 StringBuilder 的扩容代价:默认容量 16,追加内容远超时会发生多次翻倍拷贝。若能用 new StringBuilder(int) 给一个贴近实际的上限,扩容次数直接归零。这点在拼大报文、拼长 SQL 时尤其明显。
还有一个更隐蔽的坑:把 StringBuilder 当作线程安全的容器在多线程间共享。StringBuilder 非线程安全,并发 append 会丢字符或顺序错乱,这种情况该用 StringBuffer,或更好的做法是每个线程各自持有、最后合并。别用"单线程思路"去写并发代码。
再一个是用 String 的 intern() 去"优化"内存,却忽略了它把对象塞进常量池的代价与生命周期。大量 intern 在老 JDK 上曾引发永久代 OOM,现代 JDK 虽移到堆上,仍不该滥用。需要去重共享字符串,优先考虑显式的缓存 Map,而不是 blindly intern。
面试中的经典追问
"请简单介绍一下 String、StringBuilder 与 StringBuffer 的区别?"——一句话定位:String 不可变、StringBuilder 可变非线程安全、StringBuffer 可变且线程安全;一个场景佐证:循环拼接用 StringBuilder,单线程足矣;一个边界收尾:StringBuffer 的 synchronized 在单线程下是纯开销。按"结论—依据—边界"三步走最稳。
"StringBuilder 的扩容机制是什么?"——动机是"减少拷贝",机制是容量不够时按"旧容量 *2 + 2"分配新数组并 System.arraycopy,代价是扩容那一次的拷贝成本;所以给初始容量能从根上消掉大部分扩容。讲到这再补一句"默认 16",面试官基本就信你是真用过。
"StringBuffer 既然线程安全,是不是都比 StringBuilder 好?"——这题考的是权衡:线程安全只在"多线程共享同一个实例"时有意义,而字符串拼接大多发生在单线程内,此时 synchronized 纯属开销。真正的高并发场景往往让每个线程各持一个 StringBuilder 再合并,而不是共用一个 StringBuffer。
"你在项目里因为字符串踩过什么坑?"——行为题,拿真实案例说话。比如曾在循环里用 += 拼接日志导致某页卡顿,用 Profiler 抓到大量临时 String 分配,改成预容量 StringBuilder 后帧率回升;或者曾误把 StringBuilder 跨线程共享导致偶发错乱。讲"复现—定位—修复"的结构,比空谈概念有说服力得多。
工程落地:真实项目里怎么用
字符串在项目里无处不在,几个高频落点:一是日志、SQL、JSON 这类"循环或多段累积"场景一律用 new StringBuilder(估略容量),避免临时对象放大 GC;二是 DAO 层拼动态 SQL 时用 PreparedStatement 占位符替代字符串拼接,既防 SQL 注入又顺带解决拼接性能;三是把对外接口返回的字符串视为不可变,不在内部持有后去"期待"它被外部改动;四是把"比较内容用 equals、可能为 null 先判空"做成静态检查规则。
一个稳妥的工程约定:常量或单次拼接用 +,循环与未知长度累积用 StringBuilder 且尽量给初始容量,多线程共享可变序列才用 StringBuffer;把高频出现的字面量提成 static final 常量减少重复对象。把这几条固化,字符串相关的性能与并发坑能少一大半。
给正在准备面试的你
理解三者之后,需要在真实项目里练一遍:写一个循环用 += 拼接一千次,再用预容量 StringBuilder 写一遍,用 Profiler 或简单计时对比两者在对象分配上的差别;再亲手跑 new StringBuilder(16) 与默认构造的扩容次数对比。这两步做下来,比再读三遍资料都管用。
如果只记两句话,就记这两句:第一,循环或多段累积拼接用 StringBuilder 并尽量给初始容量,单次拼接用 + 编译器自己会优化;第二,StringBuffer 的 synchronized 只在多线程共享同一实例时才有价值,单线程下纯属开销。把这两句讲顺,字符串这一关就过了。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android 软件开发面试·从入门到精通」连载系列
上一篇:String-不可变性:为什么字符串要设计成不可变
下一篇预告:面向对象三大特性:封装、继承、多态怎么讲才透
有任何问题欢迎在评论区留言交流。