数组在Java里的地位很微妙。你说它简单吧,其实任何一门编程语言的数据结构课,都是从数组讲起的;你说它难吧,但你看面试里那些“熟面孔”——冒泡排序、数组去重、二维数组、数组转字符串、双指针区间求最值,本质上全是在考数组。所以我一直觉得,数组是Java基础里最值得花时间吃透的一块,它学好了,后面集合、泛型、算法甚至JVM内存模型的理解都会顺畅很多。这篇总结我尽量按“基础概念-声明初始化-常用操作-排序去重-多维数组-性能边界”一条线写下来,把我在实际开发和面试里经常见到的坑点、技巧一起带出来。
1. 先搞清数组的本质:一段连续内存,而不是一个“容器”
很多教程喜欢把数组说成“容器”,这个类比其实挺误导人的。你用ArrayList的时候可以随便往里面丢元素、删元素,它自己会扩容、搬移数据;但数组不是一个能伸缩的容器,它更像一段在创建时就已经划好边界的连续内存空间。正是因为这个“连续”,数组才有了两个极其鲜明的特点:随机访问快、插入删除慢。
1.1 数组在JVM中的真实面目
在JVM里,数组本身就是一个对象。当你写int[] arr = new int[5];时,栈上的引用变量arr指向堆内存中一个数组对象,这个对象自带一个length属性,里面记录了长度。注意,这里的length是属性,不是方法,这跟String的length()是两码事,面试和笔试里经常有人栽在这上面。
数组对象在堆里会占用一块连续空间,int[5]就是 5 个 int 的连续空间,每个 int 占 4 字节,总共 20 字节(再加上对象头这些额外开销)。要访问第 i 个元素时,JVM 不需要像链表那样从头开始找,只需要用“首地址 + i * 元素大小”做一次偏移计算就能直接定位。这就是数组随机访问时间复杂度是 O(1) 的根本原因。反过来,如果要在中间插入或删除元素,那后面的元素都要整体搬移,最坏情况下是 O(n)。
理解了这一点,很多问题就迎刃而解。比如为什么数组索引从 0 开始?就是因为首地址对应的就是第 0 个元素,访问第 0 个元素时偏移量为 0,计算最简单;如果从 1 开始,每次定位都要多做一次“地址 - 1”的运算。这个细节虽然小,但很多面试官喜欢拿来探底。
1.2 数组与链表的区别:先想清楚再选型
数据结构的选型,本质上是牺牲什么换什么。数组牺牲了插入删除的效率,换来了快速随机访问和更紧凑的内存占用;链表则反过来,牺牲随机访问能力,换来的是 O(1) 的插入删除(前提是已经定位到节点)。我用食堂排队打个比方:数组就是每个人都固定坐在一个隔间里,服务员喊“3号”,3号位的人直接应答;链表则是大家手拉手排成一串,你只能从第一个人开始一个个往后问。
实际开发里,很多场景其实需要的是“按位置快速读数据”,这时候数组天然是最优解。比如缓存一批配置项、按索引读取排行榜数据、用二维数组存地图格子。但我见过不少初学者一上来就无脑用 ArrayList,虽然它能自动扩容,但扩容的本质也是拷贝老数组,当你有大量中间插入、删除操作时,性能会非常难看。这个后面第 6 节我会专门展开聊。
2. 数组的声明、创建与初始化:细节里全是坑
声明数组看起来就是一行代码的事,但里面至少有三个认知死角:写法不统一、默认值记错、引用传递导致原数组被改。我一条条说。
2.1 声明数组的几种写法,用哪种?
Java 里数组声明有两种风格,一种是把[]写在类型后面,一种写在变量名后面:
int[] arr1; // 推荐风格 int arr2[]; // 早期C风格,能用但很容易跟变量声明混淆我强烈建议统一用int[]这种写法。原因很实际:int[]让人一眼就看出“arr1 的类型是 int 数组”,而arr2[]容易被误解成“arr2 是一个 int,后面带个方括号”,尤其在方法参数里很容易引起阅读障碍。而且int[] a, b;和int a[], b;的语义完全不同,前者声明了两个数组,后者声明了一个数组一个普通 int,这种坑遇到过两次就会长记性。
创建数组有几种常见方式:
int[] a = new int[5]; // 指定长度,元素用默认值填充 int[] b = new int[]{1, 2, 3}; // 声明并赋值,不能写长度 int[] c = {1, 2, 3}; // 语法糖,只能在声明时使用这里有个很经典的坑:new int[5]{1,2,3,4,5}是语法错误,数组初始化器({})和显式长度不能同时出现。因为长度已经由元素个数决定了,再写一遍是冗余的。另外c这种写法只能用在声明语句里,如果先声明后赋值,比如int[] c; c = {1,2,3};会直接编译报错,必须写成c = new int[]{1,2,3};。
2.2 默认值与边界问题:你以为的空间不是空间
创建int[5]时,5 个元素都会被赋予对应类型的默认值:整型是 0,浮点型是 0.0,布尔型是 false,引用类型是 null,char 是 '\u0000'。很多新手以为必须手动赋值才能用,其实数组创建后已经有一组“零值”在等着你了。
数组长度在创建时一旦确定,就再也不能改变。a[5]永远是非法的,因为有效索引范围是 0 到 length-1。这里有个容易忽略的知识点:new int[0]是合法的,它创建了一个长度为 0 的数组,这种“空数组”在返回值不想用 null 时非常有用,能省掉一堆空指针判断。真实项目里我习惯用空数组而不是 null 作为返回默认值,能大幅减少调用方的判空负担。
2.3 引用传递与拷贝:为什么方法里改了数组,外面也变了
这也是面试高发题。数组作为方法参数时传的是引用,而不是值拷贝。意思是,方法内部拿到的是同一个数组对象的地址,你改了arr[0],外部那套数组也跟着变。这跟基本类型参数完全不同,基本类型是值传递,数组是引用传递。
public static void change(int[] arr) { arr[0] = 99; } public static void main(String[] args) { int[] data = {1, 2, 3}; change(data); System.out.println(Arrays.toString(data)); // [99, 2, 3] }如果需要保护原数组不被修改,就得考虑拷贝。拷贝也有讲究:Arrays.copyOf和System.arraycopy都是浅拷贝。对于基本类型数组,浅拷贝等于深拷贝,互不影响;但对于对象数组,拷贝出来的是“一堆引用”,两个数组指向同一个对象。一个常见的误操作就是用clone()去拷贝对象数组,以为万事大吉,结果改一个对象,两个数组都变了。连同System.arraycopy也是同样的逻辑,拷贝的是引用,不是对象本身。
3. 遍历、转字符串与扩容:数组开发中的高频操作
数组嘛,创建完终归是要用的。遍历是基础,转字符串是调试利器,复制扩容是高级玩法。这个章节我把这三件事一次讲透。
3.1 遍历数组:三种方式,各有利弊
普通 for 循环是最原始的遍历方式,可以拿到索引,也可以根据自己的需要修改遍历顺序。增强 for 循环简洁,可以避免写错索引,但它拿不到当前下标,也不能在遍历过程中修改数组元素(修改的是局部变量副本)。Java 8 之后还有基于Arrays.stream的方式,适合配合 lambda 做过滤、映射、求和等操作。
int[] scores = {90, 85, 78, 96}; // 普通 for for (int i = 0; i < scores.length; i++) { System.out.println(scores[i]); } // 增强 for for (int s : scores) { System.out.println(s); } // Java 8 Stream Arrays.stream(scores).forEach(System.out::println);增强 for 之所以方便,是因为它隐藏了索引,语法层面自动帮你迭代。但如果你需要在遍历过程中记录“当前是第几个元素”,或者需要从后往前遍历,还是得老老实实写普通 for。另外,增强 for 底层是一个隐式的 Iterator 过程,对数组来说性能差距不大,但理解它的语义能帮你避免“遍历时删集合元素”这种经典错误。
3.2 数组转字符串:调试输出的三板斧
数组转字符串,很多新手会直接System.out.println(arr),打印出来是一个类似[I@1b6d3586的地址串,根本看不到内容。想看到内容,最方便的是Arrays.toString(),它能输出[1, 2, 3]这种格式,一维数组用它最合适:
int[] arr = {5, 2, 8, 1}; System.out.println(Arrays.toString(arr)); // [5, 2, 8, 1]但如果是二维数组,Arrays.toString()只能打印“每个一维数组的地址串”,要么自己写双重循环,要么用Arrays.deepToString()。这个deep就代表它会递归地把每一层数组都展开成字符串,非常适合调试。
如果想把数组拼接成自定义格式的字符串,比如用逗号、分号、竖线分隔,Arrays.toString 就不够灵活了,常见有两种路子。一种是遍历后拼 StringBuilder,另一种是 Java 8 的String.join,不过String.join的参数要求是CharSequence集合,所以要把 int 数组先转成List<String>,整体代码并不短。我个人最常用的是 Stream 的写法:
String result = Arrays.stream(arr) .mapToObj(String::valueOf) .collect(Collectors.joining(","));这行代码把 int 数组变成字符串流,再转成 String,再按逗号拼接,代码量少,语义也清楚。对于对象数组,可以用Arrays.stream(objArr).map(Object::toString).collect(Collectors.joining(", ")),思路完全一样。数组转字符串这个场景,在面试里也经常被拿来做“聊天式提问”,所以建议几种方式都挑最顺手的练熟。
3.3 数组复制与扩容:ArrayList 的“成长”秘密
数组长度不能变,这是一个硬规则。但你用 ArrayList 的时候,长度却能一直变,这是为什么?秘密就在于 ArrayList 底层其实还是一个数组,它在快满的时候会先创建一个更大的新数组,然后把旧数组的值挨个复制过去,再让底层引用指向新数组。所以你每次list.add()触发的扩容,背后都是一次数组拷贝。
数组拷贝本身最核心的方法是System.arraycopy,它是 native 方法,系统级的内存搬移,性能远高于自己写 for 循环赋值。Arrays.copyOf内部也是基于System.arraycopy实现的,属于封装好的简便入口:
int[] original = {1, 2, 3}; int[] expanded = Arrays.copyOf(original, 5); // 新数组长度为5,前3个是1,2,3,后面是0Arrays.copyOf如果指定的新长度小于原长度,相当于截断,只保留前面几个元素,这也是一种“截取前 N 个元素”的快捷方式。当你在实际开发中需要给数组“扩容”时,千万不要写循环逐个赋值,直接用Arrays.copyOf或者System.arraycopy就好,代码简洁,性能也稳妥。
4. 排序、去重与双指针:面试考数组,翻来覆去就是这三板斧
从热搜词就能看出来,Java 数组刷屏的内容永远是这几类:排序、去重、转字符串、二维数组和区间最值。这也是有原因的,这些题目既能考察语言基础,又能延伸出算法思维,作为面试八股简直是天然题库。这一节我挑三个最典型的展开。
4.1 冒泡排序:写对容易,写好需要想一步
排序是面试手写代码的保留节目。Java 里真要排数组,直接Arrays.sort(arr)就完事了,底层是 DualPivotQuicksort 或者 TimSort,比你手写任何排序都快。但面试官让你手写冒泡,考的是你的基本功,看你能不能注意边界、能不能想到优化:
public static void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { boolean swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; swapped = true; } } if (!swapped) { break; // 这一轮没有交换,说明已经有序,提前结束 } } }这段代码有一个特别加分的优化:加了一个swapped标志位。如果某一轮完整比较下来没有任何交换,说明数组已经有序,就可以提前跳出循环。对基本有序的数据,这个优化能把平均时间复杂度从 O(n^2) 拉到接近 O(n)。很多面试者能写出双层循环,但能主动想到这个优化的,说明他真正理解了排序的本质。
顺带一提,如果你对“选择排序”也熟悉,会发现写法上跟冒泡很像,但选择排序是每轮找到最小值的下标,最后交换一次,交换次数比冒泡少很多。面试时如果让你手写排序,可以先把思路说出来再动手:这种表达方式,比闷头写代码更能体现逻辑清晰。
4.2 数组去重:没有银弹,看你要不要保留顺序
数组去重是另一个高频题。最简单的做法是利用LinkedHashSet,它既去重又能保持插入顺序:
int[] arr = {3, 1, 2, 1, 3, 4}; LinkedHashSet<Integer> set = new LinkedHashSet<>(); for (int num : arr) { set.add(num); } Integer[] result = set.toArray(new Integer[0]);注意这里转出来的是Integer[]而不是int[],因为toArray不支持基本类型数组。如果想拿回int[],需要再遍历一次装箱拆箱,或者用 Stream:
int[] result = Arrays.stream(arr).distinct().toArray();这一行同样是“去重 + 保持相对顺序”的解法,底层就是 LinkedHashSet 那套思路。
如果你是面试环境里不能用现成 API,那就得自己写。通常有两条线:一条是用双重循环嵌套,逐个判断当前值在前面是否出现过,缺点是最坏复杂度 O(n^2);另一条是先排序再相邻比较,因为排序后相同的元素一定会靠在一起,去重就变成“遇到跟上一个不同就保留”,复杂度主要由排序决定,一般是 O(n log n)。两条线我都建议能写出来,因为面试官很喜欢追问“如果不让你用 Set,你怎么做”。
4.3 双指针技巧:从数组去重到区间最值,一套思路通吃
双指针是数组类算法题里一个非常核心的技巧。最经典的场景是“有序数组原地去重”,比如[1,1,2,2,3,3,4]变成[1,2,3,4],要求不新建数组。这就是热搜词里“js快慢指针有序数组原地去重”所指的内容,Java 里思路完全一样。
public static int removeDuplicates(int[] arr) { if (arr.length == 0) { return 0; } int slow = 0; for (int fast = 1; fast < arr.length; fast++) { if (arr[fast] != arr[slow]) { slow++; arr[slow] = arr[fast]; } } return slow + 1; // 新数组长度 }快指针 fast 负责探路,慢指针 slow 指向“去重后数组的最后一个位置”。每次遇到一个新值,就把 slow 后移一位并写入新值。这样一轮循环下来,数组前slow+1个位置就是不重复元素,复杂度 O(n),空间 O(1),非常漂亮。
同一种“指针滑动”的思想也能用到区间最值问题上,比如“求窗口内最大值”。你可以用单调队列:队列里维护“可能是最大值的下标”,每次窗口右移时,移除过期下标,再删掉队尾所有比新值小的元素,这样队首永远是当前窗口的最大值。第一次学这个解法会有点绕,但写熟之后,你会发现它跟双指针去重的内在逻辑很像:都在用指针维护一个有效区间,避免重复计算。
5. 二维数组与Arrays工具类:从“会用”到“用好”
二维数组在热搜词里出现频率很高,原因很简单,它是“矩阵”的直接对应,游戏地图、图像像素、表格数据,全都适合用二维数组。但很多人在理解二维数组的时候,容易把它想成一个矩形表格,这个理解其实不够本质。
5.1 二维数组的本质:数组的数组
Java 里根本没有真正的“二维数组”,所谓int[][],本质是一个“元素类型是 int[] 的数组”。也就是说,外层数组的每一个元素,其实都是一个一维数组的引用。这一点非常关键,因为它意味着二维数组不一定是“矩形”的,每一行的长度可以不一样,这叫“不规则数组”:
int[][] matrix = new int[3][]; matrix[0] = new int[]{1, 2}; matrix[1] = new int[]{3, 4, 5}; matrix[2] = new int[]{6};打印这个二维数组时,用Arrays.toString(matrix)只能看到三个地址串,正确姿势是用Arrays.deepToString(matrix),它会把每一层都展开。遍历的时候也别想着matrix[i].length一定都相等,实际开发中直接用matrix[i].length作为内层循环上限就行,不要去假设行列固定。
二维数组的内存布局算是面试里的进阶考点:外层数组对象持有指向多个一维数组的引用,这些一维数组在堆里是各自独立的对象。所以“二维数组”在内存里并不是一个大的连续矩形,而是由多个一维数组拼出来的。这也是为什么对二维数组做深拷贝要格外小心:直接克隆外层数组,得到的还是指向同一堆内层数组的引用数组。
5.2 Arrays工具类:每个Java开发者都该背下来的表
java.util.Arrays是我使用频率最高的工具类之一。它的方法看起来很多,但核心就那么几个,我按使用频率列个表:
| 方法 | 作用 | 注意事项 |
|---|---|---|
| sort(int[]) | 数组排序 | 自定义对象需要 Comparable 或 Comparator |
| binarySearch(int[], int) | 二分查找 | 要求数组必须先排序,否则结果不确定 |
| fill(int[], int) | 填充整个数组 | 对象数组填充的是同一个引用 |
| copyOf(int[], int) | 复制或截断数组 | 返回新数组,不影响原数组 |
| equals(int[], int[]) | 比较数组内容 | 比较的是内容,不是引用,多维数组用 deepEquals |
| toString(int[]) | 转字符串 | 多维数组用 deepToString |
| parallelPrefix | 前缀计算 | Java 8 引入,适合并行累积计算 |
这些方法里,binarySearch的坑最大。很多人忘了要先排序就直接调用,结果返回一个莫名其妙的负值,还以为是数组里没有这个元素。实际上负值表示的是“如果插入到某个位置,应该落在哪个下标”的补码,而不是简单的“没找到”。所以用之前先Arrays.sort(arr),这是最稳妥的习惯。
fill也藏着一个坑:如果你Arrays.fill(objArr, new SomeObject()),那么数组里的每一个元素,其实都指向同一个对象。后续你改arr[0].setX(1),会发现整个数组的对象都变了。要创建“多个独立对象”,还是得用循环或 Stream 逐个 new。
5.3 数组与集合互转:两个经典陷阱
开发中经常要跟List打交道。从List转数组,推荐写法是:
List<String> list = new ArrayList<>(Arrays.asList("a", "b")); String[] arr = list.toArray(new String[0]);这里的new String[0]不是必须刚好装下,只是一个类型指示,JDK 内部会自行分配合适大小的新数组。老代码里有写new String[list.size()]的,性能和语义上虽然没大问题,但现在官方推荐的入参长度用 0 更简洁,语义也更贴近“我只想拿类型”。
反过来,数组转List有个著名陷阱:Arrays.asList()返回的并不是java.util.ArrayList,而是 Arrays 内部定义的一个私有静态类,它虽然实现了List接口,但底层仍然基于原数组,长度不可变。你执行asList(...)之后直接add元素,会抛出UnsupportedOperationException。要转成真正的可变集合,必须外面再包一层:
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));Arrays.asList还有一个容易踩的坑:如果你传的参数是int[],它不会把数组里的每个 int 拆成元素,而是把整个 int 数组当成一个对象,得到一个大小为 1 的List<int[]>。这是泛型不支持基本类型导致的经典问题,解决办法是先转成Integer[],或者直接用 Stream 收集。这些细节看起来小,但面试卷子上的选择题最喜欢从这里出。
6. 数组的性能边界与选型:不是所有场景都该用数组
写到这里,我想聊一个比语法本身更重要的东西:数组用得好不好,本质是性能边界判断得准不准。Java 里很多集合的结构其实都是基于数组演化的,但数组也有它的局限性,硬用会造成不必要的性能损耗。
6.1 ArrayList扩容的代价:你以为的“动态数组”其实很贵
很多人喜欢用ArrayList是因为它“不用管长度”。但你看过它的源码之后就会明白,它只是一个会自动扩容的数组封装。默认初始容量为 10,每次扩容大约是 1.5 倍,扩容时要创建新数组并通过System.arraycopy把旧数据搬过去。所以如果你能提前预估数据量,最好在构造ArrayList时直接指定初始容量,避免反复扩容搬运。
举个例子,循环里往 ArrayList 添加 100 万个元素,如果初始容量设置得当,拷贝次数只有几次;如果不设置,ArrayList 从 10 开始一路倍增,虽然次数也不多,但每次扩容都是全量拷贝,浪费的赋值操作会积少成多。在数据量大、延迟敏感的系统里,这个细节能肉眼可见拉开差距。
6.2 数组、ArrayList、LinkedList:到底怎么选
很简单:只有当你明确知道“会按索引随机访问很多次,或者元素数量固定不变”的时候,原生数组才是最优解;如果元素数量会动态变化,首选 ArrayList;只有当你需要频繁在头部或中间插入删除,且不经常按下标访问时,才考虑 LinkedList。大部分业务代码里 LinkedList 的出场率很低,因为它每个节点要额外存储前后指针,内存开销比数组大多了,而且随机访问是 O(n),用不好反而是负担。
| 数据结构 | 随机访问 | 插入/删除(头部/中间) | 内存占用 | 适用场景 |
|---|---|---|---|---|
| 数组 | O(1) | O(n) | 紧凑 | 数量固定、频繁按下标读 |
| ArrayList | O(1) | O(n),尾部均摊 O(1) | 比数组多一层封装开销 | 动态增长、尾部追加为主 |
| LinkedList | O(n) | O(1)(已知节点时) | 每节点多个指针,开销大 | 频繁头尾插入删除,极少数场景 |
我见过不少后端同事处理一个“删除中间某几个元素”的需求时,直接在一个大的ArrayList里循环remove,结果每次都触发数组搬移,数据量一上万就明显卡顿。其实这种情况要么先标记再统一重建数组,要么换链表结构,总之不要无脑直接循环删除。这个思维转变很重要:数组/容器的选型,永远要看你最频繁的操作是什么。
6.3 一点个人心得:把数组当“基本功”而非“简单玩意”
聊到最后,分享一个我自己的印象。很多刚学 Java 的人觉得数组太基础,没什么好学的,于是急着去冲 Spring、微服务这些高深的东西,结果面试一考手写算法就露怯。实际上,越基础的东西越能反映一个开发者的功底。数组背后的连续内存、引用传递、复制语义、扩容逻辑、双指针思维,每一个都跟你后面学的集合框架、JVM内存、性能优化、算法题紧密相关。把这些真正搞懂,你去看 ArrayList 源码、看 HashMap 的位运算、看各种排序算法,都会轻松许多。
我个人还有个习惯:每当碰到一个跟数组相关的 bug 或性能问题,都会顺手记到笔记里,标注原因和解决思路。这种积累多了之后,你会发现很多所谓的高级问题,最后都能拆解到“数组到底是怎么存、怎么取、怎么复制”这些最底层的问题上。把基本功打牢,是真的能省下大量排障时间的。