快手客户端性能组面试有个习惯:不考你多炫的架构,先问字符串拼接。
"App 里有个接口要拼上千条日志,你用+还是 StringBuilder?"——看似送分,答错的人能有一半。
说白了,String、StringBuilder、StringBuffer 三兄弟,初级岗背定义,快手要的是你真的在性能敏感路径上趟过坑。今天把底层结构、性能差异、编译器优化一次性讲清。
一、三者底层到底差在哪
面试官:String、StringBuilder、StringBuffer 区别?
候选人(标准答法):String 不可变,后两个可变;StringBuffer 线程安全,StringBuilder 不是。
深度解析:从存储结构看就明白了。
- String:Java 8 是
private final char value[],Java 9+ 是byte[](紧凑字符串)。不可变,任何"改"都返回新对象。 - StringBuilder / StringBuffer:内部都是一个可变的 char[] 缓冲区,默认容量 16。
append()往里填,不够就扩容。区别只在——StringBuffer 的每个 public 方法都加了synchronized,StringBuilder 没有。
// AbstractStringBuilder 的共同底层 char[] value; // 缓冲区 int count; // 已用长度普通答法 vs 高分答法:
- 普通:StringBuffer 线程安全,所以慢;StringBuilder 快。
- 高分:我会点出"线程安全"的代价是每次方法调用都要拿锁(即使单线程也在空转同步),所以在明确单线程的场景,StringBuffer 是纯粹的性能浪费。快手这种高并发客户端,主线程拼字符串用 StringBuilder,不要无脑 StringBuffer。
有意思的是,很多人不知道:StringBuffer 的toString()在旧 JDK 也是synchronized的,新版本优化掉了,但方法级的锁开销仍在。
二、为什么 += 拼接会慢(性能真相)
本篇第二道核心问答,也是这道题的灵魂。
面试官:那s += "x"到底慢在哪?
深度解析:关键看是不是在循环里。
单条语句String s = a + b + c;,编译器会优化(见第三组),几乎没额外开销。但循环里的+=是灾难:
var s = "" for (i in 0 until 10000) { s += i // 每一轮都发生什么? }展开看每一轮实际干的事:
1. 新建一个StringBuilder;
2.append旧s的内容;
3.append当前i;
4. 调toString()生成新的 String;
5. 旧的s变成垃圾。
也就是说,第 n 轮要复制前 n-1 轮的所有字符。总复制量是 1+2+...+n ≈O(n²)。1 万次循环,临时对象数以万计,GC 直接起飞。
字节码视角:s += i编译后大致是new StringBuilder().append(s).append(i).toString(),每次循环都new一次。这就是为什么慢。
普通答法 vs 高分答法:
- 普通:因为 String 不可变,每次拼接都新建对象,所以慢。
- 高分:我会把"O(n²) 复制量"和"每轮 new StringBuilder + 产生中间 String 垃圾"讲清楚,并对比正确写法只 new 一次、线性复制。量化之后,考官一眼知道你真测过。
三、编译器到底做了什么优化
本篇第三道核心问答,很多人把它和"慢"混为一谈。
面试官:那编译器对+拼接不是有优化吗?
深度解析:有,但只覆盖单条语句。
javac在编译期会把同一个表达式里的+串,自动转成StringBuilder.append的链式调用:
// 源码 String s = a + b + c; // 编译后等价于 String s = new StringBuilder().append(a).append(b).append(c).toString();注意:这里只 new一次StringBuilder,所以单条语句的+性能没问题,别被"String 慢"的谣言吓到。
但循环里每次迭代是独立语句,编译器没法把跨迭代的拼接合并,于是每轮各 new 一次——优化在此失效。
Android 侧补充:R8 / ProGuard 在编译期还会做"字符串常量折叠","a"+"b"直接变成"ab";Kotlin 编译器对字符串模板"$a$b"也是生成 append 链。但没有任何编译器能跨循环合并,这是语义决定的。
高分答法:我会总结一句——"优化救得了单条语句,救不了循环"。判断用不用 StringBuilder,看的是"拼接是否跨多次迭代",而不是"用了几个加号"。
四、容量与扩容:真正的性能细节
面试官:那 StringBuilder 就一定快?不注意容量也白搭吧。
深度解析:对。默认容量只有16,append 超出时会扩容。看AbstractStringBuilder的扩容逻辑:
int newCapacity = (oldCapacity << 1) + 2; // 旧容量 *2 + 2 if (newCapacity - minCapacity < 0) newCapacity = minCapacity; value = Arrays.copyOf(value, newCapacity); // 复制旧数组到新数组意思是:超了就翻倍 +2,然后Arrays.copyOf把老数据整体拷过去。频繁扩容 = 频繁数组拷贝。
实战场景:快手客户端里拼一段接口返回的 JSON 日志,长度可能上千字符。如果你new StringBuilder()默认 16,会经历 16→34→70→142→286→574→1150 多次扩容拷贝。正确做法:
// 预估容量,一步到位,零扩容 val sb = StringBuilder(estimatedLen) for (item in list) sb.append(item.toLogLine())高分答法:我会补一个 Android 专属点——TextView.setText(CharSequence)内部大量用 Spannable/StringBuilder,如果你在onBindViewHolder里反复拼长文本且不设容量,列表滑动就会掉帧。这类细节,性能组考官最爱听。
五、开放追问:多线程到底用谁
面试官:多线程环境拼字符串,该用 StringBuffer 吗?
深度解析:这题有陷阱。StringBuffer 确实线程安全,但线程安全≠该用。
现代写法更推荐:
- 局部变量:每个线程自己的 StringBuilder,根本不需要锁,最快。
- ThreadLocal:复用缓冲区又避锁。
- StringJoiner(Java 8+):专门拼集合,带分隔符:
val j = StringJoiner(",") list.forEach { j.add(it) } val s = j.toString() // a,b,c- Kotlin 的
buildString {}:内部就是StringBuilder,DSL 写法更爽:
val s = buildString { repeat(1000) { append(it) } }高分答法:我的看法是——除非你要在一个被多线程共享的同一缓冲区上并发 append(这种场景极少),否则 StringBuffer 基本是历史包袱。与其加锁不如让每个线程各拼各的、最后合并。这展现的不是 API 记忆,是并发设计意识。
收尾:几条能落地的建议
三兄弟这道题,快手想筛的是"有没有性能体感"。String 不可变不是缺点,是特性;+不是原罪,循环里才是;StringBuilder 不是万能,不设容量照样拉胯。
面试 Tips(具体答法):
- 先讲底层结构(char[] 缓冲区 + 默认容量16),再讲差异,逻辑最顺。
- 被问
+慢,立刻分"单条语句(编译器优化)vs 循环(O(n²))"两情况,别一刀切。 - 提扩容
<<1 +2和Arrays.copyOf拷贝,展示你看过源码。 - 多线程题,反手给 ThreadLocal / StringJoiner / buildString,区分度拉满。
讲真,客户端性能优化八成都在这些"不起眼"的细节里。
---
点赞、在看、转发,三连是对我最大的支持!
「Android 大厂面经·从入门到精通」连载系列
上一篇:第002篇 字节跳动·应届 Android 面试——String 为什么设计成不可变
下一篇预告:第004篇 百度·初级 Android 面试——equals 和 hashCode 的约定
评论区聊聊:你面试遇到过最难的 Android 问题是哪道?
关于本系列:「Android 大厂面经·从入门到精通」是360篇连载系列,覆盖美团、字节跳动、阿里巴巴、快手、百度、京东、华为、小米等30+家公司从 Java 基础到架构师终面的真实面试内容。本篇属于阶段1·入门篇——Java 语言基础。