☰
Java Arrays工具类全解析:排序、拷贝、比较与避坑指南
2026/10/10 19:00:07 网站建设 项目流程

我做接口性能优化时遇到一个很有意思的场景:某个接口返回前要合并两个数组并去重,原实现写了三层循环嵌套,200条数据跑了快800毫秒。我接手后把整段逻辑换成Arrays工具类组合操作,耗时直接掉到个位数毫秒。从那时起我对java.util.Arrays的态度就从"用过几个方法"变成"值得系统性梳理一遍"。

这篇内容适合所有写Java的开发者,不管你是刚学数组的新人,还是写了几年业务代码的老手。我会把Arrays工具类的核心方法按用途拆开讲,每个方法讲清楚背后的原理、具体用法、容易踩的坑,再穿插一些我在实际项目里总结的取舍经验。看完你至少能收获两件事:一是往后遇到数组操作不用再手写循环;二是知道哪些场景该用哪些方法,以及为什么。

1. 为什么数组操作总是重复造轮子:Arrays工具类的定位与适用场景

1.1 从手写循环说起:Arrays工具类想替你省掉什么

先想一个问题:为什么Java已经有了java.util.Collections,还要单独搞一个Arrays?原因很简单,数组不是集合,它是JVM底层直接支持的连续内存结构,没有方法可调。但现实里数组的操作又极其频繁:排序、查找、拷贝、填充、比较、转字符串、转List。如果你每次都自己写循环,代码会变得又长又容易出错。

举个最典型的例子,排序。手写一个快速排序,正常要三四十行,还要考虑基准值选取、分区边界、递归终止条件。用Arrays.sort一行搞定,底层是经过大量优化的排序算法,性能远超你自己写的版本。再比如数组拷贝,手写for循环逐个System.arraycopy,你还要自己算起始位置和长度。

java.util.Arrays就是把这些高频操作收拢成一组静态方法,全部挂在Arrays类名下。它不保存任何状态,所有方法都是无副作用的纯函数式设计,传入数组,返回结果。所以它本质上是"数组操作的函数库",不是数据结构。理解这一点,你就能明白为什么它的方法名都那么直白:sort排序、search查找、copy拷贝、fill填充、equals比较。

1.2 方向感先建立:Arrays里到底有几类方法

我习惯把Arrays的方法分成七类来记忆,这样用的时候能快速定位。下面这张表是我自己整理的,建议收藏:

用途分类典型方法适用场景
排序sort、parallelSort数组排序,并行排序适合大数据量
查找binarySearch二分查找,前提是数组已排序
拷贝与区间copyOf、copyOfRange扩容、截取子数组
填充与生成fill、setAll、parallelSetAll批量赋值、按规则生成
比较与哈希equals、deepEquals、hashCode、deepHashCode数组内容比较、哈希计算
转换asList、toString、deepToString数组转List、转字符串
流式与并行stream、spliterator、parallelPrefix函数式处理、并行前缀计算

这里有个容易忽略的点:Arrays的每个方法基本都有多个重载,分别对应Object[]、int[]、long[]、double[]、char[]等基本类型。基本类型数组不能直接当作Object[]用,所以官方为每个基本类型都做了重载。比如sort就有9个重载版本。日常使用中你只需要记住:凡是涉及基本类型数组的操作,直接调用就行,类型参数会自动匹配,不需要你操心强转的问题。

2. 排序与查找:最简单也最容易翻车的两个方向

2.1 sort的幕后:从双轴快排到并行排序切换

Arrays.sort是使用频率最高的方法,但很多人在选sort还是parallelSort时是凭感觉的。我直接说结论:JDK的实现比你想象中聪明,日常场景用sort就够。

对于int、long等基本类型数组,Arrays.sort底层用的是改进过的双轴快速排序算法。这个算法在大部分数据分布下都能保持稳定高效,尤其对基本类型而言,不需要稳定性要求,所以快排完全够用。对于对象数组,Arrays.sort走的是另一个分支——稳定归并排序的改良版(旧版是稳定归并,新版JDK用了类似TimSort的思想)。为什么对象数组要稳定的排序?因为对象排序通常伴随其他属性,稳定排序能保证相等元素的相对顺序不变,这对后续处理很重要。

parallelSort则是sort的并行版本,内部会把数组拆分成多个子任务,提交给ForkJoinPool并行执行,然后再合并。但有个关键细节:JDK会根据数组长度和当前机器的CPU核心数计算一个阈值,只有当数组长度超过这个阈值时才真正启用并行分支,否则退化成普通sort。所以小数组用parallelSort不仅不会更快,反而要承担线程池任务调度的开销。我的经验值是:少于5000个元素的数组,直接用sort;超过这个量级,parallelSort才值得考虑。

来看一个实际排序代码的写法:

int[] scores = {88, 67, 92, 45, 79}; Arrays.sort(scores); // 基本类型直接排 System.out.println(Arrays.toString(scores)); // 输出 [45, 67, 79, 88, 92] // 对象数组排序需要传入Comparator UserInfo[] users = {...}; Arrays.sort(users, Comparator.comparingInt(UserInfo::getAge));

对象数组排序这里有个新手高频报错:不传Comparator直接Arrays.sort(users),如果UserInfo没实现Comparable接口,运行时会抛ClassCastException。这个问题我在第8章还会单独讲。

2.2 binarySearch的边界心机:找不到时返回的负数怎么用

Arrays.binarySearch很多人用过,但没吃透它的返回值语义。先说前提:数组必须已经按升序排好。如果你对未排序数组做二分查找,结果是不确定的,这在官方文档里写得很死,严格来说这个坑算"使用方违规"。

找到了,返回对应下标,这没什么好说的。关键是找不到的时候,它返回的不是-1,而是-(插入点) - 1。插入点定义为:第一个大于查找键的元素位置,如果所有元素都小于目标键,则是数组长度。举个例子:

int[] arr = {10, 20, 30, 40}; int idx = Arrays.binarySearch(arr, 25); // 插入点是2,返回值 = -(2) - 1 = -3 // 恢复插入点:insertionPoint = -idx - 1 = 2

这个负数设计是为了区分"找不到"和"找到下标0"的情况。如果你只设计成返回-1,那么"数组第0个位置就是要插入的位置"和"数组是空的"就没法从返回值区分了。而-(插入点)-1这个编码方式,保证了任何找不到的情况返回值都严格小于0,同时又能无损恢复插入点。

我在项目里经常用这个特性来做"插入前的定位":查到一个元素不存在时,直接用返回的负数算出插入点,把新元素插到正确位置,保持数组依然有序。这比你自己遍历找插入点省事得多。

int target = 25; int pos = Arrays.binarySearch(arr, target); if (pos < 0) { int insertIdx = -pos - 1; // 构造新数组并插入 }

另外注意,binarySearch也有重载版本支持在指定区间内查找,以及指定Comparator,但核心语义都一样。排序时如果用了自定义比较器,查找时也要传同一个比较器,否则结果对不上。

3. 拷贝、填充与区间操作:手滑最容易出Bug的三件事

3.1 copyOf扩容机制与引用数组的浅拷贝陷阱

Arrays.copyOf和Arrays.copyOfRange是处理数组扩容、截取的首选方法,它们的底层都调用了System.arraycopy这个JVM层面的原生方法。很多人知道copyOf能扩容,但不清楚它的具体策略。

看这个例子:

int[] original = {1, 2, 3}; int[] expanded = Arrays.copyOf(original, 5); // 结果:{1, 2, 3, 0, 0},新位置用默认值填满

copyOf(T[] original, int newLength)的逻辑是:如果newLength小于原数组长度,只拷贝前几个元素;如果大于,多出的部分用类型默认值填充。这个特性在实现动态数组时很有用——经典的手动扩容就是写一个ensureCapacity方法,内部用copyOf把旧数组复制到新容量。

但这里有个必须强调的陷阱:copyOf对引用类型数组是浅拷贝。如果数组里存的是对象引用,copyOf只复制引用本身,不复制对象内容。也就是说,原数组和拷贝出来的数组指向同一批对象。你修改expanded[0]的某个属性,original[0]也会跟着变。不要指望copyOf帮你做深拷贝。

UserInfo[] oldArr = {userA, userB}; UserInfo[] newArr = Arrays.copyOf(oldArr, oldArr.length); newArr[0].setAge(99); // oldArr[0]的age也会变成99

如果真的需要深拷贝对象数组,最稳妥的方式是遍历后逐个创建新对象,或者用序列化方式整体深拷贝。后者代码简短,但性能开销大,只适合对象结构复杂、拷贝频率低的场景。

3.2 fill与setAll的差异:什么时候用哪个

Arrays.fill用来把数组所有元素设为同一个值,Arrays.setAll则是按索引的规则函数逐个生成。两者看起来都是"批量赋值",但应用场景完全不同。

fill适合初始化场景,比如创建一个长度为100的int[]全部填0,或者把某个boolean[]全部置为true。注意一点:fill对引用类型数组填充的是同一个引用,如果你执行Arrays.fill(objectArr, new Object()),数组里所有位置指向的是同一个对象。这个行为本身没有错,但如果你以为生成了100个不同对象,后续就会踩坑。

setAll接收一个IntFunction,根据下标算出每个位置的值,比如:

double[] weights = new double[10]; Arrays.setAll(weights, i -> Math.pow(2, i)); // weights[0]=1, weights[1]=2, weights[2]=4...

parallelSetAll是setAll的并行版本,同样的函数会并行计算各个索引的值。因为每个索引的计算是独立无依赖的,所以可以安全并行。但要注意,如果计算公式里引用了共享可变状态,并行版本就可能产生线程安全问题。

这里我给你的取舍建议是:需要同一值就选fill,需要按规则生成每个位置的取值就选setAll。两者都不要自己写循环代替,既有性能损耗,代码也不简洁。

4. 数组间的比较与哈希:equals、deepEquals与deepHashCode的隐藏逻辑

4.1 equals只能比一维,deepEquals才是多维救星

数组比较是个容易出错的领域。用==比较数组比较的是引用地址,这我们都知道。但很多人以为Arrays.equals能解决所有数组比较问题,其实它只对一维数组有效。

为什么?看Arrays.equals的实现逻辑:它会先判断两个数组是否是同一个引用,是就直接返回true;否则逐个比较元素,对Object[]是调用元素的equals方法,对基本类型是直接比较值。问题出在Object[]的多维数组上:多维数组的元素是子数组对象,而子数组的equals方法是继承自Object的,比较的是引用地址。所以两个内容相同的二维数组,用Arrays.equals比较大概率返回false。

解决方法是Arrays.deepEquals。它会递归遍历每个子数组,一层一层比较到基本类型或普通对象为止。同样地,deepToString也是递归版本的字符串转换,打印二维数组时必须用它,否则输出的是[[I@xxx, [I@xxx]这种地址串。

int[][] matrix1 = {{1, 2}, {3, 4}}; int[][] matrix2 = {{1, 2}, {3, 4}}; boolean eq1 = Arrays.equals(matrix1, matrix2); // 大概率false boolean eq2 = Arrays.deepEquals(matrix1, matrix2); // true String s1 = Arrays.toString(matrix1); // [[I@1b6d3586, [I@4554617c] String s2 = Arrays.deepToString(matrix1); // [[1, 2], [3, 4]]

顺带说一句,对于一维基本类型数组,Arrays.equals是逐值比较的,所以equals和deepEquals对一维数组的结果一致。从代码整洁度出发,一维数组用equals,多维数组用deepEquals,这个习惯记牢就行。

4.2 deepHashCode的一致性问题

哈希方法里也有同样的一维和多维之分:Arrays.hashCode和Arrays.deepHashCode。原则很简单:如果你用deepEquals比较数组,就必须配套用deepHashCode计算哈希,否则会破坏"相等对象必须具有相同哈希值"的约定。

举个例子,一个类里有int[][]字段:

class MatrixWrapper { private int[][] data; @Override public boolean equals(Object o) { // 用 Arrays.deepEquals 比较 data } @Override public int hashCode() { // 必须用 Arrays.deepHashCode(data) } }

如果equals用了deepEquals而hashCode用了hashCode,两个内容相同的MatrixWrapper就会得到不同的哈希值,放进HashMap或HashSet时会出现诡异的"明明相等却重复存储"的问题。我在某次排查一个Set集合元素重复的Bug时,最后就定位到这种不一致上。

另外,我在实际项目中基本不推荐直接用数组作为HashMap或HashSet的键。虽然deepEquals和deepHashCode保证了内容比较的正确性,但数组内容一旦被修改,它的哈希值就会变化,存储在哈希表里的位置就失效了,容易产生脏数据。如果一定要用,请保证数组在作为键期间不可变。

5. asList返回的"假"集合:三个坑一次性说清

5.1 为什么它不支持add和remove

Arrays.asList是数组转集合最便捷的入口,但它返回的并不是java.util.ArrayList,而是Arrays内部定义的一个私有静态类,这个类的名字也叫ArrayList,但和java.util.ArrayList是两回事。

这个内部类继承自AbstractList,但只支持get、set、contains这类读取和替换操作,没有实现add和remove。你调用add或remove时,会直接抛UnsupportedOperationException,这个异常不是JVM自动生成的,而是AbstractList里这两个方法默认就抛。

我记得在某公司的技术群里,几乎每个月都有人贴这个报错截图。典型代码如下:

List<String> list = Arrays.asList("Java", "Python", "Go"); list.add("C++"); // 运行时抛 UnsupportedOperationException

要得到一个真正可增删的List,常规做法是包装一层:

List<String> list = new ArrayList<>(Arrays.asList("Java", "Python", "Go")); list.add("C++"); // 正常

5.2 它是视图而非副本:改一个,另一个跟着变

比不支持增删更隐蔽的是视图问题。Arrays.asList返回的集合在内部直接持有了原数组的引用,它不是拷贝,而是一个"数组的视图"。这意味着你通过list.set(index, value)修改集合中的元素,原数组的对应位置也会变;反过来,你修改原数组的内容,这个list看到的元素也变了。

String[] arr = {"a", "b", "c"}; List<String> list = Arrays.asList(arr); list.set(0, "z"); System.out.println(arr[0]); // 输出 z arr[1] = "y"; System.out.println(list.get(1)); // 输出 y

这个特性在某些场景下是优点,因为可以避免数据的重复拷贝;但如果你本意是"转成独立列表再随意操作",这个行为就会让你在不知不觉间改掉了原始数据。我的建议是:如果后续代码有修改集合的需求,或者原数组在其他地方也在用,就用new ArrayList<>(Arrays.asList(...))做一次真正的拷贝,别图省事。

5.3 基本类型数组传过去的灾难现场

第三个坑非常经典。想象一下:

int[] nums = {1, 2, 3}; List<int[]> list = Arrays.asList(nums);

你以为得到了List<Integer>,但实际上得到的是List<int[]>,而且这个列表的长度是1,里面只有一个元素——就是整个int[]数组。原因在于泛型只能接受引用类型,int[]本身是一个Object对象,Arrays.asList(T... a)在传参时把整个int[]当作了一个泛型参数T,而不是把里面的每个int拆成多个参数。

解决方法是用包装类型数组,或者用Java 8的Arrays.stream再收集:

Integer[] boxedArr = {1, 2, 3}; List<Integer> list1 = Arrays.asList(boxedArr); // 正常,长度3

如果你手里只有int[],最省事的做法是:

List<Integer> list2 = Arrays.stream(nums) .boxed() .collect(Collectors.toList());

另外补充一个相关细节:Arrays.asList传入两个及以上参数时,就不会有这个问题,因为它会把每个参数都当作独立的元素。问题只出在传入一个数组的场景,而日常代码里恰恰这种场景最多。

6. 流式操作与并行能力:让数组处理跟上现代Java的节奏

6.1 stream方法把数组转化为现代处理管线

Java 8之后,Arrays新增了stream系列方法,把数组平滑地接入Stream API。这是Arrays工具类里我最喜欢的一批方法,因为它能把以前需要写循环的过滤、映射、收集逻辑压缩成一行流水线。

基本写法是这样的:

String[] names = {"Alice", "Bob", "Charlie", "David"}; List<String> filtered = Arrays.stream(names) .filter(name -> name.length() > 3) .map(String::toUpperCase) .collect(Collectors.toList()); // 输出 [ALICE, CHARLIE, DAVID]

需要注意,Arrays.stream对基本类型数组有专门的重载:int[]对应IntStream,long[]对应LongStream,double[]对应DoubleStream。这三类流有各自的特有操作,比如sum、average、boxed。其他基本类型比如boolean[]、char[]没有对应的流类型,想用Stream API就得先转成包装类型数组或列表。

这个方法的另一个用途是方便地对数组做统计和汇总:

int[] scores = {85, 92, 78, 66, 99}; IntSummaryStatistics stats = Arrays.stream(scores).summaryStatistics(); // 得到 min=66, max=99, average=84.0, count=5, sum=420

我经常用summaryStatistics替代手写循环求最大最小值,代码短而且不会漏初始化逻辑。

6.2 parallelPrefix的前缀计算逻辑与实际价值

Arrays.parallelPrefix是个冷门但非常有价值的方法。它做的事情是并行前缀计算:对数组的每个位置,把从开头到当前位置的所有元素,用你指定的二元操作符累积起来。最经典的场景就是并行前缀求和。

int[] data = {1, 2, 3, 4, 5, 6, 7, 8}; Arrays.parallelPrefix(data, Integer::sum); // 结果:{1, 3, 6, 10, 15, 21, 28, 36}

等价于:每个位置i存的是原数组data[0] + data[1] + ... + data[i]的结果。这个方法名称里的Prefix指的就是前缀,parallel说明整个计算是并行执行的。它内部会先把数组切分成多个段,并行计算段内前缀,再汇总修正,最后合并成完整前缀数组。

实际项目里我用过的一个场景是"累积权重分配":把一个订单金额按比例切分到多个明细项,要求每项的金额是前几项之和的累计值。这种需求如果用循环写,时间复杂度是O(n),而parallelPrefix在数据量大时能利用多核优势提速。注意,parallelPrefix要求传入的二元操作符必须是可结合且无副作用的,不然并行执行结果会和串行结果不一致。我自己实测过,用(a, b) -> a * b做前缀乘积时,如果数组里有负数,结果顺序依然正确,因为乘法是可结合的;但如果你用了一个不可结合的操作,比如减法,结果必然是错的。

7. 冷门但关键的API:mismatch、compare与更精细的边界处理

7.1 mismatch:两个数组哪里开始不同

Arrays.mismatch是Java 9引入的方法,作用是在两个数组之间找到第一个不同元素的位置,如果两个数组完全相等则返回-1。这个方法在协议对接、数据校验、差异比对场景里非常实用。

用法很简单:

int[] arr1 = {1, 2, 3, 4}; int[] arr2 = {1, 2, 5, 4}; int pos = Arrays.mismatch(arr1, arr2); // 返回 2,因为下标2处 3 != 5

它有几个值得一提的重载:支持指定两个数组各自的查找区间,比如mismatch(arr1, from1, to1, arr2, from2, to2);也支持Object[]并接受Comparator参数。对于没有实现Comparable的对象数组,你可以传入比较器定义"不同"的判定标准。

我实际使用的一个例子是接口响应报文的缓存判断:客户端把服务端返回的二进制数组缓存起来,每次拉取新数据时用Arrays.mismatch(cached, latest)判断差异位置,如果返回-1就不需要做后续更新。比逐字节循环比较快了不止一点,代码也更清晰。

7.2 compare的字典序比较规则

Arrays.compare同样是Java 9加入的方法,按字典序比较两个数组,返回负数、零或正数。字典序含义类似字符串比较:从左到右比较第一个不同位置的元素大小;如果前面全部相同,较短的数组排在前面;如果完全相同则返回0。

int[] a = {1, 2, 3}; int[] b = {1, 2, 4}; int result = Arrays.compare(a, b); // 返回负数,因为 3 < 4

这个方法和Arrays.equals最大的区别是:equals只回答"是否相等"(返回布尔值),compare则能给出"谁大谁小"(返回排序顺序信息)。所以在需要数组排序的Comparator实现里,Arrays.compare是天然的排序依据。

比如要按"数组内容字典序"排序一组数组,你可以写:

List<int[]> list = new ArrayList<>(); list.sort((arr1, arr2) -> Arrays.compare(arr1, arr2));

另一个实用场景是版本号比较:如果版本号是固定长度的数字数组,Arrays.compare可以直接比较新旧版本顺序,而不用把数组转成字符串再去解析。不过注意,compare对基本类型数组比较的是数值,对对象数组比较的是元素的compareTo(要求实现Comparable),或者使用传入的Comparator。

8. 我踩过的Arrays坑与日常使用习惯

8.1 排序对象数组却不传Comparator

这个坑我栽过不止一次。某个项目里定义了一个OrderInfo类,没实现Comparable。某天同事写了一段Arrays.sort(orderListArr),直接编译通过,但一运行到那行就抛ClassCastException,排查了很久才发现是排序这行的类转换问题。

为什么编译不报错?因为Arrays.sort(Object[] a)的签名接受任何对象数组,但内部会调用ComparableTimSort.sort,把元素强转成Comparable。如果元素类没实现这个接口,运行时就炸了。

现在的习惯是:对象数组排序一律显式传Comparator,即使类已经实现了Comparable也照传。这样做的目的是让排序规则在调用处一眼可见,团队协作时减少猜谜时间。

Arrays.sort(orders, Comparator.comparing(OrderInfo::getCreateTime) .thenComparing(OrderInfo::getOrderId));

8.2 用asList构建List后还要转ArrayList

前面说了Arrays.asList是不可增删的定长列表。但还有一个配套的坑:很多人为了防御性编程,把asList的结果直接丢给Collections.unmodifiableList做只读包装。这是没问题的,问题在于后续代码误以为能增删时,报错信息是UnsupportedOperationException,排查时容易绕圈子。

我现在的处理原则很明确:如果列表只在当前方法内使用且确定只读,直接Arrays.asList没问题;如果列表要传到其他方法、或者不确定后续会不会有add、remove、clear操作,一律先new ArrayList<>(...)包一层再传。这个习惯帮我少接了不少线上告警。

8.3 深拷对象数组的替代方案

copyOf是浅拷贝这一点,很多人是在线上数据被意外改动后才意识到的。如果你需要的是"原数组和拷贝数组互不影响",最直接的办法是遍历后逐个克隆。前提是数组元素对象的clone方法或拷贝构造函数实现正确。

UserInfo[] target = new UserInfo[source.length]; for (int i = 0; i < source.length; i++) { target[i] = source[i].copy(); // 自定义深拷贝方法 }

如果对象嵌套层次很深,手写深拷贝要写的代码太多,可以借助序列化工具。我的建议是:做之前先想清楚业务里到底需不需要深拷贝。大部分数组操作的场景,浅拷贝是够用的,盲目深拷贝反而增加GC压力和代码复杂度。

回到开头那个性能优化的案例,我最终是这样组合Arrays工具类的:先用parallelSort排序,再用binarySearch查找去重位置,最后用copyOfRange截取结果数组。整个流程没有手写一个循环。工具类用得好不好,决定了你写业务代码是"拼接积木"还是"搬砖"。平时多翻翻Arrays的API文档,遇到数组操作先停下来想一想"这里官方是不是已经给了现成的轮子",长期下来,代码质量和开发效率都会有肉眼可见的提升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询