凡是写过Java的人,基本都绕不过数组。不管是学习阶段做的图书管理系统,还是工作里处理批量数据、做算法题、写接口返回结构,数组都是最底层、最常用的一块积木。但很多人在实际开发里对数组的处理方式其实非常原始:复制一个数组要写循环,比较两个数组要写循环,想把数组转成List要先遍历add,打印数组更是直接一个地址糊在脸上。明明JDK自带的Arrays工具类里就集成了这些操作,而且是官方优化过、经过生产环境验证过的实现,很多人却只停留在用过sort()和toString()的程度,连parallelPrefix、spliterator这些高阶API听都没听过。
这篇就把java.util.Arrays从常用到冷门的方法一次性讲透。不光是告诉你“这个方法是干什么的”,更多是结合我自己的踩坑经历,讲清楚为什么官方要这么设计、底层大概是怎么实现的、在什么场景下该用哪个方法、换了Java版本之后行为有没有变化。无论是刚学Java基础准备面试的后端新人,还是写了好几年业务代码想把手头工具用得更有章法的工程师,这篇都值得认真看完。
1. Arrays工具类整体认知与定位
1.1 一个纯静态方法的集合,根本不需要实例化
Arrays的定位和Collections很像:一个工具类,为对应的数据结构提供成套操作。Collections管的是集合框架,Arrays管的是数组。这个类的构造方法是private的,所以没法new Arrays()出来,所有方法都是static的,直接Arrays.xxx()调用就行。
这意味着什么?意味着这个类没有任何内部状态,方法之间也不共享数据。它是一堆纯函数,输入一个数组或者几个数组,输出结果或者对数组做原地修改,不会产生什么全局副作用。这也是我推荐新手去读源码的原因,Arrays源码大概是整个JDK里最容易读懂的类之一,没有异步、没有锁、没有复杂的继承结构,大部分方法几十行就能看完,读一遍对理解Java内存模型中的引用传递也有帮助。
1.2 为什么有了数组语法还得有Arrays
有些同学可能觉得,int[] arr = {1,2,3};不是挺好用的吗,下标访问、循环遍历、arr.length拿长度,原生语法已经很舒服了,为什么要一个工具类?
因为原生语法只解决了“创建数组”和“按下标操作”这两个最基础的需求,剩下的高频操作全部都是缺失的。比如:
- 把数组内容转成字符串,原生语法做不到,直接
System.out.println(arr)打印出来的是[I@1b6d3586这种类型标识加哈希码,完全不是内容。 - 两个数组做相等比较,原生语法里
arr1 == arr2比较的是引用,而equals()方法数组也没有正确重写,两个内容相同的数组用equals判断也是false。 - 数组扩容,原生语法根本没有这个能力,数组一旦创建长度就固定了,扩容只能新建数组再复制元素,而
Arrays.copyOf把这个过程封装好了。 - 快速填充、批量替换、排序、二分查找、转流,这些全部是原生语法不覆盖的。
所以要理解Arrays的价值,不能把它当成一个“能帮我省几行代码的小工具”,而要把它理解成:数组这个数据结构在JDK层面的完整方法配套。你用数组,就该用它配套的工具方法,否则就是在重复造轮子,而且大概率造得不如人家好。
1.3 和其他容器的桥梁:集合、流、数组互相转换
数组在Java里是一种独立的数据结构,但实际写代码的时候经常需要跟List、Stream互相打通。Arrays在中间承担了桥梁的作用。
- 数组转
List:Arrays.asList(T... a),但这个转换有不少限制,后面我会专门讲坑。 - 数组转
Stream:Arrays.stream(T[] array),基础类型数组还有专门的IntStream、LongStream、DoubleStream方法重载。 List转数组:这头则是集合那边提供的list.toArray(),配合Arrays.copyOf的内部逻辑,正好形成闭环。
理解了定位之后,下面把方法按功能分组,逐个拆解。先看最常用的排序和查找。
2. 排序与查找:日常开发最常用的两组方法
2.1 sort:基本类型走双轴快排,对象类型走TimSort
Arrays.sort()可能是所有方法里出场率最高的。但绝大多数人只停留在“调用就完事”的层面,不清楚它背后为什么分了两套完全不同实现的策略。
看一眼源码就能发现,sort(int[])和sort(Object[])走的根本不是一条路。基本类型数组(int、long、double等)用的是Dual-Pivot Quicksort,即双轴快速排序,JDK 7引入的优化版本,在大量实际数据上表现比传统单轴快排更稳定,最坏情况下也不会退化到O(n²)那么夸张。
对象类型数组用的则是TimSort,一种稳定的自适应归并排序,由Tim Peters在Python里提出后JVM也借鉴了过来。为什么要分开处理?因为对象排序通常需要维持相等元素之间的相对位置,比如先按姓名排序,再按年龄排序,两次排序下来如果第一次排序的次序在第二次排序时被破坏,用户就会困惑。所以对象排序必须“稳定”,而基本类型只需要数值顺序,不需要保证稳定,于是可以用更快但可能不稳定的快排。
这个区别在实际开发中是有意义的。我自己就踩过这样一次坑:一个订单列表先按时间排了一次,然后按状态排,用的是Arrays.sort(orderList, comparator),主观认为第二次排序不会打乱时间顺序,结果发现状态相同的订单时间乱了。后来查文档确认了Arrays.sort(Object[])是稳定排序,问题出在我自己写的Comparator上:我给的比较器在状态相同的时候直接返回了0,而不是继续比时间。所以如果你有“二次排序保持第一次排序结果”的需求,写Comparator的时候必须记得在字段相同的情况下继续比下一字段,而不是偷懒返回0。
基本类型排序没有Comparator重载,如果想对int[]按降序排,不能直接传一个Comparator,因为泛型不支持基本类型。常见的办法是先升序排,再自己反转数组,或者把元素装箱成Integer[]再传入Comparator.reverseOrder(),代价是多了装箱和拆箱开销。数据量小无所谓,数据量大我建议用反转或者干脆用IntStream做自定义处理,没必要为了几行代码背性能债。
2.2 parallelSort:数据量足够大时才是好选择
Java 8开始Arrays多了一组parallelSort方法,底层用ForkJoin池把数组拆分排序再合并。很多人一看到“并行”两个字就兴奋,觉得凡是排序都应该用这个方法,这是误区。
源码里有个阈值,arrays.length小于某个值(实际上内部是MIN_ARRAY_SORT_GRAN=8192)的时候,parallelSort会直接调用sort走串行逻辑。也就是说,数组不够长的时候你调用parallelSort,根本不会并行,还多了一层方法调用的开销,写这段代码的人等于白激动。只有元素数量足够大的时候,并行切分排序才有意义。
并行排序适合什么场景?比如日志分析、上报数据预处理、大规模数值计算里对几十万甚至上百万元素的数组排序。普通业务开发里一个页面最多几百条数据,老老实实用sort就好,加parallelSort并不会更快,反而可能因为线程池调度增加延迟。另一个注意点是并行排序受ForkJoin公共池影响,如果系统的公共池有其他任务在跑,排序的速度也可能被拖慢。
2.3 binarySearch:返回负数不是“出错了”,是插入点编码
Arrays.binarySearch给很多初学者带来的困惑可能是所有数组方法里最多的。明明数组里有这个元素,返回的下标是对的,但查一个不存在的元素时,返回值经常是负的,而且负的那个数字看起来毫无规律。比如数组{2, 4, 6, 8},查5返回-3,初学的人可能以为API坏了。
其实这是官方刻意设计的返回值规则。先看结论:如果找到了,返回元素下标;如果没找到,返回值是-(插入点) - 1。插入点指的是该元素如果存在,它应该被插入的位置下标。比如上面那个例子,5应该插在4后面,也就是下标2的位置,所以返回-(2) - 1 = -3。这个设计的好处是,调用方拿到任何返回值,都可以无歧义地恢复出插入位置:如果返回值>= 0,找到了;如果返回值< 0,实际插入点就是-ret - 1。
用这个规则写“不存在就插入”的代码就非常自然:
int idx = Arrays.binarySearch(arr, 5); if (idx < 0) { int insertPos = -idx - 1; // 执行插入逻辑 }使用二分查找有一个极其重要的前提:数组必须是有序的。如果数组没有排过序,binarySearch的行为是未定义的,可能碰巧对,但更多时候返回的结果莫名其妙。我自己刚工作的时候就在这上面吃过亏,一个去重逻辑里忘了排序,结果某些数据能命中,某些数据返回了错误的负数,排查了半小时才发现问题。记住一个口诀:先sort,再binarySearch,顺序不能反。
对象数组还有带Comparator的重载版本,同样要求数组按该比较器排序。
3. 数组拷贝与填充:边界情况多到值得专门写一篇
3.1 copyOf和copyOfRange:扩容的核心机制
数组长度不可变,是Java数组设计里最让新手抓狂的特性。实际写代码的时候,经常需要一个“动态容量”的数组,比如收集不确定数量的数据。Arrays.copyOf就是为了这个场景设计的。
看签名:copyOf(T[] original, int newLength)。它做的事情很朴素:创建一个长度为newLength的新数组,把original里的元素尽量复制过去。如果新数组更短,就只复制前newLength个元素,相当于截断;如果新数组更长,复制完原数组之后,剩余位置填默认值(基本类型是0、false、0.0,引用类型是null)。这个“填默认值”的细节特别有用,因为这意味着你手写循环扩容时必须自己处理空位,而官方方法已经把这一步做完了。
copyOfRange是在一个数组里按起止下标截取一段,同样的边界规则。浅拷贝这一点必须时刻记住:如果数组存的是引用类型对象,copyOf拷贝的是引用本身,不是对象内容。修改拷贝出来的对象内部状态,原对象也会变。基本类型数组则不存在这个问题,复制的就是实实在在的值。
我实际开发里最常见的用途之一是实现“数组栈”或者“数组队列”,比如手写一个按需扩容的容器时,直接在内部调用Arrays.copyOf,比手动new数组加System.arraycopy要简洁得多。还有一个高频场景是配合反射做泛型数组:
String[] arr = list.toArray(new String[0]);早年很多老程序员推荐new String[list.size()],但后来JDK官方实测new String[0]性能更好,因为这个写法会触发Arrays.copyOf走JVM优化的复制路径。虽然这是Collection.toArray那边的逻辑,但底层的优化思路是相通的:copyOf是一个高度优化的基础方法,与其传一个“正好够用”的数组,不如传一个空数组让JDK用自己擅长的方式去扩容。
3.2 fill和setAll:批量赋值的两种姿势
Arrays.fill用来把数组的某个区间全部赋成同一个值。最的简单场景是初始化一个int数组为-1,或者把布尔数组全部置为true。
使用fill时有个很容易忽略的坑:当数组是引用类型时,fill填进去的是同一个引用。比如你要创建一个长度为n的对象数组并填充一个新的Person对象,写:
Person[] people = new Person[5]; Arrays.fill(people, new Person("张三"));这样写出的结果是一个拥有5个“同一个Person对象”的数组,而不是5个独立的Person。你改people[0]的名字,people[1]到people[4]的名字全跟着变。要创建5个独立对象,得用循环或Arrays.setAll。
setAll是Java 8加入的方法,它不填固定值,而是根据索引生成值,签名是:
Arrays.setAll(T[] array, IntFunction<T> generator)输入的IntFunction接收下标,返回该下标位置要放的元素。这样就能优雅地生成一个下标和值有映射关系的数组,比如arr[i] = i * i。还有parallelSetAll版本,数据量大时用并行方式生成值。注意别对太小的数组用parallelSetAll,并行开销可能反而比串行大。
3.3 二维数组和“假填充”问题
fill用在二维数组上时,坑更深。int[][] matrix = new int[3][3]; Arrays.fill(matrix, new int[]{1,2,3});这里填充的还是引用,三行其实指向了同一个一维数组。修改matrix[0][0] = 99,三行的第0列全变99。我第一次意识到这个问题是在写一个简单的矩阵算法时,结果矩阵的行全部“共享”了,排查了半天才发现问题出在fill上。
正确的二维数组初始化方式是对每一行单独处理:
int[][] matrix = new int[3][3]; for (int i = 0; i < matrix.length; i++) { Arrays.fill(matrix[i], 0); }或者直接用Arrays.setAll给每一行生成独立的新数组。
4. 数组与集合互转:asList的三宗罪与toString的真相
4.1 asList:返回的不是java.util.ArrayList
Arrays.asList可能是整个Arrays类里最多人用错、也最多人面试栽跟头的方法。先明确一个事实:这个方法返回的确实是List的实例,但它的真实类型是Arrays$ArrayList,一个Arrays内部定义的私有静态类,不是我们日常用的java.util.ArrayList。两者最大的区别是:Arrays$ArrayList没有重写add和remove,它的底层直接引用了传入的数组,大小完全固定。
所以第一个坑就出现了:对Arrays.asList的返回值调用add或remove,会直接抛出UnsupportedOperationException。很多人在写代码的时候不以为意,觉得都是List,凭什么不能add?结果一运行就炸。不是说完全不能用这个结果,如果你只是需要只读列表、遍历、get、set,它是可以用的,而且因为底层是数组的视图,set会直接写回原数组。但任何改变元素个数的操作,想都不要想。
第二个坑更隐蔽:当你用基本类型数组去调用Arrays.asList时,结果会出乎意料。比如:
int[] arr = {1, 2, 3}; List<int[]> list = Arrays.asList(arr);返回的居然是一个元素类型为int[]、长度为1的列表。因为泛型不支持基本类型,T被推断为int[],整个数组被当成了一个对象塞进了列表。要正确得到List<Integer>,得用Integer[]包装:Integer[] arr = {1, 2, 3};然后Arrays.asList(arr)才是三个元素。这是面试里非常高频的一个考点,本质上考的是对Java泛型擦除和基本类型自动装箱机制的理解。
第三个坑是直接使用asList之后试图修改元素个数来“变相扩容”数组,结果全是UnsupportedOperationException。正确做法是拐个弯:
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3)); list.add(4); // 正常这也是实际开发里最常见的写法:先通过Arrays.asList快速生成一个不可变列表,再用它作为ArrayList构造器的参数,copy出一个真正的可变列表。Java 9之后还可以用List.of(1, 2, 3)直接创建不可变列表,语义更加明确,避免了asList的泛型陷阱。
4.2 toString与deepToString:关于数组打印的真相
数组直接打印为什么会是一串地址?因为数组是一个对象,但它没有重写toString()。Object.toString()的默认实现是“类名@十六进制哈希”,所以直接System.out.println(arr)输出的是[I@1b6d3586这种东西。方括号里的I代表int类型,这是JVM规范的数组类型描述符。
Arrays.toString(arr)重载了所有基本类型数组和引用类型数组,输出类似[1, 2, 3]的格式,这是日常调试用的。但它有个局限:如果数组的元素是对象,而这些对象的类没重写toString(),输出出来的就是一堆对象地址。对于多维数组,Arrays.toString同样不够用,因为它只调用了第一层数组元素的toString(),而第一层元素本身就是一维数组,输出还是地址。
这时候要用Arrays.deepToString。这个方法会递归处理嵌套的数组结构,把每一层元素都转成可读字符串。所以结论很简单:
- 一维数组用
Arrays.toString - 二维及以上的嵌套数组用
Arrays.deepToString - 对象数组,元素对象的类必须自己重写
toString才能得到有意义的内容
我调试一些算法的中间结果时经常要用这两兄弟。还有一个小技巧:把数组转成字符串之后再用String.format拼日志,不愁打日志把内存打爆的写法是:
System.out.println(Arrays.toString(arr).substring(0, Math.min(Arrays.toString(arr).length(), 200)));不过这个也暴露了另一个问题:直接反复调用toString可能造成重复计算,调试时图省事可以,正式代码里需要注意。
4.3 equals与deepEquals:数组比较的正确姿势
前面说了,数组没有重写equals(),所以arr1.equals(arr2)跟arr1 == arr2在效果上完全一样,都是比较引用。那怎么比较两个数组内容是否相等?用Arrays.equals。
Arrays.equals有基本类型和引用类型的重载,比较逻辑是:先看长度,再看每个位置的元素值。对于基本类型数组,直接比较值;对于引用类型数组,用Objects.equals判断每个元素,也就是说如果元素自己的equals写得有问题,这个比较也会不可信。
和deepToString类似,Arrays.equals面对嵌套数组时,第一层元素的equals显然不会去比较子数组内容(因为数组的equals是引用比较),所以比较二维数组必须用Arrays.deepEquals(a1, a2),它递归处理嵌套数组的比较。
一个很容易踩的坑是:想比较两个int[][]是否相同,用了Arrays.equals,结果永远返回false。甚至两个都从同一个源复制出来的二维数组,Arrays.equals也返回false。原因就是这一层递归没有做。所以记住,嵌套数组一律用deep系列。
Java 9之后还增加了Arrays.compare和Arrays.mismatch。compare用来按字典序比较两个数组,返回负数、0、正数,类似于字符串的compareTo。mismatch返回两个数组第一个不同元素的下标,如果完全一样返回-1。这两个方法在处理多维数组、做数组字段排序时非常方便,比手写循环优雅得多。
5. Stream、并行与原语:进阶API的实战价值
5.1 stream:数组与函数式编程的桥梁
Java 8引入Stream之后,Arrays也顺势加了一组stream()方法。基本类型数组有专门的重载:Arrays.stream(int[])返回IntStream,还有long[]和double[]的版本,引用类型数组则返回对应的Stream<T>。
这意味着处理数组时可以直接串起各种流式操作,而不用先把数组转成List再list.stream()。比如对一个int[]求最大值:
int max = Arrays.stream(arr).max().orElseThrow();统计元素个数、过滤、映射、求和、转Set、分组,统统可以在流上完成。更妙的是,IntStream还自带sum()、average()、summaryStatistics()这些聚合方法,日常数据分析不要太爽。数据量大时还可以在stream()之后加.parallel()转并行流,配合多核CPU加速。
不过要提醒一下,流式操作对初学者来说,调试体验比循环差一些。循环里可以随手打日志,流里面打日志就得靠peek。实际开发我的建议是:简单循环能处理的别硬上Stream,但凡是过滤、聚合、分组这类高表达力的活儿,Stream写出来的代码可读性远好过一堆forEach。
5.2 setAll、parallelSetAll、parallelPrefix:来自源码的宝藏
setAll前面已经提到过,Java 8加的,用生成器函数按索引赋值。还有个parallelSetAll,它用并行方式对每个位置执行生成函数,适合超长数组的初始化,比如生成100万长度的斐波那契数列。不过先别急,这种涉及的只是“读索引、算值”的过程,如果生成函数本身很简单(比如i -> i * 2),并行未必快,因为拆分任务和合并结果的开销可能超过计算本身。
parallelPrefix是另一个冷门但极其强大的方法。它的作用是:用指定的二元运算符对数组连续元素做累计计算,结果原地写回。也就是前缀和(prefix sum):
// 输入 [1, 2, 3, 4],求和前缀 Arrays.parallelPrefix(arr, Integer::sum); // 输出 [1, 3, 6, 10]这个方法在数值计算里用途很广:比如范围查询、累计涨幅、实时统计等。名字里的parallel不是装饰,它是真的并行执行的:初始时扫描前段任务,再用ForkJoin框架分块计算,每个块维护自己的局部前缀,最后再合并调整。如果数组不够长,它会直接退化成串行。
这里有个经验教训:如果你想对数组做“每个元素等于前面所有元素之和”的累计操作,手写循环做前缀和是O(n),看起来也不差,但parallelPrefix在大数组上的并行化能把耗时压下去很多。生成的代码也比手写循环少,还不容易下标越界。
5.3 spliterator与Spliterator特性:装叉但有用的知识点
Arrays.spliterator(T[] array)返回一个Spliterator,这是并行流切分任务的底层机制。日常业务代码里直接操作Spliterator的机会不多,但理解它有助于明白parallelStream是怎么把数组切给多个线程的。
Spliterator的几个关键特性标志:ORDERED表示元素顺序有意义,SIZED表示元素数量已知,SUBSIZED表示每个切分出的子部分大小也已知。数组天然满足这些条件,所以Arrays.spliterator返回的实例在这几个特性位上都置了1,JVM可以据此做更优化的并行切分策略。
如果你要写自己的并发处理框架,或者想更精细地控制并行流的拆分策略,可以用Spliterator.trySplit手动拆数组。不过大部分人知道这个概念就够了,真到了要用的时候再去细看源码也来得及。
5.4 Java 9+的compare和mismatch:别再用老办法比较了
前面提过,Java 9给Arrays增加了compare和mismatch两个系列方法。它们分别覆盖基本类型和引用类型,而且还支持通过Comparator自定义比较规则。
compare的典型场景是对一组数组字段做排序,比如一个列表里的记录每人都带一串指标数据,你想按指标数组的字典序排序:
Arrays.sort(records, Comparator.comparing(r -> Arrays.compare(r.metrics1, r.metrics2)));这个写法很清晰。如果不用compare,你得手写一个循环去比两个数组的每一个下标。mismatch则适合做数据校验、找出两个数组第一个不一致的位置,在测试断言中也挺好用。
JDK版本这事儿值得多说两句。很多团队目前的主流版本还是Java 8或者Java 11,所以Arrays的API差异在实际开发中是真实的令人困惑的点:mismatch在Java 8里根本不存在,编译器直接报红;List.of也是Java 9的。如果你的项目编译环境是JDK 8,写代码时踩到API不存在的坑就明白了。所以用Arrays之前,先确认自己项目的JDK版本,别写着写着用了一个当前版本没有的方法。
6. 实战案例与常见问题排查
6.1 合并两个数组的三种写法
日常开发里经常要把两个数组合并成一个。手写for循环是最原始的一版,但用工具类可以写得非常简洁:
String[] a = {"a", "b"}; String[] b = {"c", "d"}; String[] result = new String[a.length + b.length]; System.arraycopy(a, 0, result, 0, a.length); System.arraycopy(b, 0, result, a.length, b.length);或者更简单,用Stream.concat:
String[] result = Stream.concat(Arrays.stream(a), Arrays.stream(b)) .toArray(String[]::new);写法上后者要优雅很多,但拼接数组本身是个O(n)操作,用哪种差别不大。如果是要合并很多个数组,每一轮都concat会产生中间对象,性能一般。大数据量合并建议固定大小后System.arraycopy,这是JVM最擅长的内存复制方式。
6.2 数组去重、求交集、统计频次
去重一般第一反应是转Set,但如果是基本类型数组,手动处理起来稍微多几步:
int[] arr = {1, 2, 3, 2, 1}; int[] distinct = Arrays.stream(arr).distinct().toArray();这一行就能搞定。求两个数组的交集:
int[] a = {1, 2, 3}; int[] b = {2, 3, 4}; Set<Integer> setB = Arrays.stream(b).boxed().collect(Collectors.toSet()); int[] intersection = Arrays.stream(a) .filter(setB::contains) .toArray();统计频次就更明显了:
Map<Integer, Long> freq = Arrays.stream(arr) .boxed() .collect(Collectors.groupingBy(i -> i, Collectors.counting()));可以看到,只要把数组转成流,所有集合里能用的技巧全部都能无缝接上,这就是Arrays.stream最大的价值。
6.3 典型问题速查:10个Arrays使用高频坑
我整理一个表格,几乎把日常用到Arrays的大坑都涵盖了进去:
| 问题 | 现象 | 原因 | 正确做法/规避方式 |
|---|---|---|---|
Arrays.asList后调用add/remove | 抛UnsupportedOperationException | 返回的是内部固定大小List | 包一层new ArrayList<>()或使用List.of |
基本类型数组传给asList | 得到的List长度为1,元素是int[] | 泛型无法接收基本类型 | 用Integer[]包装 |
binarySearch结果负数 | 明明存在却返回-1 | 数组未排序或比较器不一致 | 先sort再binarySearch |
| 数组直接打印 | 输出[I@xxxx地址 | 数组未重写toString | 用Arrays.toString或deepToString |
二维数组用equals比较 | 内容相同返回false | 递归层未对比子数组 | 用deepEquals |
fill填充引用类型 | 所有元素指向同一对象 | fill拷贝引用 | 逐项setAll或循环new |
parallelSort和数据量太小 | 排序性能反而下降 | 小于8192退化为串行 | 数据量大才用并排序 |
copyOf修改新数组 | 原数组也被改了 | 引用类型浅拷贝 | 深拷贝需自行递归复制 |
Arrays.stream后忘boxed() | 无法直接collect(toList()) | 基础类型流没有Collector | 需要引用类型时先boxed() |
sort后想降序排列 | 直接用Comparator报错 | 基本类型数组不支持泛型Comparator | 装箱Integer[]或手动反转 |
这些坑我几乎一个个踩过。有些是初学时就踩的(比如直接打印数组),有些是工作了好几年才吃过的亏(比如在并行流里用parallelSetAll没看数据量)。把它们放在一起,是希望后面的人少走几步弯路。
6.4 面试常见考点:Arrays类背后的原理问题
在Java面试里,Arrays类相关的考点也很容易碰到。除了前面提到的asList的坑和parallelSort的阈值,还有几个常见问题:
Arrays.sort为什么有快排和TimSort两套实现?本质是为了稳定性需求不同,和数据结构特性有关。Arrays.copyOf和System.arraycopy的区别是什么?arraycopy是native方法,只负责复制;copyOf除了复制还能扩容并填充默认值。binarySearch为什么找不到时返回值是-insertionPoint - 1?为了保证所有负数返回值都能唯一映射回插入点。- 数组为什么不能直接
equals?因为数组没有重写Object.equals,它是引用比较。
理解这些问题的关键不在于背答案,而在于理解JDK设计者的取舍逻辑。比如二分查找返回值的编码方式,如果不这么设计,调用方就得通过其他途径知道“没找到”和“插哪”,返回单个int就同时携带这两个信息,属于非常漂亮的设计。
结尾
写到这里,Arrays工具类的主体内容基本说完了。如果只让我总结一个使用原则,那就是:数组相关的操作,先想一下JDK有没有现成的方法,有就优先用。我见过太多人一上来就手写循环,既慢又容易边界出错,其实官方工具类把边界已经处理好了,源码就放在那,还能翻着学设计。
最后分享一个我自己的习惯:每次升级JDK版本后,我都会抽个时间过一遍java.util.Arrays的类文档,看看这个版本新增了哪些方法。JDK 8的stream三件套、JDK 9的compare和mismatch,都是靠这个习惯才发现的好东西。新的工具方法往往能简化掉一大片低价值代码。这个类不大,但吃透它,你写Java的日常体验会舒服很多。