
最近带一个刚转Java的朋友入门聊到数组的时候我发现一个很有意思的现象很多人在网上看了不少教程知道怎么new一个数组怎么写for循环遍历但真到面试、刷题或者自己写工具类的时候遇到数组拷贝、二维数组、数组去重、数组整体移位这些问题还是会卡壳。原因很简单——数组在Java里太基础了基础到很多人懒得深挖但它又是 JavaSE 阶段绕不开的一块基石几乎所有集合框架、算法题、底层源码里都有它的影子。这篇博文我就把 JavaSE 里关于数组的知识点完整梳理一遍从内存模型讲到高频操作从最常见的坑讲到对应的算法思路最后聊到什么时候该从数组切换到ArrayList。不管是刚学Java的新手还是准备面试的求职者又或者是带新人的老手想找一份讲解思路这篇文章都值得你花二十分钟认真看完。内容全部基于我实际写代码、带新人、刷题过程中的真实经验不是那种照搬官方文档的翻译稿。1. 数组的底层本质为什么它是一块连续内存又是怎么实现随机访问的1.1 数组在Java里不是基本类型而是一个对象我在给新人讲数组的时候第一句话永远是数组在Java中是对象但它是一种非常特殊的对象。很多初学者会误以为int[]是基本类型其实不是。JVM在运行时会为数组专门生成对应的类int[]有它自己的Class对象甚至可以通过arr.getClass()拿到运行时类型。既然是对象数组的内存分配自然就在堆上。你写int[] arr new int[5];的时候实际上干了两件事栈上的引用变量arr指向堆上的一块连续内存这块内存被划分为5个相邻的“格子”每个格子的大小刚好能放下一个int4个字节。正因为格子是紧挨着的、每个格子大小一致数组才能做到O(1)时间复杂度的随机访问——想读第3个元素直接按“起始地址 3 × 每个元素大小”算出内存地址去取就行。相比之下链表只能从头开始一个个找这就是数组最核心的性能优势。这也解释了为什么数组要求所有元素类型必须一致。格子大小都一样地址才能用乘法直接算出来。你不可能在一块内存里既放int又放String因为JVM根本没法确定该给这个格子分配多大空间。1.2 为什么数组下标从0开始而不从1开始这个问题看起来很“常识”但真的值得停下来想想。假如每个元素占size个字节数组起始地址是baseAddress那第i个元素的地址公式是address[i] baseAddress i × size如果下标从1开始公式就变成了address[i] baseAddress (i - 1) × sizeC语言时代的设计者发现后一种写法每次访问元素都要多做一次i - 1的减法运算对性能敏感的系统来说这是纯浪费。所以下标从0开始是数学上的最优解Java沿用了这个设计。理解了这一点你也就明白了为什么数组的合法下标范围是0到length - 1而不是1到length——这不是什么随意的约定而是内存寻址模型的自然结果。1.3 数组的 length 是属性而不是方法还有一个经常被新手忽略的细节String获取长度用的是length()方法数组获取长度用的是.length属性。很多从其他语言转过来的朋友第一天就栽在这上面把arr.length()挂在嘴边。数组的length是在创建数组时就固定下来的一个final字段它记录了这个数组到底能装多少个元素不可以用arr.length 10这种方式去修改这是“数组定长”在语法层面的直接体现。2. 声明、创建和初始化看似一样的三种写法其实各有用武之地2.1 声明只是告诉编译器这是一个数组类型的引用Java里声明数组有两种风格int[] arr1; // 推荐 int arr2[]; // C语言风格Java兼容但不推荐int[] arr1;这行代码并不会真的创建数组它只是声明了一个类型为int[]的引用变量此时arr1的值是null。真正分配内存的是new关键字arr1 new int[5];我见过不少新手把“声明”和“创建”混为一谈写int[] arr new int[5];时以为arr一瞬间就有了5个“有意义的数字”但实际上那5个格子只是有了默认值后面会细说。2.2 三种初始化方式到底怎么选下面三种写法初学者最容易混淆int[] a new int[5]; // 动态初始化只指定长度 int[] b {1, 2, 3, 4, 5}; // 静态初始化声明的同时赋值 int[] c new int[] {1, 2, 3, 4, 5}; // 匿名数组式的静态初始化区别在哪里a适用于你暂时还不知道具体值、只想先占一块空间的场景比如后面要从数据库分批读取数据填进去b适用于元素值写死在代码里的场景比如定义一周的七天、一年的十二个月份c则多用于匿名数组的场景比如直接传给方法printArray(new int[] {1, 2, 3});我特别提醒一点b这种写法只能在声明的同时使用你不能先写int[] b;再写b {1, 2, 3};编译器直接报错。如果非要分两步就必须用new int[]的完整写法。这个限制很像我们生活中的充电桩——你可以买车的时候就选装好电池但如果车先提回来想再加电池就必须走另一套加装流程而不能直接把电池塞进去。我把三种方式的适用场景整理成了一张表写法适用场景注意事项new int[5]先占空间后续填入数据元素是默认值{1, 2, 3}值固定且声明时就能确定只能声明时使用new int[] {1, 2, 3}匿名数组、方法传参可脱离声明单独使用2.3 数组元素的默认值一个“看不见”但处处都在的坑Java的数组一旦创建每个元素都会自动初始化成一个默认值和局部变量的“不赋值就不能用”规则完全不同。具体默认值如下类型默认值int/byte/short/long0float/double0.0char\u0000空字符booleanfalse引用类型如String[]、Object[]null这个特性用好了很省事比如统计每个字符出现次数时直接int[] count new int[26];就能当计数器用不用手动一个个初始化为0。但如果没意识到这一点就容易踩坑比如你new了一个String[]以为数组是空的可以直接用结果每个元素都是null一调用equals()就抛NullPointerException。我在实际代码评审里见过不少这种事故都不是什么高深问题就是基础概念没打通。3. 数组赋值的“坑”你复制的是引用而不是整块数据3.1 直接赋值只是让两个变量指向同一个数组很多人学数组的时候都会问一个问题“Java里怎么把一个数组赋值给另一个数组” 最简单的答案就是直接int[] b a;但这里隐藏着一个巨大的误解——这行代码根本没有复制数组里的任何元素它只是把a里存放的“地址值”复制给了b。我用一个比喻来帮新人理解数组对象就像一栋楼a和b是两张写有同一栋楼地址的纸条。你拿着纸条 B 去改楼里某一层的装修楼本身变了你再拿纸条 A 去看看到的当然是改过的样子。所以int[] a {1, 2, 3}; int[] b a; b[0] 100; System.out.println(a[0]); // 输出 100这不是Bug是引用语义的正常行为。很多线上事故恰恰是因为有人不知道这一点觉得b是a的副本在方法里改了b结果污染了原数据。3.2 想要真正的副本有三种靠谱方法如果确实需要复制出一份独立的数据通常的做法有三种int[] source {1, 2, 3, 4, 5}; // 方式一循环复制 int[] target1 new int[source.length]; for (int i 0; i source.length; i) { target1[i] source[i]; } // 方式二System.arraycopy int[] target2 new int[source.length]; System.arraycopy(source, 0, target2, 0, source.length); // 方式三Arrays.copyOf最推荐 int[] target3 Arrays.copyOf(source, source.length);三种方式底层做的事基本一样都是把一块连续内存里的数据搬到另一块内存。区别在于循环复制最原始适合需要边复制边处理额外逻辑的场景比如只复制偶数位置。System.arraycopy本地方法性能最好但是参数多新手容易写错下标。Arrays.copyOf底层调用的也是arraycopy但API更友好还可以顺便扩容。此外还有一个clone()方法int[] target4 source.clone();对于一维数组来说clone()也能完成复制任务而且代码最短。但要注意它返回的是Object类型需要强转更关键的是对于二维数组这种“数组的数组”clone()只复制了外层数组的引用内层数组还是共享的这就是所谓的“浅拷贝”陷阱后面会专门讲。3.3 把数组传给方法方法内修改会影响外部数组作为方法参数传递时传的同样是引用。这意味着你在方法内部修改元素外部数组也会跟着变public class ArrayParamDemo { public static void main(String[] args) { int[] data {1, 2, 3}; changeFirst(data); System.out.println(data[0]); // 输出 99 } static void changeFirst(int[] arr) { arr[0] 99; } }这个特性和基本类型参数完全不同基本类型是值传递方法是影响不到外部的。很多面试题喜欢在这里做文章问“Java是值传递还是引用传递”标准答案是“Java只有值传递但引用类型传的是引用的副本”数组正好是理解这句话的最好案例。如果你不想让方法内部修改原数组就把Arrays.copyOf之后的副本传进去或者要求方法内部只读不写从设计上直接规避。4. 二维数组与不规则数组谁说表格必须方方正正4.1 二维数组的真实结构是“数组的数组”二维数组在Java里并不是一个矩阵形的内存块它的本质是一个一维数组数组里的每个元素又是一个一维数组。你可以用下面的方式创建int[][] matrix new int[3][4];这行代码在内存中干了什么事它先创建了一个长度为3的外层数组外层数组的每个元素是int[]类型的引用然后依次创建了3个长度为4的内层数组。结构上更像是一个“存放了三个盒子的盒子”而不是一个均匀的网格。访问matrix[1][2]时先通过外层数组拿到第1行内层数组的引用再从这个内层数组里取下标2的元素。这也是为什么二维数组遍历的顺序很重要——按行进和按列进的性能不一样因为按行访问时内存地址是连续的CPU缓存的命中率更高。4.2 不规则数组每行的长度可以不一样Java的二维数组有个很实用的特性内层数组的长度不要求一致。你可以创建一个“三角形”数组int[][] triangle new int[4][]; for (int i 0; i triangle.length; i) { triangle[i] new int[i 1]; }这样的triangle第0行长度是1第1行长度是2以此类推。这种不规则数组在做杨辉三角、按需存储不同长度数据的时候非常好用。遍历时注意每个内层数组都有自己的length所以循环条件写triangle[i].length而不是triangle[0].length。还有一个很常见的坑new int[4][]只创建了外层数组内层数组全是null如果直接写triangle[0][0] 1会抛NullPointerException。必须先把每一行的内层数组创建出来才能往里填数据。这个和“先有房本再有房”是一个道理外层只是挂了四个门牌号真正的房间还没盖。4.3 二维数组的拷贝要格外小心浅拷贝前面提到clone()对二维数组是浅拷贝这里展开看看。假设int[][] source {{1, 2}, {3, 4}}; int[][] copy source.clone(); copy[0][0] 100; System.out.println(source[0][0]); // 输出 100copy确实是一个新的外层数组但它的元素内层数组的引用和source是共享的。你改了copy[0][0]实际上改的是同一个内层数组里的元素source自然跟着变。要想实现深拷贝需要手动把每一行都复制一遍int[][] deepCopy new int[source.length][]; for (int i 0; i source.length; i) { deepCopy[i] Arrays.copyOf(source[i], source[i].length); }如果是三维、四维数组每一层的复制都要自己处理。所以我的建议是超过二维的话就别再用数组硬扛了改用List嵌套或者自己封装一个类否则深浅拷贝和遍历都会非常痛苦。5. 数组与工具类搭配排序、查找、转字符串、去重一次讲透5.1 Arrays 工具类的高频操作java.util.Arrays是数组操作的核心工具类我列几个日常使用频率最高的方法以及它们各自的注意点int[] arr {5, 3, 1, 4, 2}; Arrays.sort(arr); // 原地排序arr变成 [1, 2, 3, 4, 5] int index Arrays.binarySearch(arr, 3); // 返回2前提是数组已排序 System.out.println(Arrays.toString(arr)); // 输出 [1, 2, 3, 4, 5] int[] filled new int[10]; Arrays.fill(filled, 7); // 全部填成7常用于初始化 boolean equal Arrays.equals(new int[]{1,2}, new int[]{1,2}); // truebinarySearch有个坑必须单独提使用前必须保证数组已经按升序排好否则结果是不确定的。底层用的是二分查找在一个无序数组上做二分就像在一本乱序字典里找单词根本没法准确定位。此外如果数组里有多个相同的目标值binarySearch返回哪一个是不确定的别依赖它来找“第一个出现的位置”。对于对象数组Arrays.sort默认要求元素实现Comparable接口或者你传一个Comparator进去。比如对String[]排序默认是按字典序对自定义对象排序就需要自己实现比较逻辑。5.2 数组转字符串的几种思路把数组打印出来是调试时最频繁的操作。新手直接用System.out.println(arr)会发现输出一个“[I1b6d3586”样子的东西那是数组对象的类名和哈希值不是内容。想看内容有三种常见方式int[] arr {1, 2, 3}; // 第一种Arrays.toString System.out.println(Arrays.toString(arr)); // [1, 2, 3] // 第二种StringBuilder 手动拼接 StringBuilder sb new StringBuilder(); for (int i 0; i arr.length; i) { if (i 0) sb.append(, ); sb.append(arr[i]); } // 第三种StringJoinerJDK 8 StringJoiner sj new StringJoiner(, , [, ]); for (int num : arr) { sj.add(String.valueOf(num)); } // 输出也是 [1, 2, 3]Arrays.toString最省事但它输出的格式是固定的[1, 2, 3]如果对格式有特殊要求比如 “1-2-3” 或 “1|2|3”就需要自己拼。这里我建议用StringJoiner或者 Java 8 的String.join代码比StringBuilder更简洁可读性也更好。5.3 数组去重基本类型和对象类型要分开看数组去重是热搜里非常高频的问题。先说基本类型数组最常见、也最不容易出错的做法是利用LinkedHashSet因为它可以在去重的同时保持元素的原始顺序int[] arr {1, 2, 2, 3, 1, 4}; SetInteger set new LinkedHashSet(); for (int num : arr) { set.add(num); } int[] distinct set.stream().mapToInt(Integer::intValue).toArray(); // distinct 是 [1, 2, 3, 4]如果不借助集合类也可以用双重循环int[] arr {1, 2, 2, 3, 1, 4}; int[] temp new int[arr.length]; int count 0; for (int i 0; i arr.length; i) { boolean duplicate false; for (int j 0; j count; j) { if (temp[j] arr[i]) { duplicate true; break; } } if (!duplicate) { temp[count] arr[i]; } } int[] distinct Arrays.copyOf(temp, count);双重循环的时间复杂度是O(n^2)数据量大的时候明显变慢但好处是不依赖额外的数据结构面试时用来体现基础扎实很合适。重点说一下对象数组去重。光靠Set不够Set判断两个对象是否相同靠的是equals()和hashCode()两个方法。如果你的自定义类没有重写这两个方法那么默认是比较引用地址两个字段一模一样的对象也会被认为是“不同”的去重自然就失效了。所以写对象数组去重之前先确认类里是否重写了equals和hashCode这是一切去重逻辑的前提。6. 从数组到算法思维两个高频题型的完整拆解6.1 数组整体左移k位别真的“一次移一位”热搜里有“数组整体左移k位每次移动k位”这几乎算是数组操作里最经典的入门算法题。最淳朴的思路是循环k次每次把第一个元素挪到最后其他元素依次前移一位。但这样做的复杂度是O(k × n)当数组很大而且k也很大时性能很差。更聪明的做法是反转法核心思想是“把数组看成一个环”要左移k位可以先把前k个元素反转再把剩余部分反转最后整个数组反转。写成代码就是public static void rotateLeft(int[] arr, int k) { int n arr.length; if (n 0) return; k k % n; // 关键k可能大于数组长度取模后才是实际位移 reverse(arr, 0, k - 1); reverse(arr, k, n - 1); reverse(arr, 0, n - 1); } private static void reverse(int[] arr, int start, int end) { while (start end) { int temp arr[start]; arr[start] arr[end]; arr[end] temp; start; end--; } }为什么k k % n这一步很重要因为左移n位等于没移左移n 1位等于左移1位。不取模的话当 k 超过数组长度时反转的分界点会出错甚至抛异常。我见过很多人在这一步栽跟头笔试做对了80%结果忘记取模导致越界。反转法的时间复杂度只有O(n)而且不占用额外空间是面试官最喜欢看到的解之一。你可以在脑子里模拟一遍{1,2,3,4,5}k2 的情况前两个反转成{2,1,3,4,5}后三个反转成{2,1,5,4,3}整体反转成{3,4,5,1,2}正好是左移两位的结果。6.2 找无序数组中最长连续递增子序列的长度这道题的题面是“给定一个无序数组找出最长连续递增子序列的长度”。先仔细读题这里的“连续”在算法里有两种理解方式一是子序列在原数组中是连续的相邻下标二是数字本身的数值是连续的比如{1,2,3}中 1、2、3 连续。热搜里的原题语境更偏向“位置连续且递增”也就是找数组里最长的一段让arr[i] arr[i1] ...成立。解题思路非常直接一次遍历就够了public static int findLongestIncreasingSubarray(int[] arr) { if (arr null || arr.length 0) return 0; int maxLen 1; int curLen 1; for (int i 1; i arr.length; i) { if (arr[i] arr[i - 1]) { curLen; } else { maxLen Math.max(maxLen, curLen); curLen 1; } } return Math.max(maxLen, curLen); }这个解法的关键在于当递增关系中断时不需要从下一个位置重新计算那么笨只需要重置当前长度再继续往后扫就行复杂度是O(n)。maxLen的更新要注意最后一轮如果整个数组全是递增的curLen永远不会中断循环结束后必须再更新一次否则结果会少算一段。6.3 算法题为什么偏爱数组刷算法题刷多了你会发现几乎所有题都绕不开数组。数组是随机访问的代名词很多高级数据结构——堆、优先队列、树状数组、线段树——底层都是拿数组实现的。热搜里提到的“树状数组上二分”“树状数组模板”本质上就是利用数组的下标特性加速区间查询和更新。所以把数组的边界意识、索引计算、空间换时间这些基本功打牢后面学什么数据结构都能事半功倍。我自己的体会是练数组算法题不要急着看题解先在纸上把下标变化画出来。数组题十有八九是下标边界出问题画清楚之后代码基本一遍过。7. 边界问题与替代选择越界异常、动态数组、何时改用集合7.1 越界和空指针数组操作两大常见异常Java里数组操作最常见的两类异常一是ArrayIndexOutOfBoundsException二是NullPointerException。前者是访问了不存在的下标。常见踩坑场景包括for循环写了i arr.length导致最后一次访问arr[arr.length]动态计算下标时结果等于length比如从末尾往前遍历写成了i--但初始值设成了arr.length多线程环境下一个线程读数组、另一个线程修改数组也可能导致读写错位引发越界。后者是数组本身为null或者在二维数组里内层还没初始化就访问了。写代码时建议遵循一个习惯所有来自外部传入的数组使用前先判空、再判断长度这就是经典的防御式编程。判空不是怕麻烦是给未来的自己和调用方省麻烦。7.2 动态数组 ArrayList数组的“自动扩容版”数组定长这件事在很多场景下不方便比如你不知道用户会输入多少个数。Java提供ArrayList来解决这个问题。它的内部其实就是一个Object[]数组当元素个数超过当前容量时会自动创建一个更大的新数组然后把旧元素复制过去。这个扩容过程通常发生在添加元素时ListInteger list new ArrayList(); list.add(10); list.add(20); int value list.get(0);ArrayList的默认初始容量是10扩容时新容量大约是原来的1.5倍。由于扩容涉及数组拷贝频繁扩容会影响性能所以如果你能预估数据量直接new ArrayList(1000)指定初始容量是一个很好的优化习惯。但ArrayList不能完全替代数组。基本类型放进ArrayListInteger会经历自动装箱int变成Integer对象内存开销比纯int[]大不少在性能敏感的数值计算场景下int[]依然是最优选择。再者数组的类型信息在编译期更明确比如String[]可以直接传给某些框架的方法ListString却会因为泛型擦除在某些场景受限。7.3 数组和集合的选择建议这是我经常给团队新人讲的一个决策思路知道固定大小、操作简单、对性能敏感 → 用数组需要频繁增删、元素个数动态变化 → 用ArrayList需要频繁按下标随机访问 → 两者都行但数组略快需要存储多维结构化数据 → 超过二维建议用List嵌套或自定义类需要做集合运算交集、并集、去重→ 直接转成List或Set处理更省心。写在最后数组这个知识点单独看好像没什么难度但把它放到真实开发里几乎是无处不在的。哪怕你以后用ArrayList用得多底层那个扩容机制也离不开“创建新数组 拷贝元素”这套逻辑。我这两年带新人最深的感悟就是数组部分学得扎实的人后面学集合、学数据结构、学算法都非常顺数组概念稀里糊涂的人写起代码来总会这里越界、那里空指针改半天都不知道问题出在哪。最后分享一个我自己的小习惯每次写完一段与数组相关的代码我会下意识地检查三件事——数组长度会不会为0、循环边界能不能取到length、以及数组是否可能为null。这三个检查加起来花不了十秒钟但能挡掉绝大多数与数组相关的线上问题。希望你学完这篇之后也能把这种边界敏感变成一种肌肉记忆。