1. 揭开数组的面纱:它到底是什么
在Java开发里,数组恐怕是初学者最早接触、也最容易"以为学会了"的数据结构。不少人写了两年代码,new int[10]用了无数次,但真被问到"数组在JVM里长什么样""为什么数组越界报的是运行时异常""int[]和Integer[]有什么区别",照样答得支支吾吾。这篇文章就围绕Java数组的定义与使用,把底层的原理、日常的用法和面试里常见的坑一次讲透。
先说一句不那么"基础"的结论:Java数组不是简单的一排变量,它本身就是一个对象。这是理解后续一切知识点(内存分配、引用传递、数组的length为什么是属性而不是方法)的根基。int[] arr在HotSpot虚拟机里对应一个类型为[I的Class对象,直接父类是Object。所以你可以把数组赋值给Object,也可以调用它的toString、hashCode(虽然通常没意义),这一点和C/C++里"数组就是一段连续内存的起始地址"有本质区别。
既然要系统讲数组的定义和使用,我按自己平时带新人时的思路来组织:先讲一维数组的声明、初始化与内存分配,再讲遍历和读写中的那些"你以为没问题"的细节,然后讲多维数组的真实内存结构,接着是Arrays工具类和日常算法里的高频套路,最后把面试中数组相关的问题和典型错误一起梳理一遍。这样一层一层展开,能覆盖从入门到进阶的完整链路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一维数组的定义:三种写法、五个细节
2.1 声明方式与JVM内存分配
一维数组的声明有三种常见写法:
java复制int[] a; // 推荐,类型和变量名分开更清晰
int b[]; // C语言风格,Java里合法但不推荐
int c[]; // 多变量声明时容易误导
注意,声明只是创建了一个引用变量,并没有分配数组本身的内存。真正的数组实例通过new创建,或者通过静态初始化创建:
java复制int[] a = new int[5]; // 动态初始化申请5个int的空间
int[] b = {1, 2, 3}; // 静态初始化,底层等价于new int[]{1,2,3}
int[] c = new int[]{4, 5}; // 匿名数组对象,可以用在方法参数里
从JVM角度看,执行int[] a = new int[5]时到底发生了什么?首先在堆中分配一块连续的内存区域,大小是length * 元素类型大小(还要算上对象头),这块内存的地址被赋给栈上的引用变量a。数组元素的默认值规则是:整型为0,浮点型为0.0,布尔型为false,引用类型为null,char型为'\u0000'。这个细节写代码时不一定感觉到,但定位"为什么数组元素没有初始化就出现了0"这类问题时很有用。
2.2 动态输入的经典场景
数组的定义离不开从外部获取数据的场景。平时写算法题或处理数据,最常见的就是从控制台读取指定个数的整数,存进数组再处理。看一个完整示例:
java复制Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] nums = new int[n];
for (int i = 0; i < n; i++) {
nums[i] = sc.nextInt();
}
这里的细节是:先读n,再根据n创建数组,最后逐一填充。顺序不能反过来,因为数组长度在创建时就必须确定。Java里的数组是定长的,不存在"动态扩容"一说——你可能会说ArrayList可以扩容,但ArrayList内部实际上是创建新数组再拷贝,而不是原地把数组变大。
2.3 静态初始化的语法约束
静态初始化有一个新手容易踩的语法点:不能同时指定长度和初始值。
java复制int[] arr = new int[3]{1,2,3}; // 编译报错!
int[] arr = {1,2,3}; // 正确
int[] arr = new int[]{1,2,3}; // 正确
原因是这两者的信息互相冗余——花括号里的元素个数已经隐含了长度,再写一个长度就是矛盾。这种约束背后其实是Java语言设计时对"状态一致性"的坚持:一个对象要么由构造参数确定一切,要么由初始化器确定一切,不能两边各说各话。
还有一个细节:如果同时声明多个数组变量,要注意每个变量都要单独写new。
java复制int[] a, b, c; // a,b,c全是int[]引用
a = new int[3];
b = new int[3];
c = new int[3]; // 必须逐个new,不存在类似int[3] a,b,c的批量写法
我们团队里有一个新同事写过int[] x = new int[2], y = new int[2];这种混写,编译是能通过的,但可读性很差,还是推荐分开写。代码是给人看的,顺便给机器执行而已。
3. 数组的遍历读写:从for循环到增强for的真正差异
3.1 三种遍历方式的取舍
数组最常见的操作就是遍历。Java里遍历数组有三种主流方式:普通for循环、增强for循环(for-each)、以及借助Stream(Java 8+)。
普通for循环的写法是index驱动的:
java复制for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
增强for循环的写法是元素驱动的:
java复制for (int num : arr) {
System.out.println(num);
}
很多初学者以为增强for只是普通for的语法糖,其实不完全对。增强for遍历数组时,每次迭代都把元素的值拷贝到局部变量num里。对于基本类型数组,你改动num不会影响原数组;对于对象数组,num拷贝的是引用,所以num指向的对象的属性还是可以改的,但num本身重新赋值不会写回数组。这一点在面试里经常被拿出来问。
实测性能上,对于基本类型数组,普通for和增强for编译后生成的字节码几乎一致,基本没有性能差异。但如果遍历的是链表类的集合,增强for迭代器比index方式快得多,因为get(i)需要从头遍历。养成习惯:遍历数组用for-each,需要下标计算时用普通for。
3.2 反转数组与交换的坑
数组读写最典型的练手题目是反转数组。标准写法是双指针夹逼:
java复制int left = 0, right = arr.length - 1;
while (left < right) {
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
left++;
right--;
}
这里有一个连有工作经验的人都会犯的错:把交换写成arr[left] = arr[right]; arr[right] = arr[left];,那就等于把后面的值复制了两遍,前面的值直接丢了。交换必须用临时变量,或者用异或技巧(虽然我不推荐,可读性差):
java复制arr[left] ^= arr[right];
arr[right] ^= arr[left];
arr[left] ^= arr[right];
不推荐异或交换的原因很实际:一是不如临时变量直观,二是如果left和right指向同一个下标,异或完直接变成0,整型数组会出现一个隐藏bug。工程上写代码的首要目标是可读性和正确性,不是炫技。
3.3 length是属性还是方法?为什么没人用contains
数组有一个天然的属性length,注意这是一个final的公开字段,不是方法。所以:
java复制int len = arr.length(); // 错,报错!
int len = arr.length; // 对
对比String的length()方法和集合的size()方法,数组这个设计独树一帜,原因是数组从语言层面就内建支持,不像String和集合是类库设计,命名各不相同。这个差异也导致了面试八股文里经典的"数组用什么查长度"问题。
另一方面,数组本身没有contains方法,想判断一个元素是否在数组里,最简单的办法是写循环:
java复制boolean contains(int[] arr, int target) {
for (int num : arr) {
if (num == target) return true;
}
return false;
}
如果数组已经排序,可以用Arrays.binarySearch;如果数据量很大且频繁查找,建议换成HashSet。用集合不是"数组不行",而是"数据结构和场景要匹配"。
4. 多维数组:不是二维矩阵,而是数组的数组
4.1 二维数组的真实内存结构
Java里int[][]的声明可以这样理解:它本质上是"一个数组,里面每个元素又是一个int[]"。这跟C语言的二维数组完全不同——C语言的int a[3][4]是一块连续的12个int内存,而Java的int[][]是外层数组存了3个引用,每个引用指向一个独立的内层数组对象。
用代码验证一下:
java复制int[][] matrix = new int[3][4];
System.out.println(matrix.length); // 3,外层长度
System.out.println(matrix[0].length); // 4,第一行长度
System.out.println(matrix[0].getClass().getName()); // [I
System.out.println(matrix.getClass().getName()); // [[I
正因为Java二维数组是"数组的数组",所以它的每一行长度可以不同。这种结构叫锯齿数组(Ragged Array),C语言里做不到,Java天然支持。
4.2 不规则的二维数组与杨辉三角
先看一个经典例子:杨辉三角。它的特性是第i行有i+1个元素,行长度递增,正好适合锯齿数组:
java复制int[][] yanghui = new int[5][];
for (int i = 0; i < yanghui.length; i++) {
yanghui[i] = new int[i + 1];
yanghui[i][0] = 1;
yanghui[i][i] = 1;
for (int j = 1; j < i; j++) {
yanghui[i][j] = yanghui[i-1][j-1] + yanghui[i-1][j];
}
}
关键在于int[5][]先只确定外层长度,内部每一行用new int[i+1]单独创建,这就实现了"每行不同长度"。如果写int[5][5],每行就是定长5,没法变成三角形。
4.3 遍历顺序与缓存命中
数组遍历顺序对性能影响很大,尤其在数据量大的时候。对二维数组按行遍历(外层循环控制行,内层循环控制列)比按列遍历快不少,原因是CPU缓存局部性:内层数组在内存中是连续的,按顺序访问时能命中共提前加载的缓存行。按列跳着访问时,每次都很可能发生缓存未命中,性能差距在百万级数据上可以差出好几倍。
写一段直观的对比代码:
java复制long start = System.nanoTime();
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
sum += matrix[i][j];
}
}
long rowMinor = System.nanoTime() - start;
start = System.nanoTime();
for (int j = 0; j < cols; j++) {
for (int i = 0; i < rows; i++) {
sum += matrix[i][j];
}
}
long colMajor = System.nanoTime() - start;
在高数据量下,rowMinor通常比colMajor快得多。这不是什么玄学,就是内存布局和CPU预取的直觉问题。日常写代码,多想想数据在内存里是怎么放的,很多性能问题能凭空消失。
4.4 椭圆数组的创建方式
除了上面说的逐行new,还有一种创建二维数组的方式:先分配行,再分配列,也可以分行单独创建。
java复制int[][] arr = new int[3][]; // 只分配外层
arr[0] = new int[]{1, 2};
arr[1] = new int[3];
arr[2] = new int[]{4, 5, 6, 7};
这种方式在渲染棋盘、图像区域、稀疏矩阵等场景很实用。比如地图的每一行障碍物数量不同,就能用锯齿数组节省大量内存。
5. 数组工具类与算法的实战组合拳
5.1 Arrays.sort和DualPivotQuicksort
Java标准库对数组的支持集中在java.util.Arrays里。首当其冲的是排序。很多人写完数组排序第一反应是自己写冒泡排序,这当然可以练手,但实际工程中直接用:
java复制int[] arr = {5, 2, 8, 1, 9};
Arrays.sort(arr); // 全量升序
Arrays.sort(arr, 0, 3); // 只排序[0,3)区间的元素
Java的Arrays.sort对基本类型数组使用DualPivotQuicksort(双轴快排),对对象数组使用Timsort或ComparableTimSort。双轴快排的平均时间复杂度是O(n log n),在大量真实数据上表现比经典单轴快排稳定,因为它一次划分选取两个枢轴,减少了递归深度和元素移动次数。
有个高频面试题:为什么基本类型数组用快排,对象数组用归并类的Timsort?答案有两层。第一,快排是不稳定排序,对基本类型没有影响(1和1没有区别),但对象排序通常希望维持相等元素的相对顺序,所以需要稳定排序。第二,TimSort对部分有序数组做了优化,实际运行效率很高。
5.2 binarySearch的边界陷阱
排序之后就可以二分查找了:
java复制Arrays.sort(arr);
int index = Arrays.binarySearch(arr, 5);
但Arrays.binarySearch有一个著名的"负数陷阱":找不到的时候,返回值是-(insertion point) - 1,也就是"-应该插入的位置-1"。我第一次用的时候以为返回-1表示没找到,结果有个数组里没有的元素返回了-5,我的代码if (index == -1)根本没拦截住。后来学乖了,统一用index >= 0判断是否找到。这个设计其实很巧妙,把"未找到"和"应该插到哪"两个信息编码在一个返回值里,但不熟悉的人确实容易踩坑。
5.3 copyOf与arraycopy的性能差异
数组拷贝也有讲究。System.arraycopy是native方法,用C/C++在底层实现内存块搬运,性能最高;Arrays.copyOf内部也调用了System.arraycopy,但多了一次new数组的操作;Arrays.copyOfRange支持从原数组中间截取。
java复制int[] copy = Arrays.copyOf(arr, arr.length); // 拷贝全量
int[] part = Arrays.copyOfRange(arr, 1, 4); // 拷贝[1,4)
System.arraycopy(src, srcPos, dest, destPos, len); // 手动控制
这几个方法的使用场景不同:固定拷贝用System.arraycopy,需要新对象用Arrays.copyOf,截取片段用copyOfRange。项目里做分页、快照拷贝时都频繁用到,建议每个都亲手写一次。
5.4 asList背后的引用传递陷阱
Arrays.asList是一个高频但充满陷阱的方法。先看现象:
java复制Integer[] arr = {1, 2, 3};
List<Integer> list = Arrays.asList(arr);
list.add(4); // 报错UnsupportedOperationException
原因是Arrays.asList返回的不是java.util.ArrayList,而是Arrays内部的一个私有静态类ArrayList,它直接包装了原数组,底层没有实现add/remove方法。另外,如果传入的是int[]而不是Integer[],asList会把整个int[]当作一个对象,得到的是List<int[]>而不是List
正确姿势:
java复制int[] arr = {1, 2, 3};
List<Integer> list = new ArrayList<>(arr.length);
for (int num : arr) {
list.add(num);
}
// 或者用Java 8的Stream
List<Integer> list = Arrays.stream(arr).boxed().collect(Collectors.toList());
这一点在面试题里出现的频率极高,本质考的是"自动装箱不适用于数组"以及"内部类ArrayList和ArrayList的差异"。
5.5 equals、toString与deep版本的坑
数组是比较时最容易犯错的点之一。直接用==比较两个数组,比的是引用地址而不是内容。要比较内容,得用Arrays.equals或Arrays.deepEquals:
java复制int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
System.out.println(a == b); // false
System.out.println(Arrays.equals(a, b)); // true
打印数组也是同理,直接用System.out.println(arr)输出的是类似[I@15db9742的哈希值。想看内容必须用Arrays.toString(arr)。二维数组还得用Arrays.deepToString。
在开发中这种坑特别隐蔽。有一次我排查一个"登录状态对不上"的bug,查了半天发现是把两个byte[]用==比较了,内容明明一样却永远不相等。遇到数组比较,第一反应就该是Arrays.equals或deepEquals。
6. 数组操作的高频面试题与实战演练
6.1 数组去重的三种思路
数组去重是面试里出镜率极高的一类题。根据输入类型不同,有不同方案。
第一种,使用Set去重,最简单:
java复制int[] arr = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
Set<Integer> set = new LinkedHashSet<>();
for (int num : arr) {
set.add(num);
}
// 如果需要保持原顺序,LinkedHashSet很合适
但Set丢失了"数组"形态,要求返回数组时还要再转一次。而且对于int[],直接用Arrays.asList再转Set会出问题(前面已经说过了,int[]整体会被当成一个元素)。正确做法是循环填充。
第二种,排序后去重,适合不需要保持原始顺序的场景:
java复制Arrays.sort(arr);
int n = arr.length;
int[] temp = new int[n];
int idx = 0;
for (int i = 0; i < n; i++) {
if (i == 0 || arr[i] != arr[i-1]) {
temp[idx++] = arr[i];
}
}
int[] result = Arrays.copyOf(temp, idx);
原理是排序后重复元素必然相邻,一次扫描就能去重。时间复杂度O(n log n),空间O(n)。
第三种,用哈希表判断是否出现,适合需要保持原始顺序且数据量大的场景:
java复制Set<Integer> seen = new HashSet<>();
int[] temp = new int[arr.length];
int idx = 0;
for (int num : arr) {
if (seen.add(num)) {
temp[idx++] = num;
}
}
int[] result = Arrays.copyOf(temp, idx);
6.2 双指针合并有序数组
"双指针"是数组类算法题里使用频率最高的技巧之一,热搜词里也提到了"双指针合并有序数组"。场景是:给定两个升序数组,合并成一个升序数组。
java复制int[] merge(int[] a, int[] b) {
int i = 0, j = 0, k = 0;
int[] res = new int[a.length + b.length];
while (i < a.length && j < b.length) {
if (a[i] <= b[j]) {
res[k++] = a[i++];
} else {
res[k++] = b[j++];
}
}
while (i < a.length) res[k++] = a[i++];
while (j < b.length) res[k++] = b[j++];
return res;
}
核心思想是"谁小谁先走",两个指针分别指向两个数组的头部,每次比较后取较小值,然后对应的指针往后移。最后把剩余部分拼接上。归类一下,双指针的常见变体还有"快慢指针"(检测环、找中点)、"左右夹逼"(有序数组找两数之和)。理解了一份,其他都是同一套思维。
再补充一个"从尾部合并"的思路,很多面试官喜欢考有序数组合并的原地版——如果nums1有足够空间,可以从后往前填充,避免整体移动元素。这也是归并排序merge过程的简化版。
6.3 KMP算法中的next数组到底怎么推
热搜词里出现了KMP算法和next数组,这个确实和数组强相关,而且面试里推导起来比较抽象。KMP的核心是:模式串p="abacaba"对应的next数组(有的教材里叫部分匹配表或prefix function),在模式匹配失败时不回退主串指针,只回退模式串指针。
以p = "abacaba"为例,手动推next数组。约定next[i]表示"p[0..i]这个前缀中,既是前缀又是后缀的最长长度"。
- i=0,字符a,没有真前缀真后缀,next[0]=0
- i=1,子串ab,前缀集合{a},后缀集合{b},交集为空,next[1]=0
- i=2,子串aba,前缀{a,ab},后缀{a,ba},交集为{a},长度1,next[2]=1
- i=3,子串abac,前缀{a,ab,aba},后缀{c,ac,bac},交集为空,next[3]=0
- i=4,子串abaca,前缀{a,ab,aba,abac},后缀{a,ca,aca,baca},交集{a},长度1,next[4]=1
- i=5,子串abacab,前缀{a,ab,aba,abac,abaca},后缀{b,ab,cab,acab,bacab},交集{ab},长度2,next[5]=2
- i=6,子串abacaba,前缀{a,ab,aba,abac,abaca,abacab},后缀{a,ba,aba,caba,acaba,bacaba},交集{a,aba},取最长长度3,next[6]=3
推导出来后,next数组是[0, 0, 1, 0, 1, 2, 3]。这个数组在模式匹配时的作用可以理解为:主串中某个位置匹配失败时,模式串不需要从头再来,而是跳到next[j-1](或next[j]取决于实现版本)的位置继续比较。next数组的构建本身也是一个"自我匹配"的过程,经典的双指针递推实现:
java复制int[] next = new int[p.length()];
for (int i = 1, j = 0; i < p.length(); i++) {
while (j > 0 && p.charAt(i) != p.charAt(j)) {
j = next[j - 1];
}
if (p.charAt(i) == p.charAt(j)) {
j++;
}
next[i] = j;
}
这里j表示当前"已匹配的前后缀长度"。i移动到新的位置时,如果字符相等就扩展长度;不等就利用已有的next数组回退j。整个构建过程是O(n)的,因为j总共的递增量不超过n,回退次数也不会超过递增次数。
如果你第一次接触KMP觉得绕,别急,先盯着代码一步步手推两遍。等理解next数组本质是一个"失配时告诉模式串跳到哪"的查找表,后面再遇到字符串匹配就不慌了。数组在这里不是存储数据了,而是作为一张决策表来用。
6.4 对象数组与泛型数组的创建限制
日常开发里对象数组也常遇到。比如一个User[],用来存一批用户信息:
java复制User[] users = new User[10];
users[0] = new User("Tom", 20);
注意,new User[10]只是创建了一个能放10个User引用的数组,不会自动创建10个User对象,数组中每个元素初始是null。如果忘记给元素赋值就调用users[0].getName(),就会出现NullPointerException。拿数组的默认值规则来记:对象数组的默认值是null,它只分配引用空间,不分配对象空间。
泛型数组则有一个更麻烦的限制:Java不允许直接创建泛型数组:
java复制T[] arr = new T[10]; // 编译错误
原因是数组在运行时必须知道具体的组件类型,而泛型通过擦除实现,运行时拿不到T的真实类型。常见绕法是先创建Object[]然后强转:
java复制Object[] tmp = new Object[10];
T[] arr = (T[]) tmp;
但这种强转会引入unchecked warning,本质上是用"数组的运行时类型检查与泛型的编译期类型检查"做了取舍。在工程中,如果非要持有泛型数组,建议用ArrayList
6.5 OOM与数组扩容的边界
热搜词里有一条java: outofmemoryerror: insufficient memory,这和数组也有直接关系。在JVM里创建超大数组是触发堆内存溢出最常见的方式之一:
java复制int[] arr = new int[Integer.MAX_VALUE]; // 尝试申请约8GB堆内存,必爆
即使没有这么夸张,在循环里不断用Arrays.copyOf扩容,也容易把内存耗尽。所以数组扩容涉及一个常见的工程痛点:既然数组定长,要"扩容"怎么处理?
实际有两种做法。一是在使用前估算最大容量,直接分配足量数组,牺牲空间换时间;二是仿照ArrayList实现动态扩容:旧数组满了就创建新数组,容量变为原来的1.5倍,然后拷贝。
ArrayList的扩容不是一个简单的"翻倍"操作,它有讲究:
java复制int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5倍
对比一下:每次只扩1个元素,插入n个元素的时间复杂度会退化到O(n²);扩1.5倍,均摊cost是O(1)。这个数学结论在《算法导论》和ArrayList源码里都有,面试时经常被问到。如果自己设计扩容策略,推荐用倍增或1.5倍增长,不要用等差增长,不要用太小的增长倍数,否则扩容拷贝会拖垮性能。
还有一个细节:JVM的默认最大堆内存是物理内存的1/4,启动参数-Xmx可以调整。申请数组前心里估一下数据量,超过几百MB就要考虑换数据结构(比如分段存储或直接落库),不要硬堆。
7. 从"用对"到"想清楚":数组和高阶思维
数组除了存储数据,还存在"算法优化"中扮演决策表角色。热搜词里有一条"树状数组上二分",这已经是进阶内容了。树状数组(Fenwick Tree)本身用数组存储,但它的下标不是线性位置,而是通过lowbit运算维护一个树状的累加结构。树状数组上二分,是指在O(log n)时间内找出前缀和达阈值的最小位置。这种思路的核心是:数组的下标可以映射到一类范围查询的语义。理解这个应用时,要跳出"数组就是存储"的固有思维。
另外还有几个数组相关的语言扩展,值得快速提一下。在Java 8里你可以在数组上直接跑Stream:
java复制int[] arr = {1, 2, 3, 4, 5};
int sum = Arrays.stream(arr).sum(); // 15
long count = Arrays.stream(arr).filter(x -> x % 2 == 0).count(); // 2
但Stream的使用要视数据量而定,小数组用Stream确实简洁,但中间产生临时对象,对超大数组可能带来额外GC压力,性能敏感场景仍然推荐手写循环。面试时如果主动提到这一点,会有加分效果。
关于"数组为什么能这么高效",还有一层结论值得记住:数组是唯一一种能用O(1)随机访问的数据结构,因为它的元素在内存中连续排列,访问下标i时直接通过地址偏移计算就能定位。地址计算公式:arr[i]的地址 = 数组首地址 + i * 单个元素大小。这个公式在C语言里是理解指针运算的基础,在Java里由JVM替你算好了,但底层逻辑完全一样。
8. 数组常见错误的完整排查思路
8.1 数组越界异常从何而来
ArrayIndexOutOfBoundsException是Java初学者最常遇见的运行时异常之一。它比编译错误更难处理,因为程序能编译通过,只有运行到非法下标时才爆出来。
排查思路一般是这样:看异常堆栈里抛异常的代码行,确认访问的下标和数组的长度边界。最常见的原因有三个:
- 循环条件写成了i <= arr.length,导致最后一次访问arr[length]越界。
- 从键盘或文件读取数据后,数组长度分配得不够,多读了一个元素就爆了。
- 多个数组的下标没有对齐,比如拿A数组的index去访问B数组。
定位手段很简单,一是打印length和i的值,二是在遍历前先做边界断言:
java复制if (index < 0 || index >= arr.length) {
throw new IllegalArgumentException("数组越界: " + index + ", length=" + arr.length);
}
在算法题里,边界就是bug的温床,养成"用length-1做最后一个有效下标"的习惯能少踩很多坑。
8.2 空数组与null容易混淆
null数组和空数组是两回事。null代表引用不指向任何对象,arr.length会抛NullPointerException;空数组new int[0]是一个合法的数组对象,只是长度为0。
一个常见的排查场景:方法接收数组参数,先判断参数是否为null,再判断长度为0。正确的顺序是:
java复制if (arr == null || arr.length == 0) {
return; // 或抛异常
}
如果反过来写arr.length == 0 || arr == null,前一步就抛NPE了。Java的||有短路机制,所以"先判null再判长度"能避免NPE。
平时写工具方法时,建议返回空数组而不是null。返回null强迫调用方做空指针检查,返回空数组则可以直接遍历,这在日常开发里能减少大量防御性代码。
8.3 数组引用共享导致的诡异写入问题
数组也是对象,方法传参时传递的是引用,不是副本。所以在一个方法里修改了数组元素,调用方看到的数组也会变。这个特性有时是期望的,有时就是坑了。
典型的迷惑bug例子:
java复制void clean(int[] nums) {
nums = new int[0]; // 这里只是让局部引用指向了新数组,不影响调用方的nums引用
}
int[] data = {1, 2, 3};
clean(data);
System.out.println(data.length); // 还是3,不是0
如果想清空原数组所有元素,应该这样写:
java复制void clean(int[] nums) {
Arrays.fill(nums, 0); // 原地修改数组内容
}
这个案例的核心是区分"引用本身"和"引用指向的对象"。传引用的时候,修改对象内容是有效的;重新给引用赋值是无效的。这个道理在对象里也同样适用,可以自己拿List验证一下。
8.4 数组与集合的互转误用
实际开发中,数组和集合经常需要互换。最容易出错的就是前面说的Arrays.asList和int[]装箱的问题。我已经踩过这个坑,后来团队里统一规定:基本类型数组转List,一律手动循环,不允许用Arrays.asList原样子用。如果数据量很大,还要考虑boxing的开销,用IntStream更合适。
List转数组也有讲究:
java复制List<Integer> list = new ArrayList<>();
Integer[] arr = list.toArray(new Integer[0]);
关于new Integer[0]和new Integer[list.size()]哪个性能更好,在Java 8之后用new Integer[0]更推荐,因为源码里针对这个情况做了反射优化,而new Integer[list.size()]反而多一次创建一个刚好大小的空数组。这个细节挺反直觉,但确实是源码层面的优化结论。
9. 从数组到优秀代码的三个习惯
讲了这么多定义、使用、算法、排查,最后说点更"软"的东西。数组虽然基础,但它反映了一个人的底层编程思维。我有三个切身的习惯建议。
第一,写for循环时永远只用一个退出条件。特别是遍历数组,统一用i < arr.length,不要用i != arr.length,更不要用i <= arr.length - 1。后者在极端情况下容易出错(比如length为0,i <= -1的语义容易被绕晕),前两种就没这个问题。
第二,数据量稍微大一点就考虑"是否需要拷贝"。数组拷贝在Java里是显式的,不是隐式的。用Arrays.copyOf从来不是免费的,每拷贝一次就多一次O(n)的内存分配与复制。如果只是临时读取,推荐用System.arraycopy做局部拷贝,别动不动就全量copy。如果方法返回的是内部数组,想防止外部修改,可以用clone()或copyOf,但这也意味着每次调用都有额外开销。这个取舍只有实际跑过才能体会。
第三,遇到"数组不够用"时,先想想是不是该换数据结构。数组强在随机访问、连续内存、省空间;弱在插入删除耗时、定长。如果业务模型是"频繁增删、偶尔查询",就别死磕数组,换ArrayList、LinkedList甚至HashMap,各自有各自的取舍。技术选型不是"哪个好",而是"哪个匹配场景"。数组不会过时,但也不是所有数据存储问题的答案。
数组这块内容,我自己从入门到熟练也花了不少时间,很多细节都是在真实项目里踩过坑才记住的。希望这篇文章能帮你把数组的定义和使用这条线串起来,后面刷题、做项目、面试,你会慢慢发现数组就像积木,几乎所有的数据结构和算法最终都在用它。
