☰
Java数组基础实战:初始化、遍历、冒泡排序与越界异常全解析
2026/10/5 4:14:19 网站建设 项目流程

带过几期零基础转行学Java的学员后,我发现一个挺普遍的现象:讲到数组这部分,几乎所有人听完语法都觉得"这有什么难的",可真到写代码的时候,各种问题就冒出来了——数组越界、遍历结果不对、想在方法里改数组结果没改到、排序只会抄模板。这篇笔记是Java基础系列的第九篇,我打算用两个ArrayTest的测试例子,把数组的初始化、遍历、最值统计、越界异常、冒泡排序、工具类排序这几个点串起来完整过一遍。如果你正在自学Java,或者准备面试前想快速巩固一遍数组基础,这两个例子足够你动手练一遍,也能帮你把底层的坑一次性踩明白。

1. 例一:从一次数组越界崩溃开始的数组基础操作实测

1.1 为什么第一个例子一定要从初始化讲起

很多人学数组时只记了两种写法,但从来没想过两种写法背后的差异。

// 写法一:动态初始化,先声明长度,稍后再赋值 int[] scores = new int[5]; // 写法二:静态初始化,声明的同时直接赋值 int[] scores2 = {88, 95, 67, 74, 82};

动态初始化出来的数组,每个位置会拿到对应类型的默认值:int类型默认为0,double类型默认为0.0,boolean类型默认为false,引用类型默认为null。这个默认值机制很容易被忽略,但它很关键——如果你动态初始化了一个数组却忘了给某些位置赋值,程序不会报错,你拿到的就是一个0或者null,后面的业务逻辑很可能因为这个"安静的空值"而算错结果,而且这种错误特别难定位,因为编译器什么也没告诉你。

静态初始化的好处是直观,你一眼就能看出这个数组到底装了哪些数据。在写ArrayTest这种测试类时,我一般优先用静态初始化,因为我们的目的就是验证各种操作,数据固定下来才能安心观察输出。但实际开发中,动态初始化的使用频率反而更高,因为业务数据往往是程序运行过程中才慢慢填充进去的,而不是写死在代码里的。所以这两种初始化方式都要熟练,不要厚此薄彼。

1.2 遍历、求最值、统计的完整代码

第一个例子的目标很简单也很实用:有一个整型数组,我要遍历打印所有元素,同时求出最大值、最小值、总和和平均值。

public class ArrayTest01 { public static void main(String[] args) { int[] data = {12, 5, 28, 9, 17, 31, 4}; // 求最大值 int max = data[0]; for (int i = 1; i < data.length; i++) { if (data[i] > max) { max = data[i]; } } System.out.println("最大值 = " + max); // 求最小值 int min = data[0]; for (int i = 1; i < data.length; i++) { if (data[i] < min) { min = data[i]; } } System.out.println("最小值 = " + min); // 求总和与平均值 int sum = 0; for (int i = 0; i < data.length; i++) { sum += data[i]; } double avg = (double) sum / data.length; System.out.println("总和 = " + sum + ",平均值 = " + avg); } }

这段代码里有几个细节值得新手停下来想一想。第一,求最大值时,初始值为什么要拿data[0]而不是0?因为如果数组里全是负数,比如{-5, -8, -3},用0做初始值求出来的最大值就是0,而这个0根本不在数组里,结果就是错得离谱。第二,循环为什么从1开始?因为data[0]已经作为初始值了,拿它跟自己做一次比较没有任何意义,从1开始能省一次循环。第三,平均值那里为什么把sum强转成double再除?因为整数除以整数在Java里结果还是整数,7和31求平均值,直接除会得到4而不是4.428,这个精度损失在真实业务里经常引发Bug,一定要提前转类型。

1.3 越界异常:踩坑现场与解决思路

写完上面这些基础操作,我通常会让学员故意写一行越界代码,比如:

System.out.println(data[data.length]);

运行之后你会看到经典的异常信息:

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 7 out of bounds for length 7 at ArrayTest01.main(ArrayTest01.java:25)

这个问题看起来很简单,但每年面试我都会拿它问人:数组下标最大能到多少?答案是length - 1。可为什么非要写成length - 1而不是length?因为这个下标是从0开始数的。一个长度为7的数组,位置编号是0、1、2、3、4、5、6,第7个位置已经是非法地址了。很多第一次接触数组的人总是忘记这一点,把循环条件写成i <= length,一跑就崩溃。

越界的本质是访问了数组管理范围之外的内存地址。Java设计了一个安全机制:JVM会在运行时检查你的下标是否合法,一旦越界就抛异常,这比C语言那种直接操作野指针的写法安全得多。遇到这个异常时,我的排查习惯是先看异常信息里的Index和out of bounds,比如Index 5 out of bounds for length 5,这说明你试图访问下标5,但数组长度只有5,合法下标是0到4。然后再看堆栈信息里的行号,直接定位到出错的那一行。

这个例子整体跑下来,你会发现数组最基本的操作其实都是围绕"定位元素然后处理"这一个思路展开的。把这个思路稳住,后面学集合、学泛型,都会轻松很多。

2. 例二:手写冒泡排序与Arrays.sort的对照实验

2.1 为什么第二个例子选排序而不是别的

热搜词里"java排序"和"冒泡排序java"出现的频率非常高,这说明排序确实是Java入门绕不过去的坎。可市面上讲排序的教程太多了,我为什么还要在自己的笔记里专门写一遍?因为排序是少有的能把数组的"下标操作"和"循环嵌套"同时练扎实的场景。

不少人觉得,反正Java有Arrays.sort()方法可以一键排序,为什么还要手写冒泡排序?我说一个我自己的经历:有一年到一家公司做技术支持,他们有一段查询逻辑在特定数据量下特别慢,我排查后发现问题不在数据库,而在内存里一个List排错了序,导致后续查找走了错误的分支。当时如果要理解那段代码为什么排错序,靠的就是排序算法的底层原理。工具方法能帮你省时间,但理解不了工具方法背后的逻辑,你连工具出错时怎么排查都不知道。

另外从面试角度讲,手写排序算法几乎是大厂笔试的保留项目。你不会手写冒泡排序,大概率第一轮就挂了。所以这个例子的价值不只是练数组,更是为后面学更复杂的排序算法打底。

2.2 手写冒泡排序的实现与逐行拆解

冒泡排序的思路可以这样理解:每一轮都把相邻的两个元素做一次比较,如果左边的比右边的大,就把它们交换位置。一轮结束后,最大的元素会像冒泡一样浮到数组末尾。下一轮再在剩下的元素里重复同样的操作。

public class ArrayTest02 { public static void main(String[] args) { int[] arr = {64, 34, 25, 12, 22, 11, 90}; System.out.println("排序前:" + java.util.Arrays.toString(arr)); // 冒泡排序,外层循环控制轮数 for (int i = 0; i < arr.length - 1; i++) { // 内层循环控制每轮比较到哪个位置 for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { // 交换两个元素 int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } System.out.println("排序后:" + java.util.Arrays.toString(arr)); } }

这段代码我建议每一个学过循环的人都要逐行读明白。外层循环为什么是i < length - 1?因为7个元素最多需要6轮排序,最后一轮开始前,前面6个位置已经归位,最后一个位置自然就是最小的,不需要再排。内层循环为什么是j < length - 1 - i?因为每完成一轮,末尾就多一个已经归位的元素,这些元素不需要再参与比较,所以每一轮的可比较范围内层要少一个。

交换元素为什么要用临时变量temp?因为你直接写arr[j] = arr[j+1]会把arr[j]原来的值覆盖掉,后面的arr[j+1]就再也拿不到原值了。这就好比你左手拿一个杯子右手拿另一个杯子,不倒进一个空杯子里,是没办法交换两个杯子的。这个"临时变量交换法"在今后的开发里会反复出现,比如集合元素的交换、数组的反转,都是同一个套路。

这里我再提一个优化点,面试中偶尔会被问到:如果某一轮排序过程中完全没有发生任何交换,说明数组已经有序了,后续的轮次全是浪费。可以加一个标志位提前退出。

boolean swapped; for (int i = 0; i < arr.length - 1; i++) { swapped = false; for (int j = 0; j < arr.length - 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; } }

加了标志位之后,最理想的情况——数组本身已经有序,时间复杂度会从O(n²)降到O(n),只需要扫描一遍就能结束。虽然冒泡排序整体性能一般,但这个优化思想很重要:任何算法都要先想清楚"有没有不必要的计算"。

2.3 Arrays工具类的正确打开方式

手写一遍之后,我们再看工具类的写法。java.util.Arrays里封装了大量数组操作,我用得最频繁的几个列一个表:

方法作用注意事项
Arrays.sort(int[] a)对数组升序排序底层是快速排序,性能远高于手写冒泡
Arrays.toString(int[] a)把数组转成可读字符串直接打印数组只会有内存地址,必须先转
Arrays.copyOf(int[] original, int newLength)按指定长度复制数组新长度大于原长度时,多余位置补默认值
Arrays.equals(int[] a1, int[] a2)比较两个数组内容是否相等用==比较数组比的是引用,不是内容
Arrays.fill(int[] a, int value)把数组中所有元素设为指定值常用于初始化测试数据

工具类就用一行代码解决排序问题:

java.util.Arrays.sort(arr); System.out.println(java.util.Arrays.toString(arr));

你可能注意到我这里写了java.util.Arrays而不是直接写Arrays。这是因为在我的ArrayTest02类里没有写import java.util.Arrays;,全限定名也可以调用。实际开发中,我更推荐写import声明,代码更简洁。但用全限定名写一遍也有好处,能让你对这个类的完整包路径留下记忆,后面学包机制时会有亲切感。

这里必须提醒一个经典坑:不要直接用System.out.println(arr)去打印数组。新手十个人里至少有八个会这么写,结果控制台输出一串[I@1b6d3586这种看起来像乱码的东西。这是数组对象在堆内存中的引用地址,不是数组内容。想打印内容,必须借助Arrays.toString()。这个坑我自己的项目里踩过一次,当时排查了半天以为数组被改坏了,结果只是打印方式错了。

3. 两个例子背后:数组的内存模型与那些必须掰开揉碎理解的细节

3.1 数组是引用类型,这个认知决定你写代码的习惯

写完两个例子的代码之后,不要急着进入下一个知识点。如果你只停留在"能跑就行"的层面,数组在你眼里就只是"一堆数字的容器",那你迟早会在传参和拷贝问题上栽跟头。数组是Java中的引用类型,这个认知直接影响你的代码行为。

内存模型大致是这样的:你声明int[] data = {12, 5, 28}时,JVM会在栈内存中创建变量data,栈里的data保存的不是12、5、28这三个数字本身,而是堆内存中一块连续空间的起始地址。真正的数组元素对象存放在堆内存中,那块空间被划分为连续的三个格子,每个格子存一个int值。data就像一张写着"入口在什么地方"的纸条,你要操作元素,得先通过这张纸条找到那栋房子。

理解这个模型之后,一个常见的困惑就迎刃而解了:为什么int基本类型的变量作为方法参数传进去,方法里改了值,外面没有变化?而数组作为方法参数传进去,方法里改了元素,外面也跟着变了?原因就在引用两个字上。基本类型传递时,方法接收的是值的一个副本,怎么改都改不到原变量;数组传递时,方法接收的是引用地址的副本,地址指向同一个堆内存空间,方法顺着地址找到的还是原来的数组,改的当然是原来的元素。

这也是我在第一个例子里强调初始值、第二个例子里强调数组传参的根本原因——所有有趣的坑,全都埋在引用和地址这两件事上。

3.2 length是属性、for-each的原型、下标从0开始的现实感

数组的length是很多新手容易混淆的地方。字符串有length(),这是个方法,后面必须跟括号;数组的length是个属性,后面不能跟括号。为什么会有这个差别?因为字符串本质上是类类型,获知长度要靠调用方法;数组是Java语言层面的原生结构,长度是它自带的一个公开字段,直接访问就行。

访问数组元素用的下标从0开始,很多人觉得这是约定俗成,其实这跟内存偏移量有关。数组首元素在内存中起始地址的基础上偏移量为0,第二个元素偏移量是1个格子的大小,以此类推。用0起始意味着arr[i]可以直接通过"起始地址 + i × 单位元素大小"来定位,不用再去减1,计算效率高,设计上也更简洁。面试时如果被问到"数组下标为什么从0开始",能把这个偏移量的原因讲清楚,会加分不少。

再说for-each,也就是增强for循环。我在上面例子中写的都是普通for循环,但很多时候增强for更简洁:

for (int score : scores2) { System.out.println(score); }

它的底层实现,对数组来说其实就是一个按下标遍历的过程。写法上看不出来,但编译之后做的事跟普通for是一样的。它适合只读遍历,不适合需要知道当前下标或者修改元素的场景。如果你需要在遍历过程中改某个元素的值,用它就会很别扭,还是老老实实用普通for加下标。

3.3 数组在项目里最常见的三种使用姿势

把两个例子的细节都跑通之后,你可能会想:真实项目里数组到底怎么用?这个问得好,因为教程代码和工程代码之间是有一道坎的。

第一,数组经常作为"定长缓冲"出现。比如网络编程里一次读取固定字节数的数据,byte[] buffer = new byte[1024],这种场景天然适合数组,因为长度已知且固定。第二,数组经常被用来做"数据暂存",比如把一个集合临时转成数组进行某种计算,或者把某个查询结果先放进数组里过渡一下。第三,二维数组或多维数组用来表达表格结构,比如矩阵运算、图形像素数据、棋盘模型。这些场景的共同点是:数据规模相对稳定,位置访问频繁,性能要求不低。数组的连续内存布局在访问效率上比链表结构有天然优势,缓存命中率高。

在实际编码中我给你一个选型建议:如果数据长度固定且你频繁按下标访问,用数组;如果数据长度会动态变化,请直接考虑ArrayList。数组是语言自带的底层结构,ArrayList是JDK提供的封装结构,两者使用场景有重叠但侧重点不同。能理解这个差异,才算真正走出"只会用数组"的阶段。我遇到的不少新手喜欢在所有场合强行用数组,结果要么是长度不够报越界,要么是长度预留太多浪费内存,这都可以通过正确选型来规避。

4. ArrayTest之外:面试和工程里围绕数组的高频延伸考点

4.1 经典面试题:为什么方法里改了数组,外面也跟着变了

这块内容虽然严格说属于"传参"知识,但几乎所有跟数组有关的面试都会问到,所以我一直放在ArrayTest的延伸篇里讲。

看这段代码:

public static void change(int[] arr) { arr[0] = 999; } public static void main(String[] args) { int[] data = {1, 2, 3}; change(data); System.out.println(data[0]); // 输出999 }

很多人不理解:Java不是"值传递"吗?为什么这里改了数组外面也变了?要回答这个,必须把"值传递"理解到位。Java中方法参数传递的确实是值,但对于引用类型来说,这个"值"指的就是引用地址的值。你传到方法里的不是整个数组,而是记录着数组地址的那张纸条的复印件。复印件和原件指向的是同一个房子,你在房子里搬家俱,拿到复印件的人当然看得出来。

但如果方法里执行的是arr = new int[]{8, 8, 8};,这相当于把纸条在原方法里换了一张,指向了一个新房子,外面的data手里的纸条还是原来的地址,所以外面的值不会变化。这个区别是高频考点,我在模拟面试时经常问,能答上来的人往往能把内存模型想得比较清楚。

如果不应试,这个知识在生产环境同样实用。比如你写一个工具方法想对数组做排序,方法内部直接改了原数组,调用方如果不清楚这个行为,就会在后续代码里拿到一个被改动过的数据源,排查起来一头雾水。所以写数组工具方法时,建议在方法命名上明确体现"是否改变原数组"。比如sort就是改变原数组,createSortedCopy就是返回副本,不改变原数据。

4.2 数组拷贝和「深度拷贝」的边界

热搜词里有"java对象深度拷贝",这个话题虽然超出数组本身,但数组恰恰是理解深浅拷贝最直观的起点。数组有三种常见的拷贝方式:

方式一是System.arraycopy,这是一个本地方法,性能和灵活性都很好,支持指定原数组的起始位置、目标数组的起始位置和拷贝长度。

int[] src = {1, 2, 3, 4, 5}; int[] dest = new int[5]; System.arraycopy(src, 0, dest, 0, src.length);

方式二是Arrays.copyOf,内部其实也是调用System.arraycopy实现的,它的优点是写起来短,但只能从原数组的第0位开始复制。

方式三是clone(),一个简单的int[] copy = src.clone();就能完成拷贝。

这三种方式对于基本类型数组来说,都是"深度拷贝",因为拷贝的是实实在在的数字值,新数组和原数组互不干扰。但一旦数组里装的是对象引用,比如Student[],那这三种方式统统是浅拷贝——复制出来的数组里存的还是原对象的引用地址,你在新数组里通过引用改了某个对象的属性,原数组对应的对象也会变。这就是"数组拷贝不等于深度拷贝"的根本原因。

真正要实现对象数组的深度拷贝,必须遍历数组逐一对每个对象做拷贝,甚至要处理对象内部嵌套引用的情况,复杂度会迅速上升。我建议新手先把深浅拷贝的区别记牢,至于完整的深度拷贝方案,等学到序列化之后再看也不迟。

4.3 二维数组、main方法的args和ArrayTest的完整形态

数组延伸考点里还有一个容易被忽略的点:main方法的参数为什么是String[] args。很多人在初学阶段根本用不到这个参数,但它也是数组在Java世界里的一种体现。你在命令行执行java ArrayTest hello world时,hello和world就会以字符串数组的形式传入args,在程序里通过下标访问。大部分现代IDE里可以通过运行配置窗口填写Program Arguments来传参,这在我调试一些小工具时帮了大忙。

二维数组其实就是一个"数组的数组",比如:

int[][] matrix = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} };

访问二维数组里某个元素需要用两个下标,比如matrix[1][2]得到6,意思是先取第1行,再取第1行的第2个元素,也就是第二行第三列的值,因为行和列的下标都是从0开始的。二维数组的赋值和遍历经常要嵌套两层for循环,这跟冒泡排序的两个循环结构类似,本质上都是"外层控制行/轮数,内层控制列/次数"。

如果要把这两个ArrayTest例子收拢成一个更完整的测试类,我通常会在类里再加一个统计方法,把均值、极值、排序后的结果一次性打印出来,再放一个数组为空时的防御判断。空的数组没有元素,求最大值时会抛出ArrayIndexOutOfBoundsException或者拿到空结果,所以方法一进来就要先判断if (arr == null || arr.length == 0)。这种防御式编程的习惯越早养成越好,因为真实业务里的数据永远不可能像教程里那样"听话"。

讲到这儿,两个例子的完整脉络就梳理完了。我在每一轮带学生做完这套练习后都会叮嘱一句:数组这章,代码写多少行不是重点,重点是你能不能用自己的话把"数组的引用传递、下标从0开始、length是属性、工具类怎么用"这四件事讲清楚。这四件事想明白了,后面学集合、学泛型、刷LeetCode的数组题目,基础都会扎实很多。你拿着这两个ArrayTest例子跑一遍,再试着把代码改成用ArrayList实现,对比一下两种写法的差异,接下来学集合时你会觉得特别顺手。

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

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

立即咨询