资讯详情

资讯详情

Arrays.asList返回值根本不是ArrayList?一文讲透Java定长数组视图的真相

前阵子组里有个同事在 review 代码时指着一行Arrays.asList(a, b, c)问我这玩意儿返回的到底是不是ArrayList为什么我往返回值里add一个元素直接抛UnsupportedOperationException这个问题其实特别典型属于那种平时能用、但一深究就露馅的 Java 基础点。很多刚接触 Java 的开发者包括一些工作了两三年的朋友都会下意识觉得Arrays.asList返回的就是java.util.ArrayList结果在批量初始化集合、处理固定数据时踩坑。这篇就把Arrays.asList这个方法的来龙去脉拆开讲清楚它返回的到底是什么对象、和平时 new 出来的ArrayList有哪些本质差异、哪些操作会报错、哪些操作会悄悄影响原数组以及日常开发中到底应该怎么正确使用它。无论你是准备 Java 面试还是单纯想把基础打牢这篇都适用。1. 先回答标题里的问题Arrays.asList 返回的到底是谁1.1 从看源码开始Arrays$ArrayList 不是一个普通的 ArrayList直接说结论Arrays.asList(...)返回的对象类名确实叫ArrayList但它不是java.util.ArrayList而是java.util.Arrays类里定义的一个私有静态内部类全名是java.util.Arrays$ArrayList。在 JDK 源码里Arrays类的末尾有这样一段代码private static class ArrayListE extends AbstractListE implements RandomAccess, java.io.Serializable { private static final long serialVersionUID -2764017481108945198L; private final E[] a; ArrayList(E[] array) { a Objects.requireNonNull(array); } Override public E get(int index) { return a[index]; } Override public E set(int index, E element) { E oldValue a[index]; a[index] element; return oldValue; } Override public int indexOf(Object o) { return indexOfRange(o, 0, a.length); } Override public boolean contains(Object o) { return indexOf(o) 0; } Override public int size() { return a.length; } Override public Object[] toArray() { return Arrays.copyOf(a, a.length, Object[].class); } SuppressWarnings(unchecked) Override public T T[] toArray(T[] array) { int size a.length; if (array.length size) return Arrays.copyOf(a, size, (Class? extends T[]) array.getClass()); System.arraycopy(a, 0, array, 0, size); if (array.length size) array[size] null; return array; } Override public ListIteratorE listIterator(final int index) { return new ListIteratorE() { // ... }; } Override public SpliteratorE spliterator() { return Spliterators.spliterator(a, Spliterator.ORDERED); } Override public void forEach(Consumer? super E action) { Objects.requireNonNull(action); for (E e : a) { action.accept(e); } } Override public void replaceAll(UnaryOperatorE operator) { Objects.requireNonNull(operator); E[] a this.a; for (int i 0; i a.length; i) { a[i] operator.apply(a[i]); } } }注意看两个关键点第一它继承的是AbstractList不是java.util.ArrayList第二它内部直接用private final E[] a这个数组来存储数据而且这个数组是 final 的。这两个设计直接决定了它和普通ArrayList的行为差异。1.2 为什么 JDK 要单独造一个假 ArrayList有人会问既然Arrays.asList要返回一个 List直接返回java.util.ArrayList不就行了何必多定义一个内部类这个问题要从Arrays.asList的设计初衷说起。这个方法的本意是提供一个数组的 List 视图让开发者可以方便地把数组当作 List 来操作比如传入需要List参数的 API、用增强 for 遍历、调用contains判断是否存在等。它并不是为了给你一个可以自由增删的完整集合。如果直接返回java.util.ArrayList那就必须把数组元素全部拷贝一份到 ArrayList 内部的elementData数组里这既有拷贝开销又会让返回值脱离原数组。而 JDK 的设计者希望asList能零拷贝地复用传入的数组这样改返回值就能同步反映到原数组上——这在某些场景下是一种特性比如你有一个方法接收ListInteger想把已有的int[]先包成Integer[]传进去又不希望多一次内存拷贝。所以内部类Arrays$ArrayList直接把外部传入的数组引用保存下来读操作get、contains、size都是直接在数组上完成的。理解了这一点你再看它的行为就会豁然开朗因为它背后是数组所以大小固定因为它继承了AbstractList但没有重写add、remove所以这两个方法会抛异常因为它是直接持有数组引用所以对元素的修改会穿透到原数组。提示判断一个 List 是不是普通ArrayList不要靠类名要看java.util.ArrayList这个全限定名。Arrays.asList(...).getClass()打印出来是class java.util.Arrays$ArrayList而new ArrayList().getClass()打印出来是class java.util.ArrayList两者完全不同。2. 和普通 ArrayList 的核心差异增删改查上的行为对比2.1 最直观的差异add 和 remove 直接抛异常很多人在Arrays.asList返回的 List 上调用add或remove然后喜提UnsupportedOperationException。原因是AbstractList的add和remove默认实现就是直接抛这个异常而Arrays$ArrayList没有重写它们。看这段代码ListString list Arrays.asList(a, b, c); list.add(d); // 运行时报错UnsupportedOperationException list.remove(0); // 运行时报错UnsupportedOperationException普通ArrayList的add会检查内部数组容量不够就扩容然后在新位置写入元素remove会移动后续元素缩容时甚至会把尾部置 null。这些操作都需要一个可变的、带独立存储的数组作为基础。而Arrays$ArrayList内部那个final E[] a数组引用不能变长度也不能变所以结构性的修改增加、删除天然做不了。这里有个容易忽略的细节clear()方法也会抛异常因为AbstractList的clear()默认实现是removeRange(0, size())而removeRange又依赖remove最终一样走到抛出UnsupportedOperationException的那条路。所以哪怕你想把列表清空也是做不到的。2.2 set 方法可以用但背后动的是原数组与add、remove不同Arrays$ArrayList重写了set方法所以你可以替换某个位置的元素String[] arr {a, b, c}; ListString list Arrays.asList(arr); list.set(1, x); System.out.println(Arrays.toString(arr)); // 输出 [a, x, c]set能用的原因很简单替换元素不改变数组长度只改变某个下标指向的对象这在final数组上完全合法。但注意副作用——因为你操作的是视图修改会直接作用到原数组上。这一点和普通ArrayList完全不同new ArrayList(Arrays.asList(arr))之后再去set原数组纹丝不动因为元素已经被拷贝到 ArrayList 自己的内部数组里了。我在实际项目中见过这样的代码把一组配置项放进数组然后用Arrays.asList包一层传给某个通用处理方法方法内部顺手set了一下结果配置文件对应的数组值被改了后面再读原数组时数据已经变了。这种 bug 不好排查因为报错不会出现只有数据表现异常。2.3 一张表看懂两者差异为了便于记忆我把两者的行为差异整理成表格操作Arrays.asList 返回的 Listnew ArrayList()add(E)抛 UnsupportedOperationException正常可能触发扩容remove(index)抛 UnsupportedOperationException正常移动后续元素clear()抛 UnsupportedOperationException正常清空所有元素set(index, E)正常但会修改原数组正常只修改 ArrayList 内部数组get(index)正常直接读原数组正常读内部数组size()固定为原数组长度动态变化内部存储直接持有外部数组引用零拷贝独立拷贝一份元素到内部数组与外部数组关系共享引用互相影响完全隔离这张表记住一个核心就够了Arrays.asList是数组的 List 视图它把数组包装成 List 的外壳但底子还是那个固定大小的数组普通ArrayList是独立的动态数组它拥有自己的可变存储。理解这一点所有行为差异都能推导出来。3. 三个最容易翻车的坑基本类型数组、共享引用、泛型丢失3.1 int[] 陷阱asList 把整个数组当成一个元素这是 Java 基础面试里老生常谈的坑但还是有很多人往里跳。看这段代码int[] numbers {1, 2, 3}; Listint[] list Arrays.asList(numbers); System.out.println(list.size()); // 输出 1 System.out.println(list.get(0)); // 输出 [I15db9742数组的地址表示为什么不是[1, 2, 3]因为Arrays.asList的签名是public static T ListT asList(T... a)它接受的是泛型可变参数。Java 的泛型只能接收引用类型int是基本类型不能直接作为泛型参数所以整个int[]被当成了一个对象T 被推断为int[]于是列表里只有一个元素——整个数组对象。解决这个问题有两种思路。第一种是把基本类型数组换成包装类型数组Integer[] numbers {1, 2, 3}; ListInteger list Arrays.asList(numbers); System.out.println(list.size()); // 输出 3第二种是用 Java 8 的Arrays.stream把基本类型数组转成流再收集成 Listint[] numbers {1, 2, 3}; ListInteger list Arrays.stream(numbers) .boxed() .collect(Collectors.toList()); System.out.println(list); // 输出 [1, 2, 3]String[]没有这个问题因为String是引用类型可以被泛型直接接收。但只要是int[]、long[]、double[]、char[]这类基本类型数组一律会出现整个数组成为一个元素的诡异现象。顺便说一句List.of(numbers)在 Java 9 上行为类似也会把int[]当成一个元素坑是一样一样的。3.2 共享引用修改返回值会连累原数组前面已经提到过set会修改原数组这里再补充几个容易被忽视的连带情况。第一个是Arrays.asList返回的 List 与Arrays.copyOf的关系。如果你用返回值去做toArray()得到的是独立拷贝不会与原数组共享引用这个相对安全。但如果你直接遍历返回值里的元素并修改对象内部状态比如元素是某个自定义对象的引用那改的就是原数组里同一个对象这种深度共享比set更隐蔽。第二个是元素为可变对象时即使不用set只调用list.get(i).setXxx(...)也会影响原数组。因为 List 里的元素和原数组的元素是同一个引用改的是对象本身而不是列表结构。第三个是Arrays.asList的返回值不能保证序列化兼容。虽然Arrays$ArrayList实现了Serializable但它的序列化形式是按数组元素的序列化形式走的如果你把它写入文件或传到远程再从普通ArrayList反序列化回来行为可能不一致。多数场景不会踩到但如果你在做缓存序列化时要心里有数。3.3 泛型丢失asList 与集合转换的连带问题还有一种情况是泛型在转换过程中悄悄丢失。比如ListString list Arrays.asList(a, b); // 有人想把它转成真正的 ArrayList ArrayListString arrayList (ArrayListString) list; // 运行时报 ClassCastException这里会抛ClassCastException因为list的实际类型是Arrays$ArrayList它不是ArrayList。你不能强转只能用构造器拷贝ArrayListString arrayList new ArrayList(list);另外asList配合subList时也有坑。subList返回的是原 List 的视图在Arrays.asList返回的 List 上调用subList得到的是内部类AbstractList$SubList它同样是视图任何结构修改都可能抛异常而且修改子列表会同步影响原列表。如果你期望子列表是独立副本就会踩到意想不到的连带修改。注意Arrays.asList不接受 null 数组会出现NullPointerException因为源码里第一行就是Objects.requireNonNull(array)。虽然平时不会传 null但在框架代码里反射调用时这种校验可能让你排查半天。4. 从 asList 到真 ArrayList五种安全转换姿势与取舍既然Arrays.asList返回的是固定大小的视图那日常开发中我们怎么安全地拿到一个真正的、可以随意增删的ArrayList下面这几种是我实际用下来比较稳妥的方式。4.1 new ArrayList(Arrays.asList(...))最简单、最经典、兼容性最好的方式ListString list new ArrayList(Arrays.asList(a, b, c)); list.add(d); // 正常 list.remove(0); // 正常原理是ArrayList的构造器ArrayList(Collection? extends E c)会把传入集合的元素逐个拷贝到自己的内部数组里。源码大致是public ArrayList(Collection? extends E c) { Object[] a c.toArray(); if ((size a.length) ! 0) { if (c.getClass() ArrayList.class) { elementData a; } else { elementData Arrays.copyOf(a, size, Object[].class); } } else { elementData EMPTY_ELEMENTDATA; } }注意中间这个c.getClass() ArrayList.class判断如果传入的已经是普通ArrayList就直接复用它的内部数组不用再拷贝如果是Arrays$ArrayList或其他集合就用Arrays.copyOf拷贝一份。这也意味着你new ArrayList(Arrays.asList(...))之后原数组和新的 ArrayList 之间没有任何引用关系互不影响。这也是我在项目里最推荐的一行式写法。4.2 Java 9 的 List.of 与 Stream 写法如果你用的是 Java 9 以上可以用List.of创建不可变列表再拷贝成可变列表ListString list new ArrayList(List.of(a, b, c));List.of返回的是不可变 List内部实现是ImmutableCollections.ListN同样不支持add、remove但它不接受 null 元素、不接受重复元素针对List.of的某些重载等行为更严格。如果你只是需要一个不可变的固定数据集合直接用List.of就好如果需要可变再包一层new ArrayList。Java 8 的 Stream 写法也可以实现而且对int[]这类基本类型数组特别友好ListInteger list Arrays.stream(new int[]{1, 2, 3}) .boxed() .collect(Collectors.toList());collect(Collectors.toList())在 Java 8 里返回的是ArrayList具体实现未严格规定但在 OpenJDK 里是ArrayList在 Java 17 里可能返回不可变列表要注意版本差异。我的建议是如果你明确要可变列表直接写new ArrayList(...)更稳不要依赖Collectors.toList()的隐式实现。4.3 Collections.addAll 和手动循环Collections.addAll是另一种批量添加方式它避免显式创建Arrays.asList对象性能也不错ListString list new ArrayList(); Collections.addAll(list, a, b, c);它的源码逻辑很简单就是遍历可变参数逐个调用list.add(element)public static T boolean addAll(Collection? super T c, T... elements) { boolean result false; for (T element : elements) { result | c.add(element); } return result; }这种方式的好处是没有中间转换对象直接把元素加到目标集合里。缺点是如果要添加的元素已经在一个数组里你还需要先把数组展开成可变参数。它适合拿几个常量初始化一个已有集合的场景。手动循环是最笨也最不容易出错的方式String[] arr {a, b, c}; ListString list new ArrayList(arr.length); for (String s : arr) { list.add(s); }在数组元素很多、且明确不想用Arrays.asList的固定视图特性时这种写法最直白。但实际项目里很少有人会这么写因为new ArrayList(Arrays.asList(arr))一行就搞定了。4.4 源码层面的选择逻辑什么时候用视图什么时候用拷贝我把前面的转换方式总结成一张选择表方便你遇到具体场景时快速决策场景推荐写法原因只是遍历/contains 判断不需要增删Arrays.asList(arr)零拷贝性能好语义清晰需要传给只读 API不希望被改List.of(arr)不可变完全防修改需要可变集合且与数组无关联new ArrayList(Arrays.asList(arr))拷贝独立可自由增删基本类型数组转 ListArrays.stream(arr).boxed().collect(...)避免把数组整体当成元素批量添加常量到已有集合Collections.addAll(list, ...)无中间对象直接添加这里有个关键点如果arr很大new ArrayList(Arrays.asList(arr))会多一次数组拷贝。绝大多数场景无所谓但在性能敏感的代码里如果你只读不写直接保留Arrays.asList(arr)的视图反而更好。这也是为什么很多老手会在代码里刻意保留Arrays.asList而不是一律转成ArrayList——视图有视图的价值拷贝有拷贝的代价关键看你要不要结构性修改。5. 面试与实战从八股文到一线代码怎么答、怎么用5.1 面试官想听到的答题思路Arrays.asList在 Java 面试里出现频率极高基本属于必问基础题。面试官问这个问题通常想看三件事第一你是否真的看过源码。很多人只会背结论asList 返回的是固定大小的 List但如果你能说出java.util.Arrays$ArrayList这个内部类名以及它继承的是AbstractList、内部持有一个final E[] a面试官立刻知道你有源码阅读的习惯。第二你是否理解视图和副本的区别。你不仅要说出asList不拷贝数组还要能举例说明set会修改原数组、add会抛异常。能主动说出这些副作用说明你踩过坑或者思考过。第三你是否知道正确的替代方案。只发现问题不算完你要能给出解决方案new ArrayList(Arrays.asList(...))、List.of、Stream 等。能对比不同方式的适用场景才算真正掌握。一个比较完整的回答可以这样组织先指出返回的是Arrays内部类ArrayList不是java.util.ArrayList然后说明它本质是原数组的 List 视图因此大小固定、不能增删但可以set且会影响原数组最后补充如果需要一个真正独立的ArrayList用new ArrayList(Arrays.asList(...))包装一层并提到int[]这种基本类型数组会被整体当作一个元素的坑。5.2 实战中我怎么用 Arrays.asList说句实在话虽然Arrays.asList有不少坑但它绝不是坏 API关键是用对地方。我在项目里最常见的几种用法一是配合第三方接口做参数转换。比如某个方法签名是ListInteger ids我已经有一个Integer[]直接用Arrays.asList(ids)传过去省掉一次拷贝。二是用contains做快速判断。固定枚举集合用Arrays.asList包一层比写一串if分支整洁得多// 判断某个状态是否属于终态 private static final ListString TERMINAL_STATES Arrays.asList(FINISHED, FAILED, CANCELLED); public boolean isTerminal(String state) { return TERMINAL_STATES.contains(state); }注意这里TERMINAL_STATES是视图但因为代码只读不写所以很安全。为了保险也可以考虑用Set.of做成员判断contains的复杂度更低语义也更合适。三是和Collections工具类配合把asList的返回当作只读数据源。比如用Collections.unmodifiableList再包一层彻底杜绝外部修改ListString readonly Collections.unmodifiableList(Arrays.asList(a, b, c));5.3 一个实战案例从数组初始化配置项的隐患最后分享一个最近处理的问题。有个服务启动时要加载一批白名单 IP代码是这样写的String[] whitelist loadWhitelist(); ListString list Arrays.asList(whitelist);问题出在后面的逻辑里有一段代码在某种条件下执行list.remove(ip)来临时禁用某个 IP结果运行时直接抛UnsupportedOperationException。因为whitelist是从配置文件加载的数组asList返回的是固定大小视图根本不能删元素。修复方式也很简单把初始化改成ListString list new ArrayList(Arrays.asList(loadWhitelist()));这样list是一个独立的ArrayList后续增删都不影响原数组也不会抛异常。这类问题在代码 review 时很容易漏掉因为肉眼看到的是ListString很难立刻分辨它后面的真实实现是Arrays$ArrayList还是ArrayList。这也提醒我们接口类型会隐藏实现差异遇到只增不减的集合操作时要多想一步它的真实类型。5.4 再分享一个很小但实用的排查技巧如果你在排查某个UnsupportedOperationException又想确认是不是Arrays.asList导致的可以直接看异常栈。栈信息里会明确显示Exception in thread main java.lang.UnsupportedOperationException at java.base/java.util.AbstractList.add(AbstractList.java:153) at java.base/java.util.AbstractList.add(AbstractList.java:111) at ...看到AbstractList.add这一行基本就锁定是某个继承AbstractList但没有实现add的不可变/定长 List 在作怪。常见的除了Arrays$ArrayList还有List.of返回的ImmutableCollections、Collections.unmodifiableList包装后的对象它们的共同特征都是把操作委托给一个不允许结构修改的底层结构。记住这个套路排查效率会高很多。我在实际操作中的体会是这类基础 API 的坑很多时候不是你不会用而是没意识到同一个接口名背后可能是完全不同的实现。Arrays.asList和普通ArrayList的名字里都带 ArrayList但一个是定长数组视图一个是动态扩容容器。写代码时多看一眼返回类型、多留意集合操作是否涉及增删很多线上问题根本不会发生。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →