
带过几期零基础转行学Java的学员后我发现一个挺普遍的现象讲到数组这部分几乎所有人听完语法都觉得这有什么难的可真到写代码的时候各种问题就冒出来了——数组越界、遍历结果不对、想在方法里改数组结果没改到、排序只会抄模板。这篇笔记是Java基础系列的第九篇我打算用两个ArrayTest的测试例子把数组的初始化、遍历、最值统计、越界异常、冒泡排序、工具类排序这几个点串起来完整过一遍。如果你正在自学Java或者准备面试前想快速巩固一遍数组基础这两个例子足够你动手练一遍也能帮你把底层的坑一次性踩明白。1. 例一从一次数组越界崩溃开始的数组基础操作实测1.1 为什么第一个例子一定要从初始化讲起很多人学数组时只记了两种写法但从来没想过两种写法背后的差异。// 写法一动态初始化先声明长度稍后再赋值 int[] scores new int[5]; // 写法二静态初始化声明的同时直接赋值 int[] scores2 {88, 95, 67, 74, 82};动态初始化出来的数组每个位置会拿到对应类型的默认值int类型默认为0double类型默认为0.0boolean类型默认为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[j1]会把arr[j]原来的值覆盖掉后面的arr[j1]就再也拿不到原值了。这就好比你左手拿一个杯子右手拿另一个杯子不倒进一个空杯子里是没办法交换两个杯子的。这个临时变量交换法在今后的开发里会反复出现比如集合元素的交换、数组的反转都是同一个套路。这里我再提一个优化点面试中偶尔会被问到如果某一轮排序过程中完全没有发生任何交换说明数组已经有序了后续的轮次全是浪费。可以加一个标志位提前退出。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)去打印数组。新手十个人里至少有八个会这么写结果控制台输出一串[I1b6d3586这种看起来像乱码的东西。这是数组对象在堆内存中的引用地址不是数组内容。想打印内容必须借助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实现对比一下两种写法的差异接下来学集合时你会觉得特别顺手。