☰
Java数组从入门到精通:内存分配、深浅拷贝与面试避坑全解析
2026/10/3 10:32:20 网站建设 项目流程

写Java好几年,真正让我愿意花一整天赔进去讲清楚的基础知识点,数组排第一。日常开发里我们经常用 ArrayList、HashMap 这类现成容器,很少直接碰数组,但它恰恰是所有集合框架的底层原型,也是 Java 面试中高频出现、最容易让人翻车的一环。我见过不少工作两三年的开发者,提到int[]和Integer[]的区别、数组的浅拷贝深拷贝、二维数组的真实内存结构,仍然讲不透。所以这篇把 Java 数组从声明、初始化、内存分配到遍历、复制、排序去重、集合互转、高频面试题一次梳理完。想准备 Java 面试的人可以重点看第 2、5 部分,刚入门的同学建议从头顺序读,把这块地基打好。

1. 数组到底是什么:声明、初始化与内存分配

1.1 数组在 Java 里的“容器”角色

数组是 Java 中一种定长、同质的容器。定长指数组一旦创建,长度就无法改变;同质指同一个数组里只能存放同一种类型的元素,要么都是int,要么都是String,要么都是某个自定义类,不允许把不同类型混在一起装。

这里有个很重要的概念需要先纠正:int[]不是基本类型,而是一个引用类型。int[] nums这类声明,本质是定义了一个引用变量,变量本身存在栈上,真正存放数据的内存区域分配在堆里。每次执行new int[5],JVM 都会在堆上创建一个数组对象,并把数组对象的首地址交回给栈上的引用变量。这也是后来理解二维数组、数组复制和深浅拷贝的前提。

数组的“定长+同质”两个特性决定了它的适用场景:数据规模确定、类型统一、需要通过下标在 O(1) 时间内随机访问数据。比如固定保存一批配置参数、算法题里的中间结果、或者从固定格式文件里读取的一列数值,用数组最合适。

1.2 三种初始化方式与适用场景

平时写代码,我习惯把数组初始化归纳成三种方式,对应不同场景。

// 方式一:静态初始化,声明的同时直接赋值 int[] a = {1, 2, 3, 4}; // 方式二:动态初始化,只给长度 int[] b = new int[5]; // b 中每个元素默认是 0 // 方式三:先声明,再 new,再逐一赋值 int[] c = new int[3]; c[0] = 10; c[1] = 20; c[2] = 30;

静态初始化最适合“内容明确且固定”的场景。比如定义一个星期的英文缩写数组,或者把一组枚举对应的中文名称写进去,代码看起来非常清爽。它有一个隐藏限制:如果先写了声明,后面再想用{}赋值是编译不过的。

int[] d; d = {1, 2, 3}; // 编译报错 d = new int[]{1, 2, 3}; // 必须这样写

动态初始化适合“长度确定但内容需要后续填充”的场景,比如从数据库读十条用户 ID,先new long[10],再循环往里面填。第三种方式其实也是动态初始化的一种展开写法,通常在数组长度需要先经过计算得出、随后逐步赋值时使用。

不管用哪种方式,一旦创建,数组的长度就固定了。a.length只能读,不能在代码里给 length 赋值,这也是很多新手容易踩的坑。

1.3 默认值、越界与空数组

Java 数组有一个比 C/C++ 更“安全”的设计:数组创建后,每个元素都会有默认值,不会出现未初始化数据。比如int[]默认全是 0,double[]默认 0.0,boolean[]默认 false,String[]默认 null。这个机制省去了手动清零的麻烦,但也会带来一个小问题:动态初始化之后如果忘记赋值就参与运算,结果里会混进不少 0,且不报错,排查起来特别隐蔽。

访问数组时,下标从 0 开始,最大下标是length - 1。写成arr[arr.length]必然抛出ArrayIndexOutOfBoundsException。这个异常是绝大多数 Java 新手最早见到的异常之一,应对办法只有一个,就是在所有依赖下标操作的代码前,先确认数组长度和当前索引的范围。

还有一类容易混淆的“空”:

int[] empty = new int[0];

new int[0]创建了一个长度为 0 的数组对象,它本身不是 null。null 表示引用不指向任何对象,而空数组是一个真实存在的对象。几乎所有集合容器在转为空数组时,都更推荐“返回空数组而不是 null”,比如list.toArray(new int[0]),这样调用方每次拿到结果后,无需再担心空指针,可以直接遍历。

2. 遍历、复制与常用操作:像用工具箱一样用数组

2.1 遍历的三种写法

数组最基础的操作就是遍历。根据需求不同,我一般会把遍历方式分成三类。

int[] nums = {3, 1, 4, 1, 5, 9}; // 传统 for:带下标,适合需要修改元素或使用索引的场景 for (int i = 0; i < nums.length; i++) { System.out.print(nums[i] + " "); } // 增强 for:只读遍历,代码最简洁 for (int n : nums) { System.out.print(n + " "); } // Stream 流式遍历:适合配合过滤、映射一起用 Arrays.stream(nums).filter(n -> n > 2).forEach(System.out::println);

传统 for 是修改数组的必备方式,比如把数组每个元素乘以 2,就必须通过下标找到对应位置再赋新值。增强 for 底层也是一次遍历,但它拿到的只是元素副本,直接给变量重新赋值不会改变数组内容。比如:

for (int n : nums) { n = 0; // 这只改了临时变量 n,nums 的元素不受影响 }

这一点在引用类型数组中尤其容易踩坑。如果数组元素是对象,增强 for 里拿到的对象引用仍然指向原对象,修改对象属性会影响原数组;但如果给变量重新指向一个新对象,原数组不变。简单记忆:增强 for 适合“只读不变更引用”的遍历。

2.2 数组复制与扩容的几种手段

数组长度固定,所谓的“扩容”其实都是先创建一个更长的数组,再把旧数据复制过去。这就是复制操作存在的意义。在 Java 里复制数组有几种常见手段,效率和使用体验各不相同。

int[] src = {1, 2, 3, 4, 5}; // 方式一:System.arraycopy,底层 native 方法,效率最高 int[] target1 = new int[src.length * 2]; System.arraycopy(src, 0, target1, 0, src.length); // 方式二:Arrays.copyOf,封装更好,默认其余位置补 0 int[] target2 = Arrays.copyOf(src, src.length * 2); // 方式三:clone,返回一个新的数组对象 int[] target3 = src.clone();

System.arraycopy是很多工具类底层的实现依赖,参数比较多:源数组、源起点、目标数组、目标起点、复制长度。Arrays.copyOf则更贴近日常使用,第二个参数指定新数组长度,如果新长度大于原长度,多余位置会被默认值填充。

这里要特别提醒:对于引用类型数组,以上三种复制方式都属于浅拷贝。也就是说,复制出来的是一个新数组,但数组里每个元素仍然指向原来的对象。如果原数组元素是可变对象,修改新数组里的对象状态,原数组里对应的对象也会跟着变。后面第 5 部分会专门展开讲深浅拷贝。

2.3 数组转字符串与字符串转数组

数组和字符串看起来像两个不同领域的东西,但在处理日志、文件内容、字符数据时经常需要互转。

// 数组转字符串,推荐直接用 Arrays.toString int[] nums = {1, 2, 3}; System.out.println(Arrays.toString(nums)); // [1, 2, 3] // 字符串数组转一个带分隔符的字符串 String[] tags = {"java", "array", "list"}; String joined = String.join(",", tags); // java,array,list // 字符串转 char 数组 String s = "hello"; char[] chars = s.toCharArray(); // 字符串按分隔符拆成 String 数组 String line = "a,b,c"; String[] parts = line.split(",");

有一个细节:Arrays.toString输出的是[1, 2, 3],这种带方括号的格式如果直接写进 SQL 或日志,往往不是你想要的效果。需要干净字符串时,可以先把数组转成List,再用String.join,或者自己写循环拼接。

反过来,toCharArray()转换出来的是一个全新的字符数组,对它做修改不会影响原字符串,因为 String 本身是不可变对象。这个特性在写字符统计、反转字符串这类算法题时很有用,可以随便临时修改 char 数组而不污染原始数据。

2.4 数组排序、去重与过滤

排序是数组操作里最常用的功能。Arrays.sort支持所有基本类型数组,内部实现会根据数组长度在快速排序和插入排序之间切换,实际使用时你不需要关心底层策略,直接用即可。

int[] nums = {5, 2, 8, 1, 9}; Arrays.sort(nums); System.out.println(Arrays.toString(nums)); // [1, 2, 5, 8, 9] String[] names = {"banana", "apple", "cherry"}; Arrays.sort(names); System.out.println(Arrays.toString(names)); // [apple, banana, cherry]

对对象数组排序时,如果对象没有实现Comparable接口,就需要传一个Comparator。比如按字符串长度排序:

Arrays.sort(names, Comparator.comparingInt(String::length));

去重也是高频需求。数组本身没有去重方法,常见思路有三种:转换成LinkedHashSet去重并保持顺序、先排序然后相邻比较去重、用 Stream 的distinct。其中流式写法最简洁:

int[] distinct = Arrays.stream(nums).distinct().toArray();

过滤同样可以用 Stream,也可以手写循环。比如有一批文件名,想筛出所有包含.log的文件,Stream 写法非常直观。

String[] files = {"app.log", "config.yml", "error.log", "readme.md"}; Arrays.stream(files) .filter(f -> f.contains(".log")) .forEach(System.out::println);

3. 多维数组与“数组的数组”:从一维到二维再到不规则数组

3.1 二维数组的内存布局

Java 里的二维数组并不是一个“扁平的矩形数据块”,它的本质是数组的数组,或者说是一个一维数组,其中每个元素又是另一个一维数组的引用。

int[][] matrix = new int[3][4];

这段代码的实际内存结构是:栈上有一个引用指向堆中的外层数组,外层数组长度为 3,每个元素是一个指向内层数组的引用。每个内层数组都是独立创建的,长度为 4。这种设计带来一个特点:matrix.length是 3,表示行数;matrix[0].length是 4,表示第一行的列数。

有些资料会把“二维数组每行占据连续内存”理解成整个二维数组是一块连续内存,这个理解在 Java 中不准确。外层数组和内层数组在堆里的位置并不会保证连续,因此访问matrix[i][j]时,JVM 实际要经过两级引用的跳转。这种间接性也是二维数组在极端性能场景中不如一维数组按行映射快的原因之一。如果你在写性能敏感代码,可以手动把二维下标映射到一维数组:index = row * col + col,减少一次引用跳转。

3.2 不规则数组与常见遍历

因为 Java 二维数组的每一行都是独立的数组对象,行长度可以不一致,这就天然支持了“不规则数组”或者说“锯齿数组”。

int[][] triangle = new int[3][]; triangle[0] = new int[1]; triangle[1] = new int[2]; triangle[2] = new int[3];

这种结构在处理杨辉三角、帕斯卡三角、图结构的邻接表等场景时非常常见。声明时只指定外层长度,内层数组后续单独分配。需要注意,在没有创建内层数组之前,triangle[0]是 null,如果直接访问triangle[0][0]会报空指针。

遍历不规则数组时,必须使用matrix[i].length判断每行的实际长度,不能假设每行长度都一样。

for (int i = 0; i < triangle.length; i++) { for (int j = 0; j < triangle[i].length; j++) { triangle[i][j] = j + i; } }

这里我吃过一个教训:早期写算法题时,图省事把所有行默认成固定长度,结果内存浪费严重。后来换成了不规则数组,不仅更贴合数据本身,代码读起来也能直接反映业务上的“层级结构”。

3.3 “指针数组”概念在 Java 中的等价理解

C/C++ 里有“指针数组”的说法,比如char* arr[3],表示一个数组,里面每个元素是一个指向字符串的指针。Java 没有指针,但我们可以用“引用数组”来理解同等概念。

String[] strings = new String[3]; strings[0] = "hello"; strings[1] = "world"; strings[2] = new String("java");

这里的strings数组里,存的不是字符串内容本身,而是三个 String 对象的引用。换句话说,每个数组元素都是一个“类似指针的引用”。所以引用类型数组复制后,新数组和旧数组中的元素指向的是同一批对象。

初学者经常被这个特性绕晕。其实可以这样类比:基本类型数组像是存了一排独立的卡片,卡片上写的是数值;引用类型数组像是一排地址簿,每格写的是一个门牌号,门牌号指向的对象可能在完全不同的地方。操作地址簿本身不会复制房屋,只是多了几处能访问到同一房屋的入口。

4. 动态变化怎么办:数组与集合的取舍

4.1 为什么直接用数组在很多场景不够用

数组强在同一类型、定长、高性能随机访问,弱在“动态变化”。插入、删除、扩容这三个动作数组都需要手动处理。

比如在数组中间插入一个元素,正确做法是先把插入位置之后的所有元素向后移动一位,再在目标位置写入新值。这一步涉及循环和System.arraycopy,代码一旦写错,很容易出现覆盖或越界。删除元素同理,需要把后面的元素整体前移,同时把末位置空出来。

// 在 index 位置插入 value int[] arr = {1, 2, 3, 4, 5}; int index = 2; int value = 99; int[] newArr = new int[arr.length + 1]; System.arraycopy(arr, 0, newArr, 0, index); newArr[index] = value; System.arraycopy(arr, index, newArr, index + 1, arr.length - index);

这种代码写起来繁琐且易错。所以一旦数据频繁增加或删除,最自然的方案是转向集合类,尤其是ArrayList。Java 集合框架也不是凭空设计出来的,它正是为了解决数组在动态扩容、插入删除、遍历操作上的不便。

4.2 ArrayList 的扩容机制

ArrayList底层就是一个Object[]数组,这个知识点几乎每次面试都会问到。它能够动态变化的秘密,在于“装不下了就扩容”。

// 无参构造下,JDK 8 中 ArrayList 初始是空数组 List<Integer> list = new ArrayList<>(); // 第一次 add 时,数组容量会从 0 扩容到 10

当数组容量不够时,ArrayList会计算新容量,大致是原来容量的 1.5 倍。JDK 里对应的计算方式是oldCapacity + (oldCapacity >> 1),也就是oldCapacity + oldCapacity / 2。之后通过Arrays.copyOf创建新数组,并把旧数组元素复制过去。

理解扩容机制对实际开发有两个帮助。第一,如果事先能估计数据规模,建议直接用new ArrayList<>(expectedSize)指定初始容量,减少扩容次数,能带来可观的性能提升。第二,无参构造的初始容量是 10,如果长期往里面加大量数据,会发生多次数组复制和旧对象回收,这在百万级数据场景下确实会有可感知的开销。

4.3 数组与 List 互转的坑

数组转 List、List 转数组看似简单,但坑非常多,这里提几个最常见的。

// 坑一:Arrays.asList 返回的是固定长度视图 String[] arr = {"a", "b", "c"}; List<String> list = Arrays.asList(arr); list.add("d"); // 抛 UnsupportedOperationException

Arrays.asList底层直接把数组包装成 List,它没有实现结构性修改的方法,所以不能 add 和 remove。如果需要可变列表,要再包一层:new ArrayList<>(Arrays.asList(arr))。

// 坑二:基本类型数组直接用 Arrays.asList 会得到 List<int[]> int[] nums = {1, 2, 3}; List<int[]> list = Arrays.asList(nums); // 只有一个元素,而且是整个数组

想要正确把int[]变成List<Integer>,可以用循环,或者用 Stream 装箱:

List<Integer> list = Arrays.stream(nums).boxed().collect(Collectors.toList());

反过来,List 转数组用toArray时,推荐的写法是传入一个长度为 0 的数组:

List<String> list = new ArrayList<>(Arrays.asList("a", "b")); String[] arr = list.toArray(new String[0]);

这里很多教程建议new String[list.size()],但在较新版本 JDK 中,传长度为 0 的数组往往性能更优,因为集合内部会根据实际大小重新分配最合适的数组,而传入过大的数组反而浪费空间。这个技巧在阿里巴巴 Java 开发手册里也有类似建议。

4.4 如何确定数组中哪些数据和等于固定值

热词里有一条很实用的问题:给定一列数和一个固定值,怎么找出数组中哪些数据相加等于这个固定值。这是典型的“组合求和”问题,在蓝桥杯、算法面试和数据分析场景中都出现过。

最直观的解法是回溯。先把数组排序,然后递归尝试每个数字,当前剩余值为 0 时输出一组结果。

import java.util.*; public class SubsetSum { public static void main(String[] args) { int[] arr = {1, 2, 3, 4, 5, 6}; int target = 7; Arrays.sort(arr); find(arr, target, 0, new ArrayList<>()); } private static void find(int[] arr, int remain, int start, List<Integer> path) { if (remain == 0) { System.out.println(path); return; } for (int i = start; i < arr.length; i++) { if (arr[i] > remain) break; // 跳过同一层的重复元素,避免结果重复 if (i > start && arr[i] == arr[i - 1]) continue; path.add(arr[i]); find(arr, remain - arr[i], i + 1, path); path.remove(path.size() - 1); } } }

这个思路利用了数组的一个核心优势:通过下标逐个访问数据。如果数组本身无序,先排序可以提前剪枝,因为升序数组里一旦当前数大于剩余值,后面的更大数必然也不可能加入组合。这类题在面试时很能考察候选人对递归、剪枝、数组遍历的理解,建议至少手写一遍。

5. 面试高频题与避坑记录:从原理到实战

5.1 经典高频题:深浅拷贝、== 与 equals

数组相关的面试题里,“浅拷贝和深拷贝”出现频率极高。我先给结论:基本类型数组的clone、Arrays.copyOf、System.arraycopy都是值拷贝,复制后两个数组互不影响。引用类型数组的复制则都是浅拷贝,复制出来的是“一组引用”,数组本身是新的,但里面的对象还是原来那一批。

class User { String name; User(String name) { this.name = name; } } User[] users = {new User("张三"), new User("李四")}; User[] copy = users.clone(); copy[0].name = "王五"; System.out.println(users[0].name); // 王五,原数组被影响了 copy[1] = new User("新对象"); System.out.println(users[1].name); // 李四,只是把 copy 的引用换掉了

如果需要深拷贝,不能依赖任何现成的数组复制方法,必须逐个创建新对象,把原对象的属性搬过去。在真实项目中,深层对象通常配合序列化或者复制构造函数实现,而不是简单靠数组操作。

另一个高频考点是数组的比较:

int[] a = {1, 2, 3}; int[] b = {1, 2, 3}; System.out.println(a == b); // false,比较的是引用地址 System.out.println(a.equals(b)); // false,Object 的 equals 底层也是 == System.out.println(Arrays.equals(a, b)); // true,比较的是元素内容

二维数组的比较要用Arrays.deepEquals,普通Arrays.equals无法正确比较嵌套数组的内容。这一点如果你刷过 LeetCode,应该深有体会,矩阵类题目判断结果时经常在这里翻车。

5.2 字符串数组过滤与字符校验

热词里有一组和字符串数组相关的需求:从一组字符串中筛选出包含指定字符的元素,或者判断字符串里有没有非字母数字。这类需求在日志清洗、关键词过滤、数据校验中很常见。

// 筛选包含指定字符的数组元素 String[] words = {"java", "python", "javascript", "php", "ruby"}; String keyword = "java"; Arrays.stream(words) .filter(w -> w.contains(keyword)) .forEach(System.out::println);
// 判断字符串是否包含非字母或非数字字符 String input = "abc123!@#"; char[] chars = input.toCharArray(); for (char c : chars) { if (!Character.isLetterOrDigit(c)) { System.out.println("非法字符: " + c); } }

把字符串转成char[]再逐字符判断,是这类问题最朴素的解法。Character.isLetterOrDigit能同时识别字母和数字,比手写 ASCII 范围判断更安全。如果要判断“整个字符串”是否符合规则,也可以用正则表达式,比如input.matches("[a-zA-Z0-9]+"),但正则的性能和可读性要根据实际场景取舍,字符数组遍历更适合逐字符给出提示。

5.3 二维数组的螺旋遍历与行列模拟

蓝桥杯和各种算法题里非常喜欢考察二维数组的“模拟题”,比如螺旋矩阵、顺时针遍历、八方向搜索。它们不涉及高级算法,拼的就是对行下标和列下标的控制。以“顺时针螺旋遍历”为例,思路是用四个边界变量收缩。

int[][] matrix = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} }; int top = 0, bottom = matrix.length - 1; int left = 0, right = matrix[0].length - 1; while (top <= bottom && left <= right) { for (int j = left; j <= right; j++) { System.out.print(matrix[top][j] + " "); } top++; for (int i = top; i <= bottom; i++) { System.out.print(matrix[i][right] + " "); } right--; if (top <= bottom) { for (int j = right; j >= left; j--) { System.out.print(matrix[bottom][j] + " "); } bottom--; } if (left <= right) { for (int i = bottom; i >= top; i--) { System.out.print(matrix[i][left] + " "); } left++; } }

这种题我建议亲手在纸上画一个 3x3 和 4x4 的矩阵,逐步推演边界的收缩过程。很多人在第四轮循环里忘记加top <= bottom和left <= right的判断,最后输出重复元素,在核心代码上出 bug。

5.4 常见异常与环境问题排查速查

数组相关的异常比较集中,我整理成一张速查表,方便排查时对照。

异常类型触发场景排查方向
ArrayIndexOutOfBoundsException下标越界,比如访问arr[length]检查循环条件、length-1边界
NullPointerException引用类型数组某个元素是 null,却直接调用方法访问元素前先判空
NegativeArraySizeExceptionnew int[-1],数组长度为负数长度来自外部输入时先校验
ArrayStoreException把错误类型对象存进引用类型数组检查类型转换和泛型相关操作
UnsupportedOperationExceptionArrays.asList返回的视图执行 add/remove重新new ArrayList包装一层

还有一些容易和环境混淆的启动问题,比如“Java 启动失败”。这类情况首先要查环境变量:确认JAVA_HOME指向 JDK 目录,PATH里包含%JAVA_HOME%\bin(Windows 上)或$JAVA_HOME/bin(Linux/macOS 上)。其次要确认当前项目编译和运行时的 JDK 版本一致,高版本编译的 class 文件放到低版本环境运行会报UnsupportedClassVersionError。如果启动时直接抛出数组相关异常,多数是入口处对命令行参数处理不当,比如args[0]在没有参数时被访问,改成先判断args.length再取值就好。

5.5 数组作为底层结构:树状数组思想

热词里有一条和树状数组有关:长度为 16 的序列,查询前缀和sum(11),单点修改add(3, x)。树状数组底层就是一个普通数组,只不过通过下标二进制规律来维护区间信息。

class FenwickTree { private int[] tree; FenwickTree(int n) { tree = new int[n + 1]; } void add(int index, int delta) { while (index < tree.length) { tree[index] += delta; index += index & -index; } } int sum(int index) { int res = 0; while (index > 0) { res += tree[index]; index -= index & -index; } return res; } }

调用sum(11)时,会逐层累加tree[11]、tree[10]、tree[8],最后得到原数组前 11 个元素的和。调用add(3, x)后,需要更新tree[3]、tree[4]、tree[8]、tree[16],保证后续前缀和查询能读到最新值。这里的核心思想是把“数组下标”和“二进制位运算”结合,用 O(log n) 时间完成单点修改和前缀和查询。数组在面试题中不只是“存放数据的容器”,它也能直接充当复杂数据结构的载体,理解这一点,你在看 JDK 源码或刷竞赛题时会更通透。

最后说一点我自己的体会。数组这种东西,平时用起来越顺手,越容易在边界和引用上翻车。我在项目里踩过最典型的坑,就是把一个二维数组直接赋值给另一个变量,然后修改其中一个子数组,结果原数组也跟着变了,原因正是没意识到二维数组的每一行其实都是引用。后来我给自己定了一条规矩:凡是涉及数组整体赋值、复制或者跨方法传递,先问自己一句,这里要的是共享引用还是独立副本。养成这个习惯之后,很多隐蔽 bug 都能在写代码阶段被挡掉。如果你正在补 Java 基础,建议别只背结论,把每一个数组操作都跑一遍,再用调试器观察内存变化,理解会扎实很多。

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

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

立即咨询