String 拼接与数组转 List 的隐藏坑
线上有个接口,日志里频繁出现 UnsupportedOperationException,堆栈指向一行看起来人畜无害的代码:Arrays.asList(arr).add(x)。开发觉得"我明明转成 List 了,为什么不能加元素"。这类问题面试官特别爱问,因为它能一路追到字节码、扩容策略和集合设计取舍。这篇就把 String 拼接和数组转 List 这两块挖透。
手把手实操
先建一个类,把几种拼接和转换都写一遍,编译后看字节码。
public class Probe {
public String concatInLoop(String[] parts) {
String s = "";
for (String p : parts) {
s += p;
}
return s;
}
public String concatWithBuilder(String[] parts) {
StringBuilder sb = new StringBuilder();
for (String p : parts) {
sb.append(p);
}
return sb.toString();
}
public void listProbe() {
Integer[] arr = {1, 2, 3};
java.util.List<Integer> a = java.util.Arrays.asList(arr);
java.util.List<Integer> b = java.util.List.of(arr);
a.set(0, 99); // 能跑
// a.add(4); // 抛 UnsupportedOperationException
// b.set(0, 99); // 抛 UnsupportedOperationException
}
}
编译并反汇编:
javac Probe.java
javap -c -p Probe.class
问题是什么?你想确认两件事:循环里 s += p 到底生成了什么;Arrays.asList 返回的到底是不是 ArrayList。跑完 javap 你会看到 concatInLoop 里每次循环都 new StringBuilder,而 concatWithBuilder 全程只有一个。listProbe 里 Arrays.asList 返回的类型是 java.util.Arrays$ArrayList,不是 java.util.ArrayList。
关键点在两个 ArrayList 同名但不同类。Arrays$ArrayList 是 Arrays 的私有静态内部类,内部直接持有原数组引用,所以 set 能改、add/remove 直接抛异常。List.of 返回的是 ImmutableCollections 里的不可变实现,连 set 都不让。踩坑点:Arrays.asList 改元素会同步改到原数组,很多人以为转出来的是副本。
看源码及解析
StringBuilder 扩容。 StringBuilder 继承 AbstractStringBuilder,真正存字符的是 byte[] value(JDK 9 起改为紧凑字符串,Latin-1 字符一个字节,否则 UTF-16 两个字节,具体以你所用版本源码为准)。append 最终走到 AbstractStringBuilder.ensureCapacityInternal,容量不够时调 newCapacity:
// AbstractStringBuilder(JDK 8 语义)
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
也就是默认翻倍再加 2。为什么是"翻倍 + 2"而不是固定步长?翻倍让均摊复杂度保持 O(1),加 2 是为了处理初始容量为 0 或 1 时翻倍仍然是 0 的边界。无参构造 new StringBuilder() 初始容量是 16,这是 AbstractStringBuilder 的默认值。如果你能预估长度,直接 new StringBuilder(n) 能省掉多次数组复制。
循环拼接的字节码。 s += p 在 JDK 8 编译后等价于每次循环 new StringBuilder().append(s).append(p).toString()。JDK 9 起 javac 改用 invokedynamic 调用 StringConcatFactory.makeConcatWithConstants,把拼接策略推迟到运行时,理论上可以少建临时对象,但在循环体内每次迭代仍是一次独立的拼接调用,编译器不会自动把整个循环优化成一个 builder。所以循环拼接该手写 StringBuilder 还是得手写。StringBuffer 与 StringBuilder 方法签名几乎一致,区别是前者每个方法加了 synchronized。单线程下 StringBuffer 白白付出锁开销,这就是它被 StringBuilder 取代的原因。
Arrays.asList 的设计动机。 它返回的 Arrays$ArrayList 只是数组的视图,不复制数据,省内存也省时间。代价就是长度固定,add/remove 继承自 AbstractList 直接抛 UnsupportedOperationException。List.of 更进一步,元素和长度都不可变,还禁止 null。要可变列表,老老实实 new ArrayList<>(Arrays.asList(arr))。
验证方法
跑 javap -c -p Probe.class,在 concatInLoop 的输出里数 new 指令出现几次——循环体内每次迭代都会执行一次 StringBuilder 构造。再对比 concatWithBuilder,整个方法只有一次 new。
listProbe 里把 a.add(4) 的注释打开,运行会看到异常信息里类名是 java.util.Arrays$ArrayList,这就是最直接的证据。再打印 a.getClass().getName() 和 b.getClass().getName(),两个类名不一样,行为差异的根就在这。
想验证扩容,可以反射读 StringBuilder 的 value 数组长度,或者用 new StringBuilder(4) 反复 append 观察容量变化点。具体字段名随版本可能不同,以你所用版本源码为准。
面试速答
s += p在循环里每次迭代新建StringBuilder,JDK 9 起改走invokedynamic,但循环内仍不合并,手写 builder 才稳。StringBuilder扩容是旧容量 * 2 + 2,默认初始 16;StringBuffer方法带synchronized,单线程别用。Arrays.asList返回Arrays$ArrayList,是数组视图,set改原数组、add/remove抛异常;List.of完全不可变且禁 null。- 要可变列表用
new ArrayList<>(Arrays.asList(arr))。
核心收获:拼接看字节码、转 List 看返回类型,两个坑都藏在"看起来一样"的假象里。下一步,把 javap 加进你的日常调试习惯,遇到集合行为诡异先反汇编看一眼。
本文关键词:String 拼接、StringBuilder、Arrays.asList、List.of、UnsupportedOperationException、字节码、javap