ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数组在Java中到底怎么用?从内存原理到面试题一次讲透

数组在Java中到底怎么用?从内存原理到面试题一次讲透 相信每一个从 Java 入门走过来的朋友都绕不开“数组”这个词。它是最基础的数据结构也是面试八股文里高频出现的考点更是日常开发中处理批量数据的底子。很多人对数组的理解停留在“就是一块连续的内存空间”这个层面但真正上手时数组初始化、扩容、二维数组、与集合转换这些细节照样能绊倒一群人。这篇内容我准备从内存原理、创建初始化、遍历操作、二维数组、动态数组到高频面试题完整地把 Java 数组掰开揉碎讲一遍。不管你是刚学完 Java 语法的小白还是准备秋招面试的应届生或者写了两年代码想回头补基础的在职开发这篇文章都会让你对数组有更立体的认识。1. 数组到底是啥先把这个最基础的概念聊透1.1 内存视角下的数组很多人第一次接触数组记住的定义是“数组是相同类型数据的集合”。这个说法没错但它没有解答一个更关键的问题数组在内存里到底长什么样。在 JVM 中数组本质上是一段连续的内存区域。当你写下int[] arr new int[5]时JVM 会在堆内存里分配一块足够存放 5 个 int 的连续空间每个 int 占 4 字节总共 20 字节。数组对象本身还有对象头、长度信息等额外开销这部分通常不在我们业务代码的讨论范围内但理解“连续”这两个字很重要。连续存储带来的直接好处是访问速度快。因为数组的首地址是确定的每个元素的内存地址可以精确计算出来——address baseAddress index * elementSize。这就是为什么数组按下标访问的时间复杂度是 O(1)无论你访问第 1 个还是第 1000 万个元素耗时几乎一样。但连续存储也带来代价插入和删除困难。如果你要在数组中间插入一个元素需要把后续所有元素逐个后移删除元素同理需要前移。这个操作的时间复杂度是 O(n)当数组很大时性能损耗非常明显。1.2 数组的三大特性在实际开发中我更愿意把数组理解成三个核心特性的组合体理解了这三个特性后续很多坑都能提前避开。第一个特性是固定长度。数组一旦创建长度不可变。这不是说你不能“扩容”而是说扩容的本质是创建一个新数组把旧数组的元素拷贝过去然后让引用指向新数组。很多人写着写着就忘了这一点导致数组越界异常。第二个特性是类型统一。数组里只能存放同一种类型的元素。int[]只能放 intString[]只能放 String。如果你想放不同类型的对象可以定义一个公共父类数组比如Object[]但这会带来类型转换的开销和风险通常不建议滥用。第三个特性是支持随机访问。基于下标读取元素是数组最强大的能力很多算法和数据结构如堆排序、二分查找都依赖这个特性。举个例子二分查找为什么要求数据结构支持随机访问因为二分查找需要反复跳到数组中间的位置如果不是连续存储、不支持随机访问每次“跳到中间”都是 O(n) 的操作整个算法就从 O(log n) 退化成 O(n log n)。这一点在面试中经常被问值得记住。2. 数组的创建与初始化十个人九个坑2.1 声明、创建、赋值的标准姿势Java 中数组的声明有两种等价写法风格不同但语义完全相同int[] arr1; // 推荐类型清晰 int arr2[]; // C语言风格Java中也能用这里想提醒一句写 Java 代码建议统一用int[] arr这种风格。int arr[]是 C/C 的习惯遗留在 Java 里合法但在代码审查中容易被问一句“为什么这么写”没必要给自己找麻烦。创建数组则有两种方式静态初始化和动态初始化。// 静态初始化声明时直接赋值长度由编译器推断 int[] arr1 {1, 2, 3, 4, 5}; // 动态初始化指定长度元素使用默认值 int[] arr2 new int[5];静态初始化其实是一种语法糖int[] arr {1, 2, 3}的底层仍然是先 new 一个长度为 3 的数组再把 1、2、3 依次赋给每个元素。它能用是因为 Java 编译器帮你做了这些操作。需要注意一个非常容易踩的坑静态初始化不能拆开写。int[] arr; arr {1, 2, 3}; // 编译报错数组初始化器在这里不被允许正确写法是int[] arr; arr new int[]{1, 2, 3}; // 匿名数组对象这个new int[]{1, 2, 3}语法很重要它还常用于方法传参比如直接调用一个接收数组参数的方法printArray(new int[]{1, 2, 3});2.2 默认值与初始化细节动态创建一个数组后元素会自动获得默认值。很多人会搞混不同数据类型的默认值这里直接用一张表记清楚类型默认值说明byte, short, int, long0整数型默认为 0float, double0.0浮点型默认为 0.0char\u0000空字符打印时表现为空白booleanfalse布尔型默认为 false引用类型String 等null引用默认为 null这张表不只是面试题开发中真的会影响逻辑正确性。比如你写了一个String[] names new String[10]在没有给任何元素赋值之前遍历输出会得到一长串 null排序时如果没做 null 判断直接调用name.equals()就会空指针异常。对象数组还有一个隐藏问题new String[10]只是创建了 10 个“可以存放 String 引用的空位”并没有创建真正的 String 对象数组里每个元素都是 null你需要逐个 new 或者赋值。很多新手以为创建数组时对象也一起创建了这个认知错误会导致后续代码大量空指针。2.3 数组的复制数组复制是高频操作。常见的有三种方式循环复制、System.arraycopy、Arrays.copyOf。循环复制最直观但代码不够简洁int[] src {1, 2, 3, 4, 5}; int[] dest new int[src.length]; for (int i 0; i src.length; i) { dest[i] src[i]; }System.arraycopy是 JDK 提供的高性能复制方法属于 native 调用性能优于手动循环int[] src {1, 2, 3, 4, 5}; int[] dest new int[src.length]; System.arraycopy(src, 0, dest, 0, src.length);参数分别是源数组、源起始位置、目标数组、目标起始位置、复制长度。这个方法还能实现“从中间截取一段复制”的效果非常灵活。Arrays.copyOf是对System.arraycopy的封装使用最方便还能在复制时扩容int[] src {1, 2, 3}; // 会生成一个新数组长度为 5后两个位置补默认值 0 int[] newArr Arrays.copyOf(src, 5);从实际项目经验来说日常扩容、拷贝直接用Arrays.copyOf最省心但如果你是在做高性能场景比如频繁复制大数组建议直接使用System.arraycopy少一层方法调用开销。3. 数组遍历与常用操作3.1 遍历的几种写法遍历数组有四种常用方式普通 for 循环、增强 for 循环、JDK 8 的 Stream、以及Arrays.stream配合方法引用。普通 for 循环可以操作下标适合需要修改元素值或需要用到位置的场景int[] arr {10, 20, 30}; for (int i 0; i arr.length; i) { arr[i] arr[i] * 2; }增强 for 循环代码简洁但拿不到下标也无法直接修改原数组元素除非元素本身是可变对象for (int num : arr) { System.out.println(num); }还有一种需要注意的场景增强 for 循环里修改元素不会影响原数组因为每次取出来的是值的拷贝。这一点初学者经常踩坑int[] arr {1, 2, 3}; for (int num : arr) { num num * 10; } // 结果 arr 仍然是 1, 2, 3增强 for 循环的本质是迭代器遍历数组时 JVM 仍然通过下标访问性能上和普通 for 循环差别不大。但如果你在增强 for 循环里尝试增删集合元素会触发ConcurrentModificationException数组不存在这个问题因为数组长度固定根本没法增删。3.2 数组转字符串、排序、去重数组直接打印会得到一串看不懂的地址信息比如[I1b6d3586。这里[I表示 int 数组后面是哈希值。要格式化输出有以下几种手段int[] arr {3, 1, 2}; // 方式一Arrays.toString输出 [3, 1, 2] System.out.println(Arrays.toString(arr)); // 方式二手动拼接 StringBuilder sb new StringBuilder(); for (int i 0; i arr.length; i) { if (i 0) sb.append(, ); sb.append(arr[i]); }Arrays.toString是首选输出格式可读性好数组嵌套二维数组可以用Arrays.deepToString。数组排序最常用的是Arrays.sort底层对于基本类型数组使用双轴快速排序对于对象数组使用 TimSort 归并排序int[] arr {5, 3, 1, 4, 2}; Arrays.sort(arr); // 升序 System.out.println(Arrays.toString(arr)); // [1, 2, 3, 4, 5]如果要对对象数组按自定义规则排序需要传入 ComparatorString[] names {b, a, c}; Arrays.sort(names, Comparator.reverseOrder());关于数组去重基本类型数组没有现成的 API常见思路是借助 LinkedHashSet 去重后转回数组或者借助 Streamint[] arr {1, 2, 2, 3, 3, 4}; int[] distinctArr Arrays.stream(arr).distinct().toArray(); System.out.println(Arrays.toString(distinctArr)); // [1, 2, 3, 4]如果是对象数组去重用Arrays.stream(arr).distinct()也可以依赖对象的 equals 和 hashCode 方法。这里要理解一个底层细节Stream 的去重在内部还是要基于 HashMap 或类似结构完成本质上空间换时间。3.3 数组扩容的真相数组长度固定所以“扩容”只是创建新数组加复制。Java 里最标准的扩容姿势是Arrays.copyOfint[] arr {1, 2, 3}; // 扩容到 5超出部分填充 0 arr Arrays.copyOf(arr, 5);看 ArrayList 源码会发现它的扩容也是这个套路先确保容量不够就扩大约 1.5 倍然后Arrays.copyOf复制到新数组。这里我想展开一个高频面试点为什么 ArrayList 扩容是 1.5 倍不是 2 倍从数学角度考虑如果每次扩容只增加一个固定大小那么反复添加元素会导致反复复制整个数组整体复杂度退化。如果按比例扩容则均摊复杂度接近 O(1)。选择 1.5 倍而不是 2 倍是为了减少扩容次数与空间浪费之间的平衡——2 倍在重复扩容时更浪费空间1.5 倍在重复扩容时复制开销稍高但空间利用率更高。不同 JDK 版本的策略略有差异但这个比例背后的权衡思路是通用的。我自己在项目里有一个习惯如果能预估数据规模就提前指定数组容量或集合初始容量避免频繁扩容导致的内存复制开销。虽然现代 JVM 优化得很好但大数据量下扩容带来的 GC 压力仍然不可忽视。4. 二维数组与数组的数组4.1 二维数组的内存布局二维数组在 Java 里实际上是“数组的数组”。不要想象成一个矩形表格它在内存中是一维数组嵌套的形态int[][] matrix new int[3][4];这个语句创建了一个长度为 3 的一维数组其中每个元素都是一个长度为 4 的 int 数组的引用。用文字描述matrix 指向一个对象该对象内部有三个引用分别指向三个长度为 4 的 int 数组。理解这一点对性能很重要。二维数组的每一行在内存中不一定连续行与行之间的内存地址可能分散。所以遍历二维数组时按照“行优先”的顺序访问外层循环行内层循环列缓存命中率更高性能更好。反过来如果你按照列优先访问每读一个元素都要跳转到不同的内存区域缓存友好度差性能会明显下降。这在数据量大时差距可以到数倍甚至更多。二维数组的创建方式也需要注意几种写法// 直接初始化 int[][] m1 {{1, 2}, {3, 4}}; // 动态初始化 int[][] m2 new int[2][3]; // 只指定行数列数后面再指定 int[][] m3 new int[2][]; m3[0] new int[5]; m3[1] new int[3];第三种写法就是“不规则数组”的来源也是很多人容易懵的地方。4.2 不规则二维数组Java 的二维数组可以不规则。比如用一个二维数组来存储三角形矩阵每一行的列数不同int[][] triangle new int[5][]; for (int i 0; i triangle.length; i) { triangle[i] new int[i 1]; for (int j 0; j i; j) { triangle[i][j] j 1; } }这种不规则数组在很多算法题里会出现比如杨辉三角。处理不规则二维数组时千万别写死triangle[i][j]就认为每行都有相同列数。你需要先通过triangle[i].length获取当前行的长度再做内层循环。从面试角度面试官常会用二维数组来考遍历边界和内存理解。一个常见问题是二维数组int[][] arr new int[3][4]在内存中创建了几个对象答案是1 个外层数组对象 3 个内层数组对象共 4 个对象。如果你对 JVM 内存模型不熟悉这道题就很容易答错。另一个容易错的点是二维数组的length。arr.length是行数arr[0].length是第一行的列数。不少新手以为二维数组也有一个全局的列数属性但实际上每个内层数组长度都可以不同必须逐行获取。5. 动态数组为什么我们总是更喜欢 ArrayList5.1 ArrayList 内部机制数组的固定长度在实际业务中很不方便所以 Java 集合框架提供了 ArrayList 这个动态数组实现。它的内部其实就是一个 Object 数组通过扩容机制实现了“动态”的效果。ArrayList 的核心源码逻辑可以简化为几个点。默认初始容量为 10第一次添加元素的时候才会实际创建数组。当容量不够时新容量大约是旧容量的 1.5 倍具体实现是int newCapacity oldCapacity (oldCapacity 1);这个右移一位的操作就是除以 2所以新容量是旧容量的 1.5 倍。然后调用Arrays.copyOf完成迁移。理解了这个机制就能解释很多面试题。比如为什么 ArrayList 频繁 add 时性能会下降因为扩容涉及数组复制旧数组变成垃圾加重 GC 负担。比如为什么建议预判容量new ArrayList(1000)可以避免前期的多次扩容迁移尤其是已知数据量是几十万条时效果立竿见影。再往深一层说ArrayList 的增删操作性能需要具体情况具体分析。尾部 add 的均摊时间复杂度是 O(1)中间 add 和头部 add 是 O(n)因为需要移动元素。这个结论和数组本身的特性一脉相承。5.2 什么时候用数组什么时候用集合在实际项目中我的选择逻辑非常简单。当数据量固定、操作模式基本是“按下标读取”且内容类型是基本数据类型时优先用数组。典型场景包括图形处理的像素矩阵、科学计算中的向量和矩阵运算、以及性能敏感的底层工具方法。当数据量动态变化、需要频繁增删元素或需要各种现成的算法支持时用 ArrayList 或 LinkedList。ArrayList 是默认选择因为它的随机访问性能和迭代性能都足够好内存局部性也比链表强很多。还有一种组合情况方法内部需要高性能临时存储时用数组跨方法传递或需要框架支持时用 List。比如写一个工具方法要收集一堆中间结果直接用ArrayList更方便如果你已经知道结果数量上限直接用数组配合下标填充代码还能少不少。顺便说一句Arrays.asList把数组转成 List 时有一个大坑这个转换出来的 List 长度固定不能 add 和 remove一旦调用就会抛UnsupportedOperationException。原因是它返回的是一个基于原数组的视图不是独立的 ArrayList。想真正得到一个可自由增删的 List需要再包一层ListInteger list new ArrayList(Arrays.asList(1, 2, 3));这段代码应该是很多 Java 开发者的肌肉记忆但能讲清楚为什么的人并不算多。6. 面试中关于数组的那些高频考点6.1 数组有没有 length() 方法String 有没有 length 属性这个问题听起来简单考察的是对数组和 String 底层结构的基本理解。数组的 length 是属性不是方法所以正确写法是arr.length。String 的 length 是方法所以是str.length()。原因是数组在 JVM 底层有专门的 length 字段由 JVM 管理而 String 内部的字符存储依赖char[]对外通过方法暴露长度。这个知识点面试时经常是“第一题”答不上来会给人基础不牢的印象。建议在回答时再补充一句数组是特殊的内置类型不是类所以没有方法只有属性String 是类通过方法访问内部状态更符合面向对象封装原则。6.2 数组可以存放哪些类型数组既能存放基本类型也能存放引用类型。但这个问题的深层考点是数组的协变特性covariance。String[]可以被赋值为Object[]类型但String[]不是Object[]的实例它们的关系是类型兼容而非继承。数组的协变特性带来一个运行期风险。以下这段代码可以编译但不安全Object[] arr new String[10]; arr[0] 123; // 编译通过运行期抛 ArrayStoreException数组在运行时会检查所存对象类型是否符合声明时的类型。这是数组与泛型很大的一个不同点。泛型在编译器就做类型检查而数组的类型检查延后至运行期。面试只要牵涉到“数组和泛型的区别”这个点通常是最重要的分水岭。6.3 数组与 List 的转换数组转 List 常用Arrays.asList但前面提醒过它的限制不可增删。List 转数组常用list.toArray(T[] arr)或toArray()。其中toArray(T[] a)有个细节传入的数组如果容量足够直接放到这个数组里返回如果容量不够会新建数组返回。所以常见最佳写法是传入一个长度为 list.size() 的数组ListString list new ArrayList(Arrays.asList(a, b, c)); String[] arr list.toArray(new String[0]);这里new String[0]就是 JDK 推荐的最小化分配写法。放在早期版本可能觉得多此一举但在 JVM 优化下这个写法既简洁又高效。面试时可以提一句传入大小为 0 的数组时源码会根据集合实际大小动态分配效率和性能都在可控范围内。6.4 内存泄漏数组持有引用面试中还有一道比较深的问题数组会不会造成内存泄漏会。当你持有一个大数组但数组中部分元素已经不需要时如果不清空这些引用JVM 就无法回收它们造成内存泄漏。典型场景是自定义栈实现class MyStack { private Object[] data; private int size; public Object pop() { if (size 0) return null; Object item data[--size]; data[size] null; // 这一行很重要 return item; } }如果不加data[size] null栈顶位置仍持有已弹出对象的引用即使外部已经不再使用这个对象它仍然无法被 GC 回收。这个问题在 long-lived 对象场景下尤其明显属于实打实的内存管理问题。Java 虽然有自动内存管理但不代表不需要关注引用生命周期。6.5 经典算法题思路数组是算法题的土壤这里只串几个最常见的冒泡排序双重循环外层控制轮数内层比较相邻元素并交换每轮把最大元素“冒泡”到末尾。时间复杂度 O(n^2)稳定排序适合小数据量。快速排序递归分治选定 pivot 后把小于 pivot 的元素放左边大于 pivot 的放右边平均时间复杂度 O(n log n)是不稳定排序。二分查找前提是数组有序每次取中间位置比较将搜索区间缩小一半时间复杂度 O(log n)。双指针一个左指针一个右指针常用于有序数组两数之和、反转数组、三数之和等问题。前缀和预计算prefix[i]表示前 i 个元素的和区间和查询变成 O(1) 操作非常适合频繁求和场景。这些题不是我背出来的而是在实际工作中处理数据分析、日志范围过滤时经常能映射到的场景。算法题不要只当八股文背理解了它背后的“为什么”做题和写业务代码都会顺手很多。7. 实操避坑与性能建议7.1 常见异常数组操作中常见的异常主要有三种每种背后都有对应的防错思路ArrayIndexOutOfBoundsException下标越界。访问负数下标或超出length - 1的下标都会触发。这是最基础的异常也是最常见的解决办法就是好习惯所有下标访问前先确认边界。NullPointerException数组元素为 null。对象数组的实际元素没有初始化时访问其成员方法就会报空指针。建议在遍历对象数组时先判空。ArrayStoreException元素类型不匹配。前面聊多维数组协变性时提到过属于运行时检查。下面是一个综合了多种异常可能的小例子String[] arr new String[3]; arr[0] a; arr[1] null; arr[2] b; for (int i 0; i arr.length; i) { if (arr[i] ! null) { System.out.println(arr[i].toUpperCase()); } }如果少了 null 判断第二行就会空指针程序直接挂掉。数据处理中这种坑特别多尤其是文件读取、数据库查询结果转数组时null 值几乎是必然存在的东西。7.2 性能相关数组性能的几个核心建议我自己写代码时一直遵守遍历大数组时优先用“普通 for 循环 局部变量缓存数组长度”例如for (int i 0, len arr.length; i len; i)。这样避免每次循环都访问 length 属性虽然 JIT 优化后差别很小但这是代码习惯问题。如果循环体内频繁访问数组元素可以先用局部变量取出再使用减少边界检查。Java 开发中 JIT 已经帮我们消除大部分边界检查但这种风格在高性能计算场景下依然有意义。避免在循环体内创建大数组或调用Arrays.copyOf尽量提前分配好空间一次到位。对基本类型数组排序用Arrays.sort对对象数组排序如果比较规则稳定提前构造好 Comparator而不是在排序中实时计算。还有一个缓存相关的性能细节。数组是连续内存结构遍历数组时 CPU 缓存命中率很高。所以能用数组存大量基本类型数据时就不要硬套 List 加 Integer 装箱。虽然现代 JVM 对标量替换和逃逸分析做得很好但在大数组场景下基本类型数组仍有实打实的优势。7.3 代码规范建议数组这块的代码规范我认为有三点值得写进团队规范里。第一常量先定义。魔法数字是数组代码里最讨厌的东西比如new int[10]这个 10 是什么含义过三周你自己都未必记得。宁可多写一行private static final int DEFAULT_SIZE 10; int[] arr new int[DEFAULT_SIZE];第二工具方法优先。数组转字符串、排序、复制、填充这些操作优先用 JDK 的Arrays工具类不要自己发明轮子。Arrays.fill、Arrays.sort、Arrays.binarySearch这些都是经过严格测试的实现可靠性和性能远超手写代码。第三传递数组时注意是否要修改原数据。数组是引用传递方法内对数组元素的修改会影响调用方。如果不想让调用方数组被意外修改需要在方法内先做防御性拷贝void process(int[] input) { int[] safeCopy input.clone(); // 只操作 safeCopy }这个方法在写接口、写工具类时非常重要尤其当数组数据来自外部系统或用户请求时防御性拷贝能避免很多踩都踩不完的雷。我自己在实际项目中踩过最深的坑就是对象数组的浅拷贝问题。clone()对于引用类型数组拷贝的是引用两个数组引用同一个对象。修改其中一个数组中的对象属性另一个数组看到的也是修改后的状态。这一点如果没提前设计好排查 bug 时非常痛苦。所以当数组里的元素是可变对象时要多问一句我要的是浅拷贝还是深拷贝两者之间的的性能和内存差异又有多大。最后分享一个我的习惯。写算法题或者做业务开发时我尽量先考虑数据规模再决定用数组还是集合。数据量不超过几千条、访问模式固定时数组就够了数据量动态变化或需要频繁增删元素就切到 ArrayList。这种“先想清楚场景再选数据结构”的思维方式比记住一堆 API 重要得多。数组并不复杂但它是理解 Java 内存模型、性能优化、集合框架的基石。把这块地基打扎实后边的路会顺很多。
返回列表