← 返回博客
2026-10-06 08:00:01

String 拼接与数组转 List 的隐藏坑

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 观察容量变化点。具体字段名随版本可能不同,以你所用版本源码为准。

面试速答

核心收获:拼接看字节码、转 List 看返回类型,两个坑都藏在"看起来一样"的假象里。下一步,把 javap 加进你的日常调试习惯,遇到集合行为诡异先反汇编看一眼。

本文关键词:String 拼接、StringBuilder、Arrays.asList、List.of、UnsupportedOperationException、字节码、javap