
数组这东西初看简单不过是一排同类型的格子往里面塞数据就行。但真往深了说Java数组牵扯到的内存布局、类型机制、工具类使用、甚至面试里常考的算法题每一层都有值得掰开揉碎讲清楚的地方。我写这篇文章的缘由就是因为见过太多初学者在数组上栽跟头——初始化方式选错、遍历越界、拷贝不到位、对象数组复制只拷了个引用、二维数组当矩阵却发现行数列数搞混。这篇东西我会结合实际场景和完整代码案例把Java数组从底层原理到高频实战全过一遍适合刚学Java基础的人巩固理解也适合准备Java面试的人用来查漏补缺。看完不说让你立刻成为数组专家但至少再遇到数组相关的问题能有一套清晰的排查思路和可复用的代码模板。1. 先搞懂数组的本质定长容器、对象身份与连续内存1.1 数组不是变量集合它是一个真正的对象很多教材喜欢把数组比喻成一排带编号的抽屉这个比喻没有错但它容易让人忽略一个关键事实在Java里数组是一个对象。这意味着什么呢第一数组是引用类型你声明的数组变量保存的是堆内存中数组对象的地址而不是数据本身。第二数组对象有自己的属性最典型的就是length。第三数组对象继承自Object所以toString()、equals()在数组上表现得很奇怪——直接打印数组变量输出的是一串[I1b6d3586这样的地址信息这就是因为数组没有重写Object.toString()。来看一段最基础的代码public class ArrayDemo { public static void main(String[] args) { int[] numbers new int[5]; System.out.println(numbers); // [I1b6d3586这是对象标识不是内容 System.out.println(numbers.length); // 5length是属性不是方法 } }注意numbers.length不要写成numbers.length()这是新手非常高频的笔误。length是数组对象的属性而不是String那样调用length()方法。数组对象创建之后长度就固定了。这在设计上是一个硬约束也是后面所有定长局限问题的根源。如果你需要一个自动扩容、随意增删元素的容器那就得切换到ArrayList这类动态数组这点我会在第四章详细展开。1.2 连续内存与O(1)随机访问数组的性能底气数组对象在堆内存中占据的是一段连续的空间每个元素的大小是固定的默认值也会被初始化。正是这种连续性让数组拥有了一个极其重要的特性随机访问的时间复杂度是O(1)。也就是说要取arr[index]JVM不需要遍历前面所有元素而是直接通过起始地址 index × 单个元素大小算出目标内存地址。这就像你在书架上按编号取书只要知道书架的起始位置和每本书的厚度直接走过去抽就行了不需要一本一本地数。这个特性决定了数组在遍历、读取、按索引修改等操作上性能极强。但相应的代价是在数组中间插入或删除元素很难受因为需要批量移动后续元素。所以如果你需要频繁在序列中间做插入删除操作链表结构通常是更好的选择。这也是面试里常问数组和链表的区别的核心落点。1.3 数组的协变特性和运行时类型检查Java的数组有一个微妙的设计叫数组协变Array Covariance。简单说如果Integer是Number的子类那么Integer[]也是Number[]的子类型。泛型没有这个特性但数组保留了它。Number[] nums new Integer[10]; // 编译通过因为Integer[]是Number[]的子类型 nums[0] 3.14; // 编译能通过但运行时会抛ArrayStoreException这段代码能编译但运行到第二行就会炸。因为JVM在运行时知道nums底层真实是Integer[]往里面存Double显然违背类型约束。这算是一个历史设计遗留但也提醒我们不要把数组协变当成可以随意混装类型的借口运行时检查这道防线一直在盯着你。2. 初始化与遍历的细节一句代码背后藏了哪些选择2.1 静态初始化与动态初始化什么时候用哪一种数组初始化有两条路第一条是静态初始化你在写代码时就知道具体值int[] scores {88, 92, 77, 100, 65}; String[] names {张三, 李四, 王五};第二条是动态初始化你先指定长度让JVM填充默认值后续再赋值int[] scores new int[10]; scores[0] 88; scores[1] 92;这两种方式不是随手选的。静态初始化适合数据集合明确、不会中途变化的场景比如配置项、固定枚举值列表等。动态初始化则更适合你需要根据运行时条件决定数组大小的情况比如读取用户输入的商品数量再创建对应大小的数组去承载数据。这里有个值得注意的细节每一种基本类型都有默认值。整型是0浮点型是0.0字符型是\u0000布尔型是false引用类型是null。int[] a new int[3]; boolean[] b new boolean[2]; String[] c new String[2]; System.out.println(Arrays.toString(a)); // [0, 0, 0] System.out.println(Arrays.toString(b)); // [false, false] System.out.println(Arrays.toString(c)); // [null, null]引用类型的数组初始值是null这意味着你访问c[0].length()之前必须手动赋值否则空指针异常在等着你。2.2 三种遍历方式的取舍普通for、增强for、Stream遍历数组最常见的是普通for循环和增强for循环// 普通for需要索引时使用 for (int i 0; i arr.length; i) { System.out.println(index i , value arr[i]); } // 增强for只读遍历时推荐 for (int num : arr) { System.out.println(num); }普通for的优势是你能拿到索引这在需要修改当前位置元素、或者根据索引做条件判断时必不可少。增强for写起来简洁但它本质上是在迭代器或索引上做的语法糖你无法通过它直接修改数组元素的值——你拿到的只是一个局部变量副本。Java 8之后还可以用Stream风格处理数组Arrays.stream(arr) .filter(x - x 60) .forEach(System.out::println);这种写法的好处是链式表达语义清晰尤其适合过滤、映射、聚合这类组合操作。但要注意如果只是简单遍历Stream的装箱和流水线开销反而比普通for更重追求极致性能的场景我一般不用Stream。2.3 数组拷贝的正确姿势System.arraycopy还是Arrays.copyOf直接赋值int[] arr2 arr1并不是拷贝它只是让两个变量指向同一个数组对象。你改arr2[0]arr1[0]跟着变因为它们本来就是同一个对象。真正拷贝数组常见有两种方式// 方式一System.arraycopyJDK提供的最底层批量拷贝方法 int[] src {1, 2, 3, 4, 5}; int[] dest new int[5]; System.arraycopy(src, 0, dest, 0, src.length); // 方式二Arrays.copyOf内部也是调System.arraycopy int[] copy Arrays.copyOf(src, src.length);两者的差别在于Arrays.copyOf会创建一个新数组并返回适合拷贝出一个独立数组的场景System.arraycopy则允许你指定原数组的起始位置、目标数组的起始位置以及拷贝长度适合在已有目标数组上做部分拷贝。对象数组拷贝这里藏着一个大坑这两者都是浅拷贝。对于引用类型的数组拷贝出来的新数组里存的还是同一个对象的地址。如果修改某个对象的属性新旧数组看到的状态都会变化。真要深拷贝你得自己循环遍历逐个clone或者手动new一个新对象塞进去。3. 案例实战一排序、去重、转字符串五个高频场景的代码拆解3.1 冒泡排序手写与Arrays.sort的对比面试里手写排序是经典环节。冒泡排序虽然性能一般但它能直观展示你对排序过程的理解public static void bubbleSort(int[] arr) { if (arr null || arr.length 2) { return; } for (int i 0; i arr.length - 1; i) { boolean 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; // 已经有序提前结束 } } }我在手写时一般会加一个swapped标记如果某一轮完全没有交换说明数组已经有序直接跳出循环。这个优化能应对部分有序的输入也是面试官期待看到的细节之一。实际开发中大部分场景直接用Arrays.sort(arr)就够了。这个方法对基本类型数组使用快速排序双轴快排对对象数组使用稳定的归并排序TimSort。稳定性在某些场景很重要如果你的对象有多个排序字段稳定排序能保留第一次排序后的相对顺序。3.2 数组去重三套方案三个复杂度数组去重的需求很常见热搜词里也频繁出现。我给出三种方案// 方案一LinkedHashSet最简单保持插入顺序 Integer[] arr {3, 1, 4, 1, 5, 9, 3}; LinkedHashSetInteger set new LinkedHashSet(Arrays.asList(arr)); Integer[] unique set.toArray(new Integer[0]); System.out.println(Arrays.toString(unique)); // [3, 1, 4, 5, 9] // 方案二双重循环O(n^2)不引入额外空间 public static int[] uniqueByLoop(int[] arr) { int n arr.length; int[] unique new int[n]; int size 0; for (int i 0; i n; i) { boolean exists false; for (int j 0; j size; j) { if (unique[j] arr[i]) { exists true; break; } } if (!exists) { unique[size] arr[i]; } } return Arrays.copyOf(unique, size); } // 方案三Java 8 Stream一行搞定 int[] result Arrays.stream(arr) .distinct() .toArray();LinkedHashSet方案是最推荐的生产级写法因为HashSet的去重时间复杂度是O(n)同时LinkedHashSet还能保证元素顺序和原数组一致。Stream.distinct()本质上也依赖HashSet。有一点要注意基本类型数组不能用Arrays.asList(arr)直接转成ListInteger。你在代码里看到Arrays.asList(arr)如果传的是int[]得到的是Listint[]里面只有一个元素。正确做法是先包装成Integer[]或者直接用Stream。3.3 数组转字符串别再用循环拼接了数组转字符串这块新手最常见的就是自己写for循环加StringBuilder拼StringBuilder sb new StringBuilder(); for (int i 0; i arr.length; i) { if (i 0) sb.append(, ); sb.append(arr[i]); }这段代码本身没问题但没必要。Arrays.toString(int[])直接拥有标准输出格式int[] arr {1, 2, 3, 4, 5}; System.out.println(Arrays.toString(arr)); // [1, 2, 3, 4, 5]如果你希望输出成a,b,c这样无方括号的分隔符格式可以用Java 8的StringJoiner或者String.join注意String.join只接受CharSequence类型基本类型数组需要先转成字符串流Integer[] arr {1, 2, 3, 4, 5}; String joined Arrays.stream(arr) .map(String::valueOf) .collect(Collectors.joining(,)); System.out.println(joined); // 1,2,3,4,53.4 数组反转与元素循环移动数组反转是个基础操作思路是双指针从两端向中间交换public static void reverse(int[] arr) { int left 0, right arr.length - 1; while (left right) { int temp arr[left]; arr[left] arr[right]; arr[right] temp; left; right--; } }这里要注意边界条件left right而不是left right。如果数组长度为偶数左右指针会交叉越过用会导致中间两个元素被交换两次等于换了个寂寞。循环移动比如把数组整体右移k位则是更进阶的玩法经典算法是三次反转先反转整个数组再反转前k个再反转剩余部分。这个思路如果直接看代码可能觉得绕但手推一遍就明白了——它本质上利用了反转可以局部保留顺序的特性。3.5 求连续子数组最大和从暴力到Kadane算法这个题目在面试题里出现频率极高同时也是数组算法价值的最佳体现。题目是给定一个整数数组找出其中一个连续子数组使得该子数组的和最大返回最大和。最朴素的做法是三重循环枚举所有子区间O(n^3)基本不可用。好一点的是前缀和O(n^2)依然不够。正确的打开方式是Kadane算法一次遍历搞定public static int maxSubArraySum(int[] nums) { if (nums null || nums.length 0) { throw new IllegalArgumentException(数组不能为空); } int currentMax nums[0]; int globalMax nums[0]; for (int i 1; i nums.length; i) { currentMax Math.max(nums[i], currentMax nums[i]); globalMax Math.max(globalMax, currentMax); } return globalMax; }核心思想是每一步都在从当前元素重新开始和把当前元素接续到之前的累加和上之间做选择。如果之前的累加和是负数那接上去只会拖累当前元素不如从nums[i]重新开始。比如数组{-2, 1, -3, 4, -1, 2, 1, -5, 4}手动跑一遍这套逻辑最终全局最大和是6子数组{4, -1, 2, 1}。这就是Kadane算法精妙的地方它没有显式记录子数组的位置却通过局部最优的累积最终得到了全局最优子数组的和。这属于动态规划思想在数组上的经典应用理解了这一题后面很多子数组类问题你都能触类旁通。4. 案例实战二二维数组、ArrayList与数组的互相转换4.1 二维数组的本质数组的数组二维数组在Java里并不是一个真正的矩形内存块它是数组的数组——外层数组的每个元素本身又是一个数组对象的引用。这就是为什么你可以写出锯齿状数组int[][] matrix new int[3][]; matrix[0] new int[5]; matrix[1] new int[2]; matrix[2] new int[7];上面这段代码是合法的每个内层数组长度不同。如果你需要的是规则的矩阵语法上更直接的方式是int[][] matrix new int[3][4]; // 3行4列访问时matrix[row][col]matrix.length是行数matrix[0].length是第一行的列数。这里我强调一个高频错误不要把行和列搞反。在双重循环里外层变量控制行内层变量控制列一旦顺序反了轻则数组越界重则逻辑错误但又不报错非常棘手。打印二维数组不要用Arrays.toString那只会打出来一串地址要用Arrays.deepToString(matrix)int[][] matrix {{1, 2}, {3, 4}}; System.out.println(Arrays.deepToString(matrix)); // [[1, 2], [3, 4]]4.2 实战案例实现一个矩阵转置矩阵转置就是把matrix[i][j]变成result[j][i]。写这个案例不复杂但它能强迫你想清楚行列关系public static int[][] transpose(int[][] matrix) { if (matrix null || matrix.length 0) { return matrix; } int rows matrix.length; int cols matrix[0].length; int[][] result new int[cols][rows]; for (int i 0; i rows; i) { for (int j 0; j cols; j) { result[j][i] matrix[i][j]; } } return result; }注意一个细节matrix[i][j]对应的new int[cols][rows]行列数对调了。很多人在这一步直接写反结果转置完形状不对。不想背公式的话就记住一个检验方法转置两次应该得到原矩阵。写完之后用Arrays.deepToString打印测试一下int[][] original {{1, 2, 3}, {4, 5, 6}}; int[][] transposed transpose(original); System.out.println(Arrays.deepToString(transposed)); // [[1, 4], [2, 5], [3, 6]]original是2行3列转置后是3行2列一切正常。4.3 动态数组ArrayList和数组的互转toArray方法的坑数组定长日常开发里更常用的是ArrayList。两者互转是高频操作。List转数组推荐使用list.toArray(new String[0])注意是传入一个长度为0的数组而不是new String[list.size()]。ListString list new ArrayList(Arrays.asList(a, b, c)); String[] array list.toArray(new String[0]); System.out.println(Arrays.toString(array)); // [a, b, c]为什么推荐传0长度因为在现代JDK中这个方法会根据集合大小重新分配数组传0长度反而能简化内部判断而传完整长度的数组如果集合不是在并发环境下被操作其实也是一种常见写法两者性能差异微乎其微。但toArray()无参版本返回的是Object[]如果你直接强转String[]会抛出ClassCastException这算是最容易踩的坑。数组转ListString[] array {a, b, c}; ListString list Arrays.asList(array);这里有个非常重要的坑Arrays.asList返回的List是定长视图直接修改结构add/remove会抛UnsupportedOperationException。如果你想得到一个真正可增删的ArrayList要再包一层ListString list new ArrayList(Arrays.asList(array));而且前面说过的基本类型数组问题在互转时依然存在int[]无法直接转成ListInteger需要流式处理int[] array {1, 2, 3}; ListInteger list Arrays.stream(array) .boxed() .collect(Collectors.toList());4.4 数组扩容与手动实现ArrayList的add逻辑理解了ArrayList自动扩容的原理你会对动态数组有更深的认识。它的内部其实就是一个Object[] elementData当元素数量达到数组容量上限时会创建一个更大容量的新数组然后把旧元素拷贝过去。如果让你模拟最简单的扩容逻辑代码长这样public class SimpleArrayBox { private int[] data; private int size; public SimpleArrayBox(int capacity) { this.data new int[capacity]; this.size 0; } public void add(int value) { if (size data.length) { int[] newData new int[data.length * 2]; System.arraycopy(data, 0, newData, 0, data.length); data newData; } data[size] value; } }注意这里扩容倍数是1.5倍还是2倍不同实现有差异。ArrayList的扩容逻辑是新的容量大致为oldCapacity (oldCapacity 1)也就是1.5倍左右。这个设计的目的是在扩容次数和空间浪费之间取得平衡——扩太少导致频繁拷贝扩太多浪费内存。写这段代码并不是让你去重造轮子而是帮你理解动态数组为什么建议预估初始容量如果你知道最终会有1000个元素直接new ArrayList(1000)就能省掉中间多次扩容和数组拷贝的开销。5. 踩过的坑与排查思路数组越界、泛型冲突、对象拷贝5.1 ArrayIndexOutOfBoundsException的完整排查链路数组越界是Java初学者最容易撞上的异常没有之一。它的排查其实有个固定链路第一步确认报错信息。异常堆栈会明确告诉你数组下标越界发生在哪个文件哪一行以及合法的索引范围。比如Index 5 out of bounds for length 5说明你试图访问索引5但合法范围是0到4。第二步检查循环条件。最常见的错误是把i arr.length写成边界for (int i 0; i arr.length; i) { System.out.println(arr[i]); // 最后一次循环arr[arr.length]越界 }正确写法永远是i arr.length。这是我的老生常谈但几乎每周都有同学犯这个错。第三步检查索引变量是否被偏移。比如你用一个index变量去访问数组却在某个分支里对它做了index或者index index - 1最好在访问前后各打印一次索引一眼就能看出问题。还有一种隐蔽情况二维数组的列数不一致锯齿数组你固定用matrix[0].length当所有行的列数当某一行更短时就会越界。遇到不确定行长度的情况优先使用matrix[row].length。5.2 数组与泛型的矛盾为什么不能创建泛型数组Java泛型通过类型擦除实现泛型信息在运行时是不存在的。而数组在运行时携带自己的元素类型信息这就是之前说过的ArrayStoreException的来源。两者直接冲突所以语言层面禁止你写new T[]或者new ListString[10]。实际开发中如果确实需要泛型数组通常有两个替代方案第一个方案是用ArrayList替代数组在集合层面保留泛型ListString[] lists new List[10]; // 裸类型警告不推荐 // 更推荐的做法直接用ListListString第二个方案是通过反射创建泛型数组。比如public static T T[] newArray(ClassT clazz, int length) { return (T[]) Array.newInstance(clazz, length); }Array.newInstance是反射中创建数组的方式它能在运行时根据Class对象创建对应类型的数组。这种方式在日常业务代码里不常见主要活跃在底层框架和工具库中。5.3 对象数组的浅拷贝深拷贝引用复制不等于对象复制我前文提过一次浅拷贝这里必须展开成为一个专门的坑位。先看这个案例class Person { String name; Person(String name) { this.name name; } } Person[] teamA {new Person(张三), new Person(李四)}; Person[] teamB Arrays.copyOf(teamA, teamA.length); teamB[0].name 王五; System.out.println(teamA[0].name); // 王五teamB[0]和teamA[0]指向同一个Person对象通过teamB改动对象属性teamA也会看到。这不是bug而是Java对象模型的固有逻辑。copyOf只是把引用从旧数组复制到新数组引用指向的对象本身没有被复制。如果你需要完全独立的副本要么让Person实现Cloneable并重写clone()要么循环创建新对象Person[] teamC new Person[teamA.length]; for (int i 0; i teamA.length; i) { teamC[i] new Person(teamA[i].name); // 手动深拷贝 }写业务代码时要根据是否希望两者共享可变状态来决定采用浅拷贝还是深拷贝。这个判断如果做错了排查起来往往需要好几个小时因为问题不会立刻暴露而是等某个状态下被悄悄改掉才浮现。5.4 排序稳定性与对象比较的另一处细节最后一个容易忽略的细节Arrays.sort对基本类型数组用的是快速排序不是稳定排序对对象数组用的是归并排序TimSort是稳定排序。什么时候稳定性会变成关键比如你有Person[]先按年龄排好序再按姓名排序稳定排序会保证姓名相同的元素之间年龄的相对顺序和第一次排序保持一致。如果你的排序多次进行、字段有优先级那稳定性就是硬需求。如果你觉得默认排序不满足需求可以传入自定义ComparatorPerson[] people ...; Arrays.sort(people, Comparator.comparing(Person::getName) .thenComparing(Person::getAge));这里链式的写法多字段排序的优先级一目了然比手写复杂的Comparator匿名类要清晰得多。收尾前再补三个实用习惯我平时处理数组相关代码时还有几个已经养成肌肉记忆的习惯分享出来供你参考。第一数组工具类首选Arrays。排序、二分查找、填充、比较、转字符串、copyOf这个类几乎覆盖了数组的日常操作需求。不要自己造轮子遍历查找最大值Arrays.parallelPrefix之类的进阶方法甚至能玩出函数式编程的质感。第二创建数组时先问自己要多大。能预估容量就一次性给足避免ArrayList反复扩容。如果数组大且不确定容量优先用ArrayList而不是自己手动重新分配数组。第三调试输出用Arrays.toString或deepToString并且注意在修改变量前打印一次变更前快照。数组相关的很多谜之bug其实都是因为某个引用在别处被悄悄修改了。多打印几个点你真能看到真相。数组在Java世界里好像是入门第一课但认真挖下去它的内存模型、类型机制、工具类方法和算法延伸足够你消化很久。希望这篇文章能帮你把这些散落的知识点串起来以后遇到数组的问题心里能有一张清晰的地图。