
刚带新人的时候我特别喜欢用“数组去重”当第一道考题。Java数组语法上看起来就那么几行但真正动起手来初始化、越界、引用传递、二维数组这几座大山几乎能把新人全埋进去。后来带项目、做面试复盘发现大家卡住的地方高度一致都在一些“看起来简单、细想才明白”的点上。这篇就把Java数组从声明到内存、从遍历到算法场景完整捋一遍把我这些年踩过的坑和积累的经验一次性讲清楚适合刚学Java的初学者也适合准备面试的人查漏补缺。1. 为什么Java基础再基础数组也得单独捋一遍很多初学者会问数组不就是连续的内存空间吗有什么好学的这话说对了一半。数组确实是连续内存但Java里的数组和C/C里的数组有着本质差异。C语言的数组名就是指针没有边界检查越界了可能静默改掉别的内存。Java数组不一样它是对象有length属性有默认值越界会直接抛ArrayIndexOutOfBoundsException。这一层设计上的差异决定了你会遇到的问题类型完全不同。数组是Java容器体系里最基础的组件。ArrayList底层就是Object数组HashMap的哈希桶也是Node数组字符串操作也绕不开字符数组。可以说数组没搞明白后面学集合框架全是空中楼阁。我自己判断一个人Java基础扎不扎实不看他会背多少API而是问三个问题int[] a {1,2,3}; int[] b a; b[0] 99; a[0]是多少为什么二维数组一定是矩阵吗为什么方法里传一个数组进去修改数组元素外面的数组变不变这三个问题能答对说明数组这块是真的通了。这篇文章我不会跳过任何基础概念但也不会只停留在语法层面。我会把数组的内存模型、赋值机制、拷贝方式、工具类用法还有算法场景下的典型应用全部串起来。后面每部分都会配有可运行的代码示例和踩坑提醒你可以边看边在IDE里动手验证。2. 数组声明、创建和初始化三个时间点别搞混2.1 声明只是“定义了一个引用”刚学Java的人经常把声明和创建混为一谈。看这句话int[] nums;这里到底发生了什么只是在栈上定义了一个引用变量nums它的值是null还没有任何数组对象存在。这时候你去访问nums[0]会直接抛出NullPointerException因为数组根本不存在。很多人不理解为什么Java要这样设计。其实很简单声明只是告诉编译器“我这个变量将来会指向一个int类型的数组”。至于数组本身有没有创建出来那是另一件事。这就好比你订了个车位但车还没开过去车位当然是空的。2.2 new才是真正分配内存创建数组靠的是new关键字int[] nums new int[5];这一步执行后JVM会在堆内存中分配一段连续空间可以容纳5个int类型的元素然后把这段空间的地址赋值给nums这个引用变量。nums.length此时是5每个元素的默认值是0。这里要纠正一个常见的错误认知很多人以为new int[5]就是创建了5个int类型的变量。实际上Java数组创建时元素会按照类型自动赋予默认值。整型是0浮点型是0.0布尔型是falsechar是\u0000引用类型是null。这个机制在某些场景下非常有用。比如你需要一个初始值全为0的计数数组直接new int[n]就完事不需要再循环赋值。但如果默认值不是你想要的就得显式初始化。比如我想创建一个全部为-1的数组int[] scores new int[5]; for (int i 0; i scores.length; i) { scores[i] -1; }或者用Arrays.fill一行搞定java.util.Arrays.fill(scores, -1);2.3 三种初始化方式的适用场景Java数组有三种初始化方式它们的本质区别在于“什么时候知道元素的值”// 方式一静态初始化声明时直接给值 int[] arr1 {1, 2, 3, 4, 5}; // 方式二动态初始化先指定长度再逐个赋值 int[] arr2 new int[5]; arr2[0] 1; // 方式三先声明引用再用new创建适合在方法之间传递 int[] arr3; arr3 new int[]{1, 2, 3};方式一要求你在声明的同时就给值适合数据固定的场景比如定义一组常量。方式二适合长度明确、但元素值需要后续计算的场景。方式三最灵活适合分支判断后再决定数组内容的场景。注意方式三的语法new int[]{1, 2, 3}不能省略new的部分这和方式一不同。2.4 默认值机制对算法题的影响数组默认值是0这件事在刷算法题时非常常见。举个例子统计字符出现次数int[] count new int[26]; for (char c : str.toCharArray()) { count[c - a]; }这段代码能成立完全依赖数组默认值为0的机制。你不需要先手动把count数组清零JVM已经帮你做好了。如果用C语言这一步反而要自己处理。这也是我特别喜欢在LeetCode上用Java写题的原因之一。不过默认值也有坑。如果你创建的是引用类型数组比如String[] strs new String[3];每个元素默认是null。这时候直接调用strs[0].length()立刻NullPointerException。所以引用类型数组创建之后第一件事通常就是循环初始化每个元素。3. 从内存模型看数组为什么说数组是对象为什么下标从0开始3.1 栈上的引用堆里的数据要理解Java数组的赋值机制关键在内存模型。看这段代码int[] a {1, 2, 3}; int[] b a;a和b指向同一个数组对象因为引用赋值拷贝的是地址不是数组内容。此时修改b[0] 99a[0]也会变成99。这是Java数组新手最容易犯的错误之一。很多人不理解我用拷贝数组为什么原数组变了原因就是数组是对象变量名只是引用。在Java里只有通过new创建的对象才会在堆内存中分配空间而普通变量基本类型直接存值。数组变量存的是“堆内存中对象的地址”不是数组本身。想复制一份独立数组必须用Arrays.copyOf、System.arraycopy或clone()后面我会详细讲。3.2 下标从0开始的偏移量原理这个问题面试官偶尔会问为什么数组下标从0开始不从1开始核心原因是内存寻址的偏移量计算。假设数组首地址是start每个元素占size字节那第index个元素的地址就是start index * size如果下标从0开始第0个元素的地址就是start 0第1个元素就是start size计算简洁不需要额外的偏移操作。如果从1开始每次访问都要多一步减法start (index - 1) * size白白浪费一次运算。在底层频繁执行的场景里这点性能差距会被放大。当然从0开始也有历史原因C语言当年就是这么设计的Java继承了这套约定。所以数组越界异常和循环习惯都得适应这个从0开始的世界。3.3 方法传参传的是引用不是值再看一个高频面试点。方法参数是数组时传入的是什么public static void changeFirst(int[] arr) { arr[0] 100; } public static void main(String[] args) { int[] nums {1, 2, 3}; changeFirst(nums); System.out.println(nums[0]); // 输出100 }Java是值传递但这里传递的是“引用的值”。也就是说arr和nums指向同一个数组对象。通过arr修改数组内容nums自然能看到变化。但有一种情况例外如果在方法内对arr重新赋值比如arr new int[5];外面的nums不会受影响因为它指向的还是原对象。这个区别特别容易在写链表、树的递归代码时踩雷搞不清楚就会莫名其妙丢数据。3.4 数组越界检查机制到底帮你挡住了什么ArrayIndexOutOfBoundsException大家肯定都见过。Java在访问数组元素时会自动进行边界检查下标小于0或大于等于length都会抛异常。这是JVM层面做的安全检查代价是每次访问数组元素都有一点点性能损耗。这个机制有利有弊。好的一面是你不会被静默的内存错误坑到程序会立刻告诉你“越界了”。坏的一面是如果你在循环里频繁写arr[i]每次都会检查一次范围。对于性能敏感的场景可以考虑把数组长度存到局部变量或者用迭代器但大多数情况下这个损耗可以忽略。实际写代码时我最常遇到越界的地方是for循环的边界条件写错比如该写成了或者二维数组的行列搞反。解决办法也很简单所有涉及下标的循环先明确start和end是什么再动手写。4. 数组高频操作实战遍历、拷贝、排序、查找4.1 遍历for循环和增强for怎么选遍历数组是最基础的操作但选择哪种写法有讲究。// 方式一普通for需要下标 for (int i 0; i arr.length; i) { System.out.println(arr[i]); } // 方式二增强for只读场景 for (int num : arr) { System.out.println(num); }增强for写起来简洁但它拿到的只是元素的副本。如果你尝试在增强for里修改元素值for (int num : arr) { num 100; // 无效不会改变arr里的值 }此时num只是局部变量修改它不会影响数组本身。如果想修改数组元素必须用普通for加下标for (int i 0; i arr.length; i) { arr[i] 100; }所以选择策略很简单只读用增强for需要下标或修改用普通for。遍历二维数组时同理外层和内层都可用增强for只要你不修改元素。4.2 拷贝数组别再用循环挨个赋值了新手最爱用循环拷贝数组老手直接用现成API。三种主流方式对比一下// 方式一System.arraycopy最底层效率最高 int[] src {1, 2, 3, 4, 5}; int[] dest new int[5]; System.arraycopy(src, 0, dest, 0, src.length); // 参数依次是源数组、源起始位置、目标数组、目标起始位置、拷贝长度 // 方式二Arrays.copyOf适合扩容场景 int[] expanded Arrays.copyOf(src, 10); // 新数组长度是10多出来的位置自动补默认值0 // 方式三clone方法 int[] cloned src.clone();System.arraycopy是native方法直接操作内存性能最好而且支持指定区间比如只拷贝src[2]到src[4]到目标数组特定位置。Arrays.copyOf内部其实调用的也是System.arraycopy但它可以顺便扩容。clone()最简洁但对于二维数组它只是浅拷贝。意思是你得到一个“外层数组的新对象”但里面的每一行仍然指向原来的行数组。修改克隆出来的matrix[0][0]原数组也会变。这个坑在开发多维数组时非常常见。4.3 排序基本类型和对象类型的写法差异排序是数组最常用的操作之一。基本类型数组直接用Arrays.sortint[] nums {3, 1, 4, 1, 5, 9}; Arrays.sort(nums); // 默认升序如果想降序基本类型没有现成的降序排序得先转成包装类型或者用Arrays.sort指定区间后手动翻转Integer[] arr {3, 1, 4, 1, 5}; Arrays.sort(arr, Collections.reverseOrder());对象数组排序需要传Comparator。比如按字符串长度排序String[] words {apple, pear, banana}; Arrays.sort(words, (a, b) - a.length() - b.length());这里要注意Lambda表达式的陷阱a.length() - b.length()可能溢出最好用Integer.compare(a.length(), b.length())对于字符串直接用Comparator.comparingInt(String::length)更安全。还有一个场景我经常用对数组的部分区间排序。比如只对下标2到5之间的元素排序Arrays.sort(arr, 2, 6);注意区间是左闭右开2包含6不包含。这个API在高频算法问题里很实用。4.4 查找binarySearch前记得排序用Arrays.binarySearch做二分查找之前数组必须是升序排列的否则结果不可预测int[] nums {1, 3, 5, 7, 9}; int idx Arrays.binarySearch(nums, 5); // 返回2如果找不到返回的是-(插入点) - 1这个负值也有含义。比如查找4插入点应该是3下标所以返回-4。利用这个规律可以找出元素应该插入的位置。这个技巧在处理区间查询时很实用。另外Arrays.binarySearch只能用于基本类型和包装类型数组。自定义对象数组要传Comparator比如按某个字段排序后查找人才对象。5. 二维数组和锯齿数组二维数组其实是“数组的数组”5.1 三种定义方式看清区别很多初学者把二维数组想成“矩阵”然后被不规则数组搞懵。正确的理解是Java的二维数组本质上是一个“数组的数组”——外层数组的每个元素又是一个一维数组。// 方式一标准的矩形二维数组 int[][] matrix new int[3][4]; // 方式二只声明行数列数后续决定 int[][] arr new int[3][]; arr[0] new int[2]; arr[1] new int[5]; arr[2] new int[3]; // 方式三静态初始化 int[][] grid { {1, 2, 3}, {4, 5}, {6} };方式二和方式三创建出的就是锯齿数组每一行的长度可以不同。这在表示稀疏数据时非常有用比如杨辉三角、图邻接表。5.2 用二维数组表示实际数据实际开发中常遇到二维数组的场景包括矩阵运算、棋盘游戏、图像像素处理、动态规划表。比如动态规划里dp[ i ][j]经常用二维数组表示状态。如果你确定每行长度固定用矩形二维数组如果每行长度不同比如存储每个用户的标签列表用锯齿数组更节省空间。给个简单的例子打印杨辉三角。int n 5; int[][] yanghui new int[n][]; for (int i 0; i n; 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]; } }这个代码用到了锯齿数组每一行长度递增非常直观。5.3 二维数组的遍历与拷贝注意点遍历二维数组的常规写法for (int i 0; i matrix.length; i) { for (int j 0; j matrix[i].length; j) { // 访问 matrix[i][j] } }注意matrix.length是行数matrix[i].length是第i行的列数。如果某行是null直接访问matrix[i].length会NPE。所以创建锯齿数组后要确保每一行都初始化过。拷贝二维数组时clone()是浅拷贝前面说过会共享内部行数组。想要深拷贝需要逐行拷贝int[][] copy new int[matrix.length][]; for (int i 0; i matrix.length; i) { copy[i] matrix[i].clone(); }这一步在算法题里太常用了。尤其是做DFS、BFS时经常需要保存棋盘的状态快照如果不做深拷贝回溯时状态会互相污染。6. Arrays工具类与集合互转好用但坑也不少6.1 Arrays.asList返回的是什么Arrays.asList是数组转List最常用的工具但它返回的List和我们平时用的ArrayList不一样。看代码String[] arr {a, b, c}; ListString list Arrays.asList(arr); list.add(d); // 运行时报UnsupportedOperationExceptionArrays.asList返回的是一个固定长度的List底层仍然引用原数组。不能add也不能remove因为根本没有实现修改结构的方法。而且对list.set(0, x)的修改会直接反映到原数组上。这个坑我见过无数次很多人拿asList的返回值直接当成可变集合用运行时就炸了。想得到一个真正的ArrayList应该这样ListString list new ArrayList(Arrays.asList(arr));这样会复制出一份独立的、可增删的列表。注意如果数组是引用类型Arrays.asList只是把数组元素逐个放进列表修改列表元素会影响原数组。要完全独立还得先拷贝数组再转。6.2 基本类型数组转List的先天缺陷Arrays.asList有个更隐蔽的坑如果传入的是基本类型数组比如int[]返回的List里只有“一个元素”——这个数组本身而不是每个int值。int[] nums {1, 2, 3}; Listint[] list Arrays.asList(nums); System.out.println(list.size()); // 输出1原因很简单泛型不支持基本类型asList把整个int[]当成一个Object放进List。想转为正确元素数量的List要么先装箱成Integer[]要么用循环Integer[] boxed Arrays.stream(nums).boxed().toArray(Integer[]::new); ListInteger list new ArrayList(Arrays.asList(boxed));在Java 8及以上更简洁的写法是ListInteger list Arrays.stream(nums).boxed().collect(Collectors.toList());用Stream处理后list大小正确元素也可操作。6.3 数组转字符串调试利器Arrays.toString调试数组内容时新手经常System.out.println(arr)结果输出是一串看不懂的[I1b6d3586。这是因为数组的toString没有重写打印的是对象地址。正确做法是int[] nums {1, 2, 3}; System.out.println(Arrays.toString(nums)); // [1, 2, 3]二维数组要用Arrays.deepToStringint[][] matrix {{1, 2}, {3, 4}}; System.out.println(Arrays.deepToString(matrix)); // [[1, 2], [3, 4]]这个习惯对排查问题帮助巨大。每次不确定数组内容是什么先toString打印一下比空想快得多。6.4 fill、equals、copyOfRange日常高频API除了toString我日常高频使用的Arrays方法还有三个// 填充 int[] arr new int[5]; Arrays.fill(arr, 8); // [8, 8, 8, 8, 8] // 比较内容而不是比较引用 int[] a {1, 2, 3}; int[] b {1, 2, 3}; System.out.println(a.equals(b)); // false比较的是引用 System.out.println(Arrays.equals(a, b)); // true比较的是内容 // 复制区间 int[] part Arrays.copyOfRange(a, 1, 3); // [2, 3]左闭右开equals这个坑尤其值得注意。用a.equals(b)比较数组内容永远不会返回true因为数组没有重写Object的equals。二维数组内容比较用Arrays.deepEquals。掌握了这些API很多基础操作就不需要自己写循环了。7. 数组在算法场景里的三个高频用法组合求和、前缀和、双指针7.1 已知固定值如何找出数组里哪些数据和等于它这个问题的典型描述是给定一个数组和一个目标值target找出数组里所有能组合成target的数据组合。这是经典的回溯算法题。比如数组{2, 3, 6, 7}target是7可能的组合有[7]和[2, 2, 3]。可以用DFS回溯实现public ListListInteger combinationSum(int[] candidates, int target) { ListListInteger res new ArrayList(); dfs(candidates, target, 0, new ArrayList(), res); return res; } private void dfs(int[] candidates, int remain, int start, ListInteger path, ListListInteger res) { if (remain 0) { return; } if (remain 0) { res.add(new ArrayList(path)); return; } for (int i start; i candidates.length; i) { path.add(candidates[i]); dfs(candidates, remain - candidates[i], i, path, res); path.remove(path.size() - 1); } }这里的关键点有三个一是递归函数要维护“当前剩余值remain”二是每次递归从start开始避免重复组合三是回溯时用path.remove(path.size() - 1)撤销当前选择。这类题目考察的正是对数组元素遍历和状态回溯的理解。如果数组里有重复元素还要先排序并在循环中跳过相同的元素否则结果里会有重复组合。这些细节在面试时都是加分项。7.2 前缀和与树状数组快速求区间和前缀和是最常用的数组预处理技巧。给定数组nums前缀和数组prefix的定义是prefix[i] nums[0] nums[1] ... nums[i-1];这样区间[l, r]的和就能用sum(l, r) prefix[r1] - prefix[l];预处理需要O(n)之后每次区间查询O(1)。这在处理大量区间求和问题时非常有用比如LeetCode的“区域和检索”。如果数据会频繁修改就需要树状数组Binary Indexed Tree或线段树。树状数组的核心只有两个操作单点修改和前缀和查询。网上流传的模板我简单整理一下class FenwickTree { private int[] tree; public FenwickTree(int n) { tree new int[n 1]; } private int lowbit(int x) { return x (-x); } // 单点修改给下标i的元素加上delta public void add(int i, int delta) { while (i tree.length) { tree[i] delta; i lowbit(i); } } // 前缀和查询求下标1到i的和 public int sum(int i) { int s 0; while (i 0) { s tree[i]; i - lowbit(i); } return s; } }模板本身不难背难的是理解lowbit为什么能高效定位需要更新的索引。lowbit(x)取的是x二进制表示里最低位的1所对应的值比如lowbit(6) 2。修改一个点只需要更新树状数组里包含它的某些节点复杂度从O(n)降到O(logn)。这类结构刷算法题时会遇到实际业务中如果只是静态数据用前缀和就够了动态修改频率高才上树状数组。7.3 双指针与滑动窗口把O(n^2)降成O(n)数组的有序性配合双指针往往能大幅优化时间复杂度。最典型的例子是“有序数组两数之和”。给定升序数组和一个目标值找出两个数使得它们的和等于目标值。暴力解法是双重循环O(n^2)双指针只需要O(n)int[] nums {2, 7, 11, 15}; int target 9; int left 0, right nums.length - 1; while (left right) { int sum nums[left] nums[right]; if (sum target) { // 找到了 break; } else if (sum target) { left; } else { right--; } }核心逻辑是数组有序时左指针向右移动会使和变大右指针向左移动会使和变小。通过不断收缩区间逼近目标值每次循环排除一个元素整体复杂度O(n)。滑动窗口本质是双指针的一种应用。求“数组中和不小于K的最短子数组长度”用两个指针维护一个窗口右指针扩张左指针收缩始终保证窗口内的和满足条件。这种写法比暴力枚举所有子数组快得多代码量也不大。数组相关的算法题里双指针和滑动窗口出现频率极高强烈建议多练。8. 面试高频数组题复盘与实用建议面试时数组相关的问题翻来覆去就那几类我把遇到最多的问题和解决要点整理成一个表方便复习。问题核心考点答题关键数组和ArrayList的区别底层结构与动态扩容数组定长、ArrayList自动扩容数组性能更高List功能更全数组越界会throws什么异常机制ArrayIndexOutOfBoundsException数组的length是方法还是属性基本认知数组length是属性String的length()是方法数组可以作为方法返回值吗引用类型可以返回的是数组对象的引用数组默认值是什么初始化机制基本类型按类型默认值引用类型为null如何高效去重集合与哈希用HashSet或Stream distinct二维数组的clone是深拷贝吗内存模型不是需要逐行clone除了这些理论题手写代码时最容易暴露出来的几个问题我也一块儿说一下。第一个是数组去重的实现。最简单的方式int[] nums {1, 2, 2, 3, 3, 4}; int[] distinct Arrays.stream(nums).distinct().toArray();如果你不想用Stream可以用LinkedHashSet保序去重LinkedHashSetInteger set new LinkedHashSet(); for (int num : nums) { set.add(num); }面试时如果能说出Stream版本的优缺点会显得基础很扎实。第二个是“原地去重”问题即不借助新数组返回去重后的长度。这个要用快慢指针LeetCode第26题就是典型。public int removeDuplicates(int[] nums) { if (nums.length 0) return 0; int slow 0; for (int fast 1; fast nums.length; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }这里slow指向最后一个不重复元素的位置fast负责探路。遇到新元素就前移一位并放入。这类题目考的其实就是对下标操控的熟练度。第三个常见问题是数组扩容模拟。ArrayList底层是数组当容量不够时会创建新数组并拷贝旧数据。自己实现一个简化版int[] oldArr new int[4]; int[] newArr Arrays.copyOf(oldArr, oldArr.length * 2);理解了数组是定长的、扩容必须新开数组ArrayList的底层原理也就懂了。最后我想说数组这块内容看起来很基础但它是整个Java学习路径的承重墙。墙没砌好后面集合框架、泛型、算法题都会松松垮垮。我建议你学完这篇之后亲手把每个示例都敲一遍尤其是拷贝和二维数组的部分光看永远不如自己踩一次错记得牢。等到你能不需要思考就写出Arrays.copyOf、System.arraycopy、Arrays.fill这些操作数组这块就算真正过关了。