首页 / 资讯中心 / 文章详情

Java数组详解:从内存模型到实战面试题

Java数组详解:从内存模型到实战面试题 ★ FEATURED ARTICLE
1. 先把数组的本质讲透声明、初始化与内存模型1.1 数组是什么一块连续内存的“编号抽屉柜”数组在Java里并不是类似int、double那样的简单数据类型它本质是一个引用类型对象。当你写下int[] arr new int[3];的时候JVM会在堆上分配一块连续的内存并把这块内存的首地址交给arr保存。连续有什么好处访问任意一个元素都能通过“首地址 下标 × 元素大小”直接算出来时间复杂度是O(1)。这也是为什么数组随机访问快但插入和删除慢因为插入删除往往要移动其他元素。把数组想象成一排编号固定的抽屉柜编号从0开始总共就这么多抽屉。你想往第三个抽屉放东西直接走过去放就行。但如果中间某个抽屉要临时换成一个更大体积的柜子整排抽屉都得挪位置很麻烦。这就是“连续内存、长度固定”两个特性带来的直接代价。很多新手一开始不理解为什么数组长度不能变其实不是不能变而是一旦在内存里分配好连续区域很难原地扩展所以设计上必须固定长度。数组还有一个特性容易被忽略它知道自己的长度。不像C语言里数组传递给函数后会退化成指针长度信息就丢了。Java数组对象在创建时就把length保存下来了这一点对写代码来说很友好后面讲遍历、边界判断时都会用到。1.2 三种初始化方式与默认值数组的初始化看起来简单但细节足够让面试官抓好几次。第一种是静态初始化直接在声明时给值int[] arr1 {1, 2, 3}; int[] arr2 new int[]{1, 2, 3};注意arr1这种写法只能用在声明变量时。如果你想先声明再赋值int[] arr3; arr3 {1, 2, 3}; // 编译报错必须写成arr3 new int[]{1, 2, 3};。这是新手很容易踩的坑也是一些面试题故意设置的错误选项。第二种是动态初始化只指定长度不指定内容int[] arr4 new int[3];此时数组里的每个元素都会有一个默认值。默认值规则如下数组元素类型默认值int / short / byte0long0Lfloat0.0fdouble0.0char\u0000booleanfalse引用类型String、Object等null这个默认值很关键。int[] arr new int[10];之后你忘了给部分元素赋值读到的不是C语言那种随机数而是0程序仍然可控。但代价是String[] names new String[10];之后直接执行names[0].length()会抛空指针因为引用类型默认值是null不是空字符串。实际开发里这种“动态初始化的数组元素是null”的问题出现频率相当高。1.3 length属性为什么是字段而不是方法很多初学Java的人受了String的length()方法影响写数组长度时顺手写成arr.length()结果编译报错。这背后的原因值得搞清楚。数组不是一个普通的类它由JVM内部特殊处理。字节码层面有专门的arraylength指令JVM执行这条指令时直接读取数组对象头里保存的长度信息不需要调用任何方法。所以编译器把arr.length当作一个final字段来处理而不是方法。你去翻Java源码是找不到某个“Array类”里定义这个字段的它属于语言层面的特殊语法。String就不同了。字符串内部是一个char[] value数组value.length是数组长度但字符串对外提供的length()方法会先做判空再返回value.length可以参与更多封装逻辑。理解这一点之后写代码时就不会再纠结为什么数组用length、字符串用length()。顺带一提如果你在IDE里输入arr.length然后补全你会发现没有括号这也是IDE在提示你它是一个“字段”。1.4 下标越界Java和C语言的区别数组访问时下标从0开始到length-1结束。下标为负数或者大于等于length都会抛出ArrayIndexOutOfBoundsException也就是数组下标越界异常。这一点Java做得很严格。C/C里数组访问不会做边界检查你写arr[length]程序并不会立即报错很可能读到数组后面内存里的脏数据甚至写入时破坏了别的变量排查起来极其痛苦。Java选择在每次数组访问时做边界检查宁可牺牲一点性能也要把风险在运行时暴露出来。所以循环条件一定要写成i arr.length而不是i arr.length。不过边界检查也意味着访问数组并不完全“零成本”。在性能敏感的场景比如高频循环里反复读取arr[i]可以先把长度保存到局部变量len arr.length再循环。JVM的JIT编译器通常会自动优化但写成局部变量更符合习惯读起来也不容易出错。总之越界问题是数组相关Bug里的大头后面我会再给一张速查表。2. 数组的常用操作从遍历到排序去重2.1 遍历的三种姿势遍历大概是用得最多的数组操作。最常见的是普通for循环for (int i 0; i arr.length; i) { System.out.println(arr[i]); }这种写法能拿到下标适合需要在遍历过程中修改元素、或者根据下标做判断的场景。比如想把所有偶数位置上的元素都翻倍增强for就做不到必须用普通for配合i % 2 0判断。普通for还能很方便地倒序遍历很多从后往前处理的算法比如后面要讲的合并有序数组都依赖这个能力。第二种是增强forfor (int value : arr) { System.out.println(value); }增强for写法简洁但有个非常经典的坑循环变量value拿到的是数组元素的副本。如果你在循环里写value value * 2数组里的元素并不会改变。想改元素只能回到普通for用arr[i] value * 2。所以增强for适合只读遍历不适合修改。第三种是用Java 8的Stream。数组本身不支持直接转Stream但可以通过Arrays.stream(int[])转换成IntStreamint sum Arrays.stream(arr) .filter(x - x % 2 0) .sum();我平时写一次性数据处理时比较喜欢Stream代码很简洁。但要注意Stream会引入额外对象开销刷算法题或者写底层逻辑时还是优先用普通for。三种方式没有绝对好坏按场景选择就好。2.2 数组转字符串的四个方法数组转字符串看起来简单但不同场景有不同选择。第一种是Arrays.toString(arr)结果长这样[1, 2, 3]。这是调试打印的首选。如果你直接System.out.println(arr);只会打印数组的地址类似[I1b6d3586完全没法看。我排查问题时的第一步基本都是Arrays.toString(arr)配合日志输出。第二种是手动拼接StringBuilder适合需要自定义分隔符的场景。比如想输出1,2,3而不是[1, 2, 3]StringBuilder sb new StringBuilder(); for (int i 0; i arr.length; i) { if (i 0) sb.append(,); sb.append(arr[i]); }第三种是Stream拼接简洁但可读性稍差String result Arrays.stream(arr) .mapToObj(String::valueOf) .collect(Collectors.joining(,));第四种是二维数组用Arrays.deepToString(arr)。如果你对二维数组用Arrays.toString(arr)每个元素打印出来的是一维数组的地址看到的会是一堆[Ixxx完全不是你想要的内容。deepToString会递归打印嵌套内容适合多维数组的调试。2.3 数组排序Arrays.sort与手写冒泡Java面试里“手写冒泡排序”大概是出现频率最高的基础题。先看APIArrays.sort(arr)对基本类型数组直接升序排序内部实现是DualPivotQuicksort双轴快排平均时间复杂度O(n log n)。对Object数组比如String[]内部是TimSort这是一种稳定排序。而基本类型排序是不稳定的因为基本类型相等没有意义JVM可以随意交换。手写冒泡排序可以这样写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; } } }冒泡排序稳定但时间复杂度O(n²)。面试时经常追问“能不能优化”常见优化是加标志位如果某一轮没有发生交换说明已经有序提前结束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]) { swap(arr, j, j 1); swapped true; } } if (!swapped) break; }先学冒泡目的是理解“相邻交换”和“每轮把最大值冒到末尾”这两个核心思想后面学快排、归并会有帮助。实际开发中直接用Arrays.sort就好不要自己造排序轮子。2.4 数组去重的几种实用方案数组去重在业务代码里很常见但Java数组长度固定所以去重思路通常是把结果收集到另一个容器再转回数组。最简单的是用LinkedHashSetInteger[] arr {1, 2, 2, 3, 3, 4}; Integer[] unique new LinkedHashSet(Arrays.asList(arr)).toArray(new Integer[0]);注意这里要用Integer[]而不是int[]因为Arrays.asList接收的是引用类型数组int[]会被当成一个整体元素。如果手里是基本类型数组可以用Stream装箱int[] arr {1, 2, 2, 3, 3, 4}; int[] unique Arrays.stream(arr).distinct().toArray();第三种是原地去重要求数据有序比如[1, 1, 2, 3, 3]用双指针int slow 0; for (int fast 0; fast arr.length; fast) { if (fast 0 || arr[fast] ! arr[slow - 1]) { arr[slow] arr[fast]; } } // slow 就是新长度arr[0..slow-1] 是去重后的结果这个思路其实对应LeetCode 26题。面试中问去重不要只回答“用Set”说出来几种方案以及各自的复杂度和适用场景会显得更有经验。2.5 二维数组与不规则数组二维数组本质是“数组的数组”。比如int[][] matrix new int[3][4];可以理解成矩阵是一个长度为3的数组每个元素又是一个长度为4的int数组。在内存里matrix[0]、matrix[1]、matrix[2]各自指向独立的一维数组所以matrix.length是行数3matrix[0].length是第一行的列数4。二维数组初始化可以这样写int[][] m1 new int[3][4]; int[][] m2 {{1, 2, 3}, {4, 5, 6}};还有一种不规则数组经常被拿来考只指定行数每行单独指定长度。int[][] triangle new int[3][]; triangle[0] new int[1]; triangle[1] new int[2]; triangle[2] new int[3];这种结构适合保存杨辉三角。遍历二维数组时外层循环行内层循环列内层边界要用matrix[i].length而不是matrix[0].length因为不规则数组每一行列数可能不同。打印调试用Arrays.deepToString(matrix)最省事。3. 面向面试的数组进阶题同构判断实战3.1 题目描述与输入输出光讲基础知识点不如来一道题把知识串起来。这里分享一个非常典型的“数组同构题目”我在带新人时经常用它来检验对方对数组遍历和边界条件的掌握程度。题目描述给定两个长度均为n的整数数组A和B判断是否存在一个整数offset使得对所有下标i0 ≤ i n都满足B[i] A[i] offset。如果存在返回任意一个满足条件的offset如果不存在返回false。先看两个例子例1A [1, 2, 3]B [4, 5, 6]。因为每个位置都满足B[i] A[i] 3所以存在offset3返回true。例2A [1, 2, 3]B [1, 3, 4]。第一个位置差值是0第二个位置差值是1第三个位置差值也是1不统一所以不存在offset返回false。简单说这就是判断两个数组能不能通过“整体加减同一个整数”变得完全一致。3.2 思路拆解为什么用偏移量判断一个直观的想法是如果存在这样的offset那么任意位置i都应该满足B[i] - A[i] offset。换句话说只要所有位置上的差值都一样offset就存在。所以我们不需要遍历所有可能的offset值只要取第一个位置的差值作为基准然后遍历数组检查是否每个位置的差值都等于这个基准。只要有一个位置不等立刻返回false。这里有两个容易被忽略的细节。第一为什么取第一个位置做基准如果offset存在所有位置的差值必然都等于同一个数取任意位置都一样取第0个位置只是习惯上最简单。第二两个int相减可能溢出。比如A[i]是Integer.MIN_VALUEB[i]是Integer.MAX_VALUE差值已经超过int范围。所以计算差值时最好先转成long避免溢出带来误判。3.3 代码实现与边界条件下面是我写的一个参考实现可以直接跑public class ArrayOffsetCheck { public static boolean canShiftToEqual(int[] a, int[] b) { if (a null || b null) { return false; } if (a.length ! b.length) { return false; } if (a.length 0) { return true; } long offset (long) b[0] - a[0]; for (int i 1; i a.length; i) { if ((long) b[i] - a[i] ! offset) { return false; } } return true; } public static void main(String[] args) { System.out.println(canShiftToEqual(new int[]{1, 2, 3}, new int[]{4, 5, 6})); // true System.out.println(canShiftToEqual(new int[]{1, 2, 3}, new int[]{1, 3, 4})); // false System.out.println(canShiftToEqual(new int[]{}, new int[]{})); // true System.out.println(canShiftToEqual(new int[]{1}, new int[]{Integer.MAX_VALUE})); // true } }边界条件处理很重要两个数组都为空按数学上理解已经没有元素需要比较了任取一个offset都能成立所以返回true长度不同直接falsenull数组返回false。这些判断如果面试时没写全很容易被追问“如果传null怎么办”。如果题目要求返回offset而不是boolean可以把返回值改成Long不存在时返回null存在时返回offset。用包装类的好处是语义清晰调用方拿到非null就知道存在偏移量。3.4 复杂度分析与面试追问这个解法的时间复杂度是O(n)只需要一次遍历空间复杂度是O(1)除了一个long变量没有额外容器。面试官很喜欢在写完基础版本后继续追问。比如如果n特别大这个解法还能优化吗答案是从复杂度上无法低于O(n)因为每个位置都可能不同最坏情况必须全部看一遍才能确定。如果题目改成“两个数组都已经排序”仍然要检查所有位置除非额外给定两个数组首尾元素的关系作为前置条件。另外一个常见变形是把数组换成二维矩阵问两个矩阵能不能通过“统一行偏移 统一列偏移”变成一样。这个问题会更复杂可以先想想一维的思路再扩展到二维第一行先通过列偏移对齐再看剩余行是否被同一个行偏移对齐。不过一维版本如果都写不对后面的就不用考虑了。这道题的价值在于它考察的不只是数组遍历更是把“是否存在唯一规律”转化为“样本检查”的思维。4. 数组实战中的常见坑与排查技巧4.1 空指针与null数组数组变量声明后如果不初始化默认值是null。这时候直接使用arr.length或者arr[0]都会抛NullPointerException。很多新手以为int[] arr;之后数组会自动变成一个空数组其实不是只有new了之后才有真正的对象。动态初始化int[] arr new int[0];得到的是一个长度为0的合法数组不是null。两者的区别在排查时很关键arr.length 0能判断“空数组”但不能区分“因为null所以不能用”和“真的空数组”。所以规范做法是先判null再判lengthif (arr null || arr.length 0) { // 处理空数组 }我在代码评审里见过很多次只写arr.length 0的判断结果入参是null时直接炸了。顺序不能反arr null ||一定要放在前面利用短路避免空指针。4.2 值传递还是引用传递数组作为方法参数这是Java基础面试的经典问题。Java语言中参数传递只有值传递但很多人因此把“数组在方法里改了不会影响外面”搞反了。数组变量保存的是对象的引用把数组变量传给方法时传递的是这个引用的副本。所以方法内通过arr[0] 100修改的是同一个数组对象的元素外部能看到变化但方法内执行arr new int[3]只是让局部变量指向一个新数组外部变量仍然指向原数组外部看不到变化。我用代码说明public static void modifyArray(int[] arr) { arr[0] 100; // 外部可见 arr new int[]{7, 8, 9}; // 外部不可见 } public static void main(String[] args) { int[] nums {1, 2, 3}; modifyArray(nums); System.out.println(Arrays.toString(nums)); // 输出 [100, 2, 3] }如果想让方法内重新赋值也影响外部可以返回新数组或者把数组包在一个自定义对象里。理解这个原理后很多“方法内明明改了数组为什么外面没变”的问题一眼就能定位。4.3 System.arraycopy与Arrays.copyOf的区别数组拷贝也是高频操作。System.arraycopy是native方法声明如下System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length)它可以把源数组从srcPos开始的length个元素拷贝到目标数组从destPos开始的位置适合数组扩容、局部拷贝、两个数组合并等场景。Arrays.copyOf是更简单的封装int[] newArr Arrays.copyOf(oldArr, newLength);它内部会调用System.arraycopy。区别在于copyOf只需传入原数组和新长度会在内部新建目标数组然后拷贝。如果newLength大于原数组长度多出来的位置补默认值这个特性常用来做数组扩容如果小于原数组长度就截断。还有一个细节copyOf和System.arraycopy对于引用类型数组都是浅拷贝拷贝的是引用不是对象本身。二维数组也一样copyOf只能复制第一层引用修改copy[0][0]会影响到原数组。真正需要深拷贝时必须遍历每一行分别clone。4.4 动态扩容怎么实现数组长度固定那怎么实现“动态数组”最直接的答案是使用ArrayList它内部就是一个Object[] elementData在add时如果容量不够会自动扩容。如果面试官让你手写一个简单的自动扩容数组可以参考ArrayList的思路保存一个数组和一个size记录有效元素个数。add时判断size是否等于elementData.length是的话扩容为原来的1.5倍int oldCapacity elementData.length; int newCapacity oldCapacity (oldCapacity 1); Object[] newData new Object[newCapacity]; System.arraycopy(elementData, 0, newData, 0, size); elementData newData;为什么是1.5倍而不是每次只加1因为如果每次只增加一个位置添加n个元素的总复杂度会退化成O(n²)。采用倍增策略虽然偶尔一次扩容需要O(n)的拷贝但均摊到每次add上总复杂度是O(1)。ArrayList的扩容因子默认是1.5HashMap扩容则更复杂但思想是相通的。4.5 常见错误速查表我在带新人时整理过一张数组错误速查表这里分享出来可以直接对照检查。错误写法或场景现象正确做法int[] arr;后直接arr.lengthNullPointerExceptionnew之后再用或先判空int[] arr new int[2]{1, 2};编译错误初始化值多余int[] arr {1, 2};或new int[]{1, 2}int[][] arr new int[][3];编译错误必须给第一维长度new int[3][]每行再初始化Arrays.sort(arr, (a,b)-b-a)且arr是int[]编译错误基本类型不能用Comparator转成Integer[]后传入ComparatorSystem.out.println(arr);打印对象地址用Arrays.toString(arr)循环条件i arr.length但代码里访问arr[i1]ArrayIndexOutOfBoundsException检查边界改成i arr.length - 1String[] arr new String[3];后直接arr[0].length()NullPointerException先判断arr[0] null把int[]传给Listint[]泛型不支持基本类型使用Integer[]或IntStream.boxed()这些坑看起来都很基础但每一个我都见过在线上的代码里出现过。尤其是数组下标越界和空指针能占数组相关Bug的七成以上。排查的时候不要一上来就怀疑算法逻辑先看看是不是下标写死了或者某个数组其实没有被正确初始化。5. 从数组到算法思维几个练习题带刷5.1 题一两数之和暴力哈希“两数之和”是数组题里最有名的入门题目给定一个int数组nums和一个目标值target返回两个下标使得这两个下标对应的元素之和等于target。先写暴力双重循环时间复杂度O(n²)for (int i 0; i nums.length; i) { for (int j i 1; j nums.length; j) { if (nums[i] nums[j] target) { return new int[]{i, j}; } } }暴力能做出来但面试会要求优化。更优的解法是遍历数组时用HashMap记录每个元素的值和下标同时查找target - nums[i]是否已经在Map里MapInteger, Integer map new HashMap(); for (int i 0; i nums.length; i) { int remain target - nums[i]; if (map.containsKey(remain)) { return new int[]{map.get(remain), i}; } map.put(nums[i], i); }HashMap的查找和插入平均O(1)所以整体O(n)。这个思路不只在数组题里用很多需要“边遍历边查历史元素”的题目都依赖同一个套路。5.2 题二移动零双指针题目给定一个整型数组nums把所有的0移动到数组末尾同时保持非零元素的相对顺序。要求原地操作不复制额外数组。思路是双指针。用一个慢指针slow表示“当前可以放置非零元素的位置”然后用快指针fast遍历数组。遇到非零元素就放到slow位置然后slow。最后剩下的位置都填0。简洁写法int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; slow; } } while (slow nums.length) { nums[slow] 0; }这个题考察的是双指针思想也提醒你注意“原地”这个要求。如果直接new一个新数组虽然也能解决但不满足题目限制。双指针在数组题里的应用非常广有序数组去重、移动零、颜色分类本质上都是这个套路。5.3 题三合并两个有序数组原地归并还有一道经典题nums1长度为mn前m个位置是排序好的有效元素后面n个位置是0占位nums2长度为n也已经排序。要求把nums2合并进nums1结果仍然有序必须原地操作。如果从前往后合并会覆盖nums1还没处理的元素很麻烦。正确姿势是从后往前看谁大谁放到末尾int i m - 1; // nums1 有效元素末尾 int j n - 1; // nums2 末尾 int k m n - 1; // nums1 总末尾 while (j 0) { if (i 0 nums1[i] nums2[j]) { nums1[k--] nums1[i--]; } else { nums1[k--] nums2[j--]; } }这个解法的时间复杂度O(mn)空间O(1)。从后往前覆盖的思路在处理“数组合并但空间已预留”的题目时非常实用。类似的还有把两个有序数组合并到新数组那个可以从前往后写但原地版本就必须倒着来。5.4 学习路线建议数组只是起点到这里数组的基础和几道经典算法题已经过了一遍。数组本身只是一个载体真正重要的是通过这些题练习起来的算法思维双指针、哈希表、前缀和、二分查找。这些思维在后续学习链表、树、图时会反复用到。我给刚开始刷题的朋友一个建议不要一上来就追难题先把数组、字符串、链表这些基础数据结构对应的简单题刷熟。比如二分查找、滑动窗口、前缀和这些经典套路很多都是基于数组展开的。等你把数组的各种操作练到不用想就能写对再往后面的数据结构走节奏会顺很多。至于Java环境配置、开发工具入口这些内容都属于Java入门的准备工作和数组本身关系不大但一定要先把本地环境跑通再动手写代码。如果命令行里能直接用javac和java跑起来说明基础环境已经没问题接下来就靠多写多练。数组就是这么简单又磨人的东西把它吃透后面的路会好走很多。我个人带过不少新人也参加过一些技术面试最大的感受是数组太基础基础到很多人不愿意耐心把细节捋清楚。但恰恰是这些细节决定了你在面试中敢不敢接下“手写一个去重”“写个原地合并”这类问题。最后再分享一个小技巧遇到任何数组相关的问题先不要急着在脑子里推演整个算法把数组打出来看一眼。用Arrays.toString还是debugger都行很多时候Bug的原因不是你思路不对而是某个下标差了一或者一个数组被意外赋成了null。把这些基础坑提前踩过、记住后面开发遇到的很多奇怪问题都会迎刃而解。
阅读完成 · 觉得有帮助?
咨询建站