
写代码这么多年数组是我见过最容易被忽视、却又最常用的数据结构。而说到Java里数组操作的帮手java.util.Arrays工具类绝对排在第一位。很多人对它的印象停留在“一个会排序的工具类”实际上它的能力远不止于此排序、二分查找、复制、填充、内容比较、哈希、转集合、并行计算几乎覆盖了数组操作的所有高频场景。今天这篇就围绕Arrays工具类把所有常用方法、源码设计思路、以及那些容易踩进去的坑一次说透。这篇内容适合谁准备Java面试的开发者、刚入门想系统掌握数组操作的新手、以及写了不少代码但没用过parallelSort这类高级方法的老手。你不用担心基础问题我会从“为什么需要这个方法”讲到“底层是怎么实现的”再给出一线实战中最常见的坑和修复方式尽量做到看完就能直接用。1. 先弄清Arrays工具类的定位和核心价值1.1 没有Arrays之前操作数组有多痛苦我刚开始写Java那会儿最烦的就是数组。倒不是因为它功能弱恰恰相反数组本身非常强大连续内存、随机访问O(1)、缓存友好但它最大的问题是“操作太原始”。想打印一个数组看看内容直接System.out.println(arr)输出的是[I4554617c这种对象地址根本看不到里面是什么。想比较两个数组的内容是否相等用equals比较的是引用地址两个内容完全一样的数组照样返回false。想给数组排序没有内置方法要么自己写冒泡、快排要么费劲转成List再借助Collections.sort。想复制一部分数据得手写循环一个元素一个元素地拷。想找一个元素的下标只能遍历效率还低。这些问题不是某一个开发者遇到的而是整个Java生态里普遍存在的痛点。所以JDK很早就提供了java.util.Arrays这个工具类把所有高频数组操作集中成静态方法一行调用就能解决。从设计上看Arrays类本身是一个不可实例化的纯工具类构造方法是private的所有方法都是static。这一点和Collections工具类是同一个套路。它不持有状态只是把算法封装成静态方法暴露出来所以你在用的时候不需要new直接Arrays.xxx()就可以了。1.2 按功能拆解Arrays到底能做什么Arrays类的方法数量不少但如果按功能归类可以清晰分为下面这几组。我用一张表把常用方法列出来方便你对照查阅功能分类代表方法核心作用排序sort、parallelSort升序排序、区间排序、自定义比较器排序、并行排序查找binarySearch在有序数组中二分查找返回下标未命中返回负数插入点复制copyOf、copyOfRange复制整个数组或区间自动扩容并按类型补默认值填充fill用指定值批量填充数组或区间比较equals、deepEquals比较一维数组内容、多维数组深层内容哈希hashCode、deepHashCode基于内容计算数组哈希值转字符串toString、deepToString把数组内容转成可读字符串转集合asList把数组包装成固定长度的List视图并行/流式parallelPrefix、setAll、parallelSetAll、spliterator、stream批量赋值、前缀计算、转Stream这种分组的价值在于你不需要死记硬背几十个方法只要记住“我现在要做什么操作”就能对应到具体的方法组里。下面我挑最核心的几组方法逐个展开讲清楚用法、原理和坑。2. 排序与查找最高频的两类方法实操2.1 sort排序四种姿势一次说清排序是Arrays里用得最多的功能。很多人只知道Arrays.sort(int[])能全排但实际它有四种常见用法覆盖了绝大多数业务场景。第一种对整个数组升序排序最简单int[] arr {5, 2, 9, 1, 7}; Arrays.sort(arr); // arr变成 [1, 2, 5, 7, 9]第二种只对数组的某一段区间排序。这个在数据处理时很常用比如只排前10个元素或者只排从第2个到第5个之间的元素int[] arr {5, 2, 9, 1, 7, 8, 3}; // 只对下标1到4之间的元素排序注意toIndex是排他性的不包含下标5 Arrays.sort(arr, 1, 5); // 结果[5, 1, 2, 7, 9, 8, 3]区间内部的顺序变了其他位置不动这里我要强调一个新手最容易搞混的点sort(arr, fromIndex, toIndex)中的fromIndex是包含的toIndex是排他的。也就是说排序的下标范围是[fromIndex, toIndex)。这个设计和substring、copyOfRange保持一致都是左闭右开。写的时候要格外小心一旦写错看起来不报错但结果不对排查起来很痛苦。第三种对对象数组排序配合Comparator使用。比如对一个学生数组按成绩升序排列Student[] students { new Student(张三, 82), new Student(李四, 95), new Student(王五, 73) }; Arrays.sort(students, Comparator.comparing(Student::getScore));如果要降序就加上reversed()Arrays.sort(students, Comparator.comparing(Student::getScore).reversed());多条件排序就用thenComparing链式调用。比如先按成绩降序成绩一样的再按姓名升序Arrays.sort(students, Comparator.comparing(Student::getScore) .reversed() .thenComparing(Student::getName));这种写法看起来优雅但要注意一个隐藏问题如果学生的成绩有null值Comparator.comparing会直接抛NullPointerException。我自己就踩过这个坑有个历史数据里某个学生的成绩字段是空的排序直接炸了。解决办法是先用Comparator.nullsLast或nullsFirst包一层Arrays.sort(students, Comparator.comparing(Student::getScore, Comparator.nullsLast(Double::compare)) .reversed());第四种是JDK 8引入的parallelSort。对于大数据量的数组它会利用多核并行排序速度明显优于单线程的sort。关于这个方法的原理和阈值我在第6章会专门展开讲。这里顺便说一下底层原理。对于int[]这类原始类型数组Arrays.sort使用的是双轴快速排序Dual-Pivot Quicksort。这个算法是JDK 7引入的平均时间复杂度O(n log n)最坏情况下有优化保护不会像普通快排那样退化成O(n²)。对于Object[]数组使用的是TimSort一种结合了归并排序和插入排序的稳定排序算法。为什么对象数组用TimSort因为对象排序通常是稳定的相等元素的相对位置不变而双轴快排是不稳定的。所以面试官如果问你“为什么int数组排序和对象数组排序算法不一样”这就是答案。2.2 binarySearch查找返回值藏着大坑binarySearch是配套sort使用的查找方法。前提条件很明确数组必须先排序。因为二分查找的原理就是每次取中间值和目标比较从而排除一半的数据如果数组无序整个逻辑全部失效。基本用法int[] arr {1, 3, 5, 7, 9}; int index Arrays.binarySearch(arr, 5); // index 2找到了 int missing Arrays.binarySearch(arr, 6); // missing -4这里最大的坑就是当元素不存在的时候返回值不是一个简单的-1而是一个负数插入点。具体规则是-(insertion point) - 1其中insertion point是目标值应该插入的位置。拿上面的例子说6应该插入到下标3的位置插在7前面所以返回值是-(3) - 1 -4。如果你直接判断index -1来代表“没查到”那么6这种情况返回-4判断就失效了。正确的判断逻辑应该是int index Arrays.binarySearch(arr, 6); if (index 0) { // 找到了index就是下标 } else { // 没找到可以通过 -(index) - 1 得到应该插入的位置 int insertionPoint -index - 1; }这个设计其实是严谨的负数返回值同时传达了两层信息——没查到以及如果要插入应该插在哪。理解了设计意图就不会觉得这个负数是多余的。另外还有两点要注意。第一如果数组中存在多个相同元素binarySearch不保证返回的是哪一个下标。比如数组{1, 3, 3, 3, 5}查3返回的可能不是第一个3的位置。第二binarySearch对对象数组同样可以使用但需要传入对应的Comparator比如按字符串长度排序并查找String[] names {a, ab, abc, abcd}; Arrays.sort(names, Comparator.comparingInt(String::length)); int index Arrays.binarySearch(names, abc, Comparator.comparingInt(String::length));2.3 自定义对象排序的Comparator实战上面提到了Comparator的链式调用这里单独展开讲一下因为实际业务里几乎不可能只按一个字段排序。假设有一个订单数组需要先按下单时间倒序再按金额倒序最后按订单号升序Order[] orders ...; Arrays.sort(orders, Comparator.comparing(Order::getCreateTime) .reversed() .thenComparing(Order::getAmount, Comparator.reverseOrder()) .thenComparing(Order::getOrderNo));这里有个细节值得注意Comparator.comparing(...).reversed()是对第一个字段整体反转而后面的thenComparing又可以用Comparator.reverseOrder()实现倒序。如果你写Comparator.comparing(Order::getCreateTime).reversed()它会创建一个新的Comparator直接对整个链的第一个key反转不会影响后面链的排序方向。这一点如果没理解透容易出现“第一个字段降序了但第二个字段也莫名变成降序”之类的困惑。还有一个很实用的小技巧用java.util.Comparator的静态方法组合排序时如果你要处理null值推荐在链头就包一层Comparator.nullsFirst(...)或者Comparator.nullsLast(...)。例如Arrays.sort(students, Comparator.nullsLast( Comparator.comparing(Student::getScore, Comparator.nullsLast(Integer::compare)) .reversed()));这样不管学生对象本身是null还是score字段是null都不会直接抛NPE而是按规则排到最后面。这种写法第一次看觉得复杂但在真实的数据清洗场景里非常实用。3. 复制、填充与转字符串数组处理的日常三件套3.1 copyOf与copyOfRange扩容复制的最佳拍档数组的长度是固定的不能动态扩容。但日常写代码时“往数组里追加元素”的需求又非常普遍。copyOf就是用来解决这个问题的。String[] oldArr {a, b, c}; // 扩容到5后面两个位置补null String[] newArr Arrays.copyOf(oldArr, 5); // newArr {a, b, c, null, null}这是ArrayList底层扩容的核心思想。你去看ArrayList.add()的源码当内部容量不够时它调用的就是Arrays.copyOf(elementData, newCapacity)。理解了copyOf基本上也就理解了ArrayList扩容机制的一多半。copyOfRange则用于复制数组的某一段int[] arr {1, 2, 3, 4, 5, 6}; // 复制下标1到4之间的元素结果是 {2, 3, 4} int[] sub Arrays.copyOfRange(arr, 1, 4);这里再次提醒from包含to排他。关于copyOf有几个细节需要讲清楚。第一复制数组时如果newLength大于原数组长度多出来的位置会按类型自动补默认值。int补0boolean补false对象类型补null。这一点看起来简单但有人会误以为扩容后补的是0值的“空对象”结果遍历时对元素调用方法直接NPE。第二copyOf是浅拷贝。如果数组存的是引用对象复制出来的新数组和旧数组指向的是同一批对象。你通过新数组修改了某个对象的属性旧数组里的对应对象也会跟着变。很多人以为copyOf是深拷贝这是个常见的误解。要真正实现深拷贝你需要序列化或者逐个克隆对象copyOf做不到。第三还有一个容易忽略的性能点。Arrays.copyOf本质上调用的是System.arraycopy这个native方法它利用内存直接搬运数据效率远高于手写for循环逐元素赋值。所以复制数组的时候优先用copyOf别自己写循环。3.2 fill填充批量赋值的高效姿势fill方法用法很直白就是给数组的每个元素赋同一个值。它的常用场景有两个一个是初始化二维数组另一个是测试数据的准备。比如你要创建一个3行4列的布尔二维数组默认全falseboolean[][] visited new boolean[3][4]; for (boolean[] row : visited) { Arrays.fill(row, false); }这里有一个很多人不知道的细节Java创建二维数组后每一行的boolean[]默认就是false所以上面的for循环其实是多余的。但如果是一位数组一维数组已经赋过值你要重置全部状态fill就非常有用int[] counters {3, 5, 9, 12, 6}; Arrays.fill(counters, 0); // 全部归零变成 {0, 0, 0, 0, 0}fill也支持区间填充int[] arr new int[10]; Arrays.fill(arr, 2, 6, 7); // 下标2到5的位置填充7其他位置保持0这个在算法题里很常用比如你要把数组的一段区间快速重置为某个初始状态就不用手写循环了。3.3 toString和deepToString打印数组的正确姿势数组不能直接打印这个问题我开头提过。原生数组打印出来的是[I4554617c这样的地址串非常不直观。Arrays.toString()是解决这个问题的最简单方式int[] arr {1, 2, 3}; System.out.println(Arrays.toString(arr)); // 输出 [1, 2, 3]但如果你遇到的是二维数组问题就来了int[][] matrix {{1, 2}, {3, 4}}; System.out.println(Arrays.toString(matrix)); // 输出 [[I1540e19d, [I677327b6]打印出来的还是一堆地址。原来的每一个一维数组都是对象直接toString又变成了地址。这时候要用deepToStringSystem.out.println(Arrays.deepToString(matrix)); // 输出 [[1, 2], [3, 4]]我平时调试代码的惯例是一维数组用toString多维数组用deepToString。这个习惯能省下大量排查时间。毕竟调试的效率很大程度上取决于日志的可读性如果你每次打印数组都是一串地址那等于没打。4. equals、hashCode与deep系列内容比较的本质4.1 为什么数组的equals不能用数组是对象但它没有重写Object.equals所以两个数组用equals比较的是引用。内容完全一样的两个数组用equals比较结果是falseint[] a {1, 2, 3}; int[] b {1, 2, 3}; System.out.println(a.equals(b)); // 输出 false要比较两个一维数组的内容应该用Arrays.equalsSystem.out.println(Arrays.equals(a, b)); // 输出 trueArrays.equals会先比较长度长度不同直接返回false长度相同则逐个元素调用比较对对象调用其equals。所以对于String[]这类数组Arrays.equals会比较字符串的内容而不是引用。多维数组就麻烦一些。如果直接用Arrays.equals比较二维数组它比较的是“外层引用数组”的元素也就是每个一维数组的引用所以还是会得到false。这时要用Arrays.deepEqualsint[][] m1 {{1, 2}, {3, 4}}; int[][] m2 {{1, 2}, {3, 4}}; System.out.println(Arrays.deepEquals(m1, m2)); // 输出 truedeepEquals会递归比较所有层级的元素无论是几维数组都能正确比较内容。这个规律可以概括为一句话一维用equals多维用deepEquals。4.2 hashCode与数组的哈希哈希值和比较往往成对出现。Arrays.hashCode基于数组内容计算哈希码和Arrays.equals搭配使用以保证内容相同的数组拥有相同的哈希码。比如把数组放进HashSet或当作HashMap的key时就必须用基于内容的哈希。有个小细节值得提一下为什么Object.hashCode对数组不好使而Arrays.hashCode可以因为Object.hashCode对数组使用的是默认实现基于对象地址。而Arrays.hashCode的算法是按照元素的值累加的比如int数组的哈希算法大致是31 * 累积值 当前元素这样内容相同的数组无论创建了几次哈希值都一样。多维数组的哈希同样要递归处理对应的方法是Arrays.deepHashCode。如果你重写了deepEquals那么一定也要重写deepHashCode这是Java约定的一致性要求两个对象相等哈希值必须相同。关于数组哈希实战中还有一个比较冷门但实用的点数组没有重写hashCode方法所以直接用数组对象做HashMap的key是一个非常糟糕的设计。你存入时的数组和查找时的数组如果不是同一个对象哈希值会不同导致查不到数据。如果业务上确实需要数组做key正确做法是用包装类List或者把数组内容拼接成字符串。这一点在面试题“如何正确使用数组作为HashMap的key”里经常被拿出来考。5. asList与集合转换数组和集合之间的桥梁和陷阱5.1 Arrays.asList的真实身份一个不让你增删的ListArrays.asList是数组和集合之间的重要桥梁。它的基础用法很简单String[] names {a, b, c}; ListString list Arrays.asList(names);但这个list和我们平时用new ArrayList()创建的列表有一个本质区别它不是java.util.ArrayList而是Arrays内部定义的一个私有静态类Arrays$ArrayList。这个内部类继承了AbstractList实现了List接口但它没有重写add和remove方法。当你调用list.add(d)时会走到AbstractList的默认实现直接抛UnsupportedOperationException。为什么会这样设计因为这个List只是一个“数组的视图”它把数组包装成了List的样子底层指向的还是同一个数组对象。它允许你通过list.set(index, value)修改元素而且修改会直接作用于原数组但它不允许改变长度因为数组的长度是固定的。理解了这层设计下面这些现象就都说得通了String[] arr {a, b, c}; ListString list Arrays.asList(arr); list.set(0, x); System.out.println(arr[0]); // 输出 x修改List会影响原数组 list.add(d); // 抛异常 UnsupportedOperationException如果你需要的是一个真正独立、可增删的ArrayList正确做法是用构造器拷贝一份ListString list new ArrayList(Arrays.asList(arr));这时新创建的ArrayList会复制一份元素到自己的内部数组和原数组不再有引用关系。修改它不会影响原数组增删也都没问题。还有一个和JDK版本相关的坑。从Java 9开始提供的List.of是一个不可变List它同样不允许增删但和Arrays.asList有区别。List.of不允许null元素会抛NullPointerException并且底层不是数组视图修改元素本身也会抛异常而Arrays.asList允许null元素也允许修改单个元素。如果你要用不可变列表优先考虑List.of它的语义更严格也更安全。5.2 基本类型数组转换的经典陷阱这是我在实际辅导新人时经常讲的一个坑。int[]不能直接转成ListIntegerint[] arr {1, 2, 3}; Listint[] list Arrays.asList(arr); // 编译通过但list里只有一个元素这个元素就是整个arr数组问题的根源在于泛型的机制。asList接收的是泛型参数T...而int是原始类型不能作为泛型参数所以T被推断成了int[]最终得到的是Listint[]而不是ListInteger。整个数组变成了一个元素。解决办法有很多最简单的是用包装类型声明数组Integer[] arr {1, 2, 3}; ListInteger list Arrays.asList(arr);如果数组已经是int[]不想改类型可以用Java 8的Stream完成装箱int[] arr {1, 2, 3}; ListInteger list Arrays.stream(arr).boxed().collect(Collectors.toList());这里我强烈建议业务代码里尽量用ListInteger而不是int[]来存数据集合除非你是做底层算法、对内存占用极其敏感的场景。因为集合的操作能力完全碾压原生数组而数组的优势只在特定场景下才会体现。5.3 List转数组的正确姿势反向转换同样有讲究。最常规的写法是ListString list new ArrayList(); list.add(a); list.add(b); String[] arr list.toArray(new String[0]);很多新手会问“为什么传入的是new String[0]而不是new String[list.size()]”这其实是一个性能优化的约定。在JDK 8及以后toArray(T[] a)的源码里有一个优化逻辑如果传入的数组长度小于集合的size它不会用你传的数组而是通过反射按正确长度重新创建一个新数组。所以传入new String[0]时实际上会给你创建一个大小刚好的新数组不需要额外调整传入过长数组则会有多余空位补null浪费空间。传0长度的写法既简洁又高效。也有人说传new String[list.size()]可以避免一次数组创建在某些老版本JDK里有这个考虑但现代JDK的写法已经推荐toArray(new String[0])了。还有一个坑List.toArray()无参版本返回的是Object[]不能强制转换成String[]会抛ClassCastException。所以必须用带泛型参数的版本Object[] objArr list.toArray(); // 得到Object[] // String[] strArr (String[]) list.toArray(); // 运行时异常禁止这样写 String[] strArr list.toArray(new String[0]); // 正确6. 并发增强与面试高频坑盘点6.1 parallelSort大数据量排序的性能选择JDK 8给Arrays带来了并发能力的增强最典型的就是parallelSort。用法和sort几乎一样只是内部实现走了并行int[] bigArr new int[10_000_000]; // 填充随机数... Arrays.parallelSort(bigArr);它的原理是用ForkJoinPool把数组拆分成几个子区间每个区间独立排序然后再归并。但并不是所有场景都适合用parallelSort。源码里有一个阈值判断当数组长度小于某个阈值时默认是8192parallelSort会退化成普通的sort。因为拆分任务、调度线程本身有开销数据量太小的话并行反而更慢。所以我的建议是数据量小于一万级别用普通sort就行几十万、上百万级别的大数组才值得考虑parallelSort。另外要注意并行排序的最终结果虽然和普通排序一致但它使用的是ForkJoin公共线程池。如果你的应用本身就在大量使用ForkJoin任务并行度会被抢占实际提升可能不如预期。还有parallelSetAll和setAll这两个方法可以批量计算数组元素的值。比如初始化一个数组下标i处的值是i的平方int[] squares new int[10]; Arrays.setAll(squares, i - i * i); // squares {0, 1, 4, 9, 16, 25, 36, 49, 64, 81}parallelSetAll则用并行方式做同样的事。这种写法比for循环简洁而且在海量数据时能利用多核。parallelPrefix是一个比较冷门但很有意思的方法。它可以对数组做前缀计算。比如对数组{1, 2, 3, 4}做累加前缀int[] arr {1, 2, 3, 4}; Arrays.parallelPrefix(arr, (a, b) - a b); // arr变成 {1, 3, 6, 10}第0个元素不变第1个是前两个之和第2个是前三个之和以此类推。这个操作在计算累积和、最大前缀、前缀最大值等场景非常高效。并行版本在处理百万级数据时速度提升很明显因为它把前缀计算拆成了多个并行段再处理段与段之间的衔接。6.2 面试官最爱的几个Arrays问题聊完了实操我想再集中盘点几个面试高频考点。这些都是我在各个技术社区里反复看到的问题也是判断一个人对Arrays工具类掌握程度的试金石。第一个问题为什么Arrays.asList返回的List不能调用add和remove答案的核心是它返回的是Arrays内部类继承了AbstractList但没有重写可变结构的方法。add和remove调用的是AbstractList的默认实现直接抛UnsupportedOperationException。更深一层的原因是这个List的底层就是原来的数组数组长度不可变所以任何改变长度的操作在语义上都不可能实现。但set方法可以正常使用因为它只是修改已有元素不改变数组长度。第二个问题Arrays.asList和List.of有什么区别一句话总结asList返回的是可修改但不可增删的数组视图List.of返回的是完全不可变的List。asList允许null元素List.of任何元素是null都会抛NullPointerException。另外List.of是Java 9才有的如果项目还在用Java 8只能用asList或者Guava的ImmutableList。第三个问题binarySearch返回负数代表什么答案是代表没找到并且负数经过-index - 1换算可以得到插入点。这个设计和Collections.binarySearch保持一致。面试中如果你能直接说出“-(insertion point) - 1”这个公式并且解释清楚为什么这样设计面试官通常会认为你读过源码。第四个问题为什么比较二维数组内容要用deepEquals而不用equals因为equals对多维数组只比较外层引用内层的一维数组还是对象比的是地址。deepEquals会递归到最底层元素逐一比较。注意deepEquals不能混用它要求所有参与比较的数组元素类型一致如果你用deepEquals比较两个int[]一维数组结果会和equals一样因为一维数组没有嵌套层级语义上等价。这些问题的共同点在于它们考的都不是API用法本身而是方法的设计和语义。搞明白了为什么这样设计面试题就成了送分题。6.3 我实际排查中遇到过的Arrays相关线上问题最后分享一个真实的案例。之前我维护一个报表系统有个接口每天凌晨跑任务会有大批量数据进来。某一天突然报OutOfMemoryError排查了半天最后定位到一行代码ListString codes Arrays.asList(userCodes); // userCodes是用户传进来的一个很大的数组就是因为asList返回的List底层直接引用原数组本来可以省一份拷贝但后续代码里对这个list做了一步new ArrayList(codes)的防御性拷贝结果内存里同时存在两个大数组副本。当时数据量到了一定规模直接OOM了。后来把这段逻辑改成直接使用原始数组加Stream操作省掉了中间拷贝问题就消失了。这个案例给我的教训是asList的“省拷贝”优势有时候是双刃剑。它让数组和List共享同一个底层存储用好了能省内存用不好会导致意外的对象生命周期拉长。代码里一定要清楚每个List背后引用的是什么数据尤其是从外界传入的数组尽量不要让人口处就产生大对象的副本。还有一个很隐蔽的坑出现在binarySearch与对象数组的组合场景里。当时有一个按日期排序的对象数组我用Arrays.binarySearch查一个日期。问题在于binarySearch要求数组必须是按照和查找时一致的Comparator排序的。如果排序时用了Comparator.comparing(Order::getDate).reversed()降序查找时却忘了加同样的reversed()结果就是数组中确实存在这个日期但binarySearch永远返回负数而且因为返回值可能是负数代码逻辑里把它当成“不存在”直接漏掉了数据。这个问题的排查花了很长时间因为代码不报错只是结果少了几条数据。后来我把排序和查找的Comparator抽成同一个静态常量保证两边永远一致才算彻底解决。这个经验值得分享用binarySearch前务必确认排序Comparator和查找Comparator是同一个对象或者完全等价的实现。只要有一次不一致结果就会在极端情况下悄悄出错而且很难通过单元测试发现。