1. 数组到底是什么:先看这张内存图
很多人学了几年 Java,写数组还是“会写不会想”——知道int[] arr = new int[5]能跑,但一问“数组是对象吗”“存在堆还是栈”“为什么数组打印出来是一串[I@1b6d3586”就卡住了。这很正常,因为数组在 Java 里是个“半隐半显”的类型:语法上像 C 语言那样的原生数据类型,本质上却是不折不扣的引用类型。
1.1 声明、创建与初始化的三种姿势
数组的声明有两种写法:
int[] arr1; // 推荐 int arr2[]; // 不推荐但合法int[]是把“数组类型”当作一个整体来看,而int arr2[]是 C 风格残留,读起来容易跟变量声明混淆,团队规范里基本都会禁掉这种写法。声明本身不分配空间,只是告诉编译器“我有个引用变量叫 arr1,它将来要指向一个 int 数组”。
创建数组有三种常见方式:
// 方式一:new 关键字指定长度,元素取默认值 int[] a = new int[5]; // 方式二:静态初始化(明明知道有哪些元素) int[] b = {1, 2, 3, 4, 5}; // 方式三:先声明,后 new,再逐个赋值 int[] c = new int[3]; c[0] = 10; c[1] = 20; c[2] = 30;方式一里,new int[5]会在堆内存开辟一块连续空间,长度固定为 5,元素被自动赋予该类型的默认值:int 是 0,double 是 0.0,boolean 是 false,引用类型是 null。这个“默认值”特性很关键,很多初学者踩的 NPE 就是从这里埋下的——拿到的不是空对象,而是“装着一堆 null 的对象数组”。
方式二最直观,适合元素数量已知、内容固定的场景,比如定义一个星期的缩写、一个状态的合法值列表。注意它只能用在“声明的同时赋值”这种语法里,不能在声明之后再用花括号整体赋值。
1.2 数组是对象:那它的类型到底是什么
直接抛结论:数组是对象,数组的类型是 JVM 在运行时动态生成的。你看不见它的类名,但arr.getClass()一定能拿到一个class [I这样的标识,[I表示 int 数组,[Ljava.lang.String;表示 String 数组。数组的父类是谁?是Object。所以下面这段代码能通过编译:
int[] arr = new int[5]; Object obj = arr; // 向上转型,没问题 System.out.println(obj instanceof Object); // true但也因为数组是引用类型,它在方法间传递时走的是“引用传递”——更准确说是“传引用值”。你在方法里改arr[0] = 100,外面看的同一个数组内容也变了。这不是数组特有的,任何 Java 对象都这样,但很多人写工具方法时下意识把数组当成“值传递”,结果就出 bug。
还有一个隐蔽点:数组的length是属性,不是方法。arr.length()会直接编译报错,arr.length才能拿到长度。这个属性是 final 的,数组一旦创建,长度不可变。想“加长”,只能建新数组再拷贝,这也是后面要讲Arrays.copyOf的原因之一。
数组的索引从 0 开始,最大索引是length - 1,越界访问会抛ArrayIndexOutOfBoundsException,这是个运行时异常,编译期完全查不出来。
2. 数组遍历:四种写法,一种原则
遍历是所有数组操作的基础,写法倒是不少,但核心只有一条:能用增强 for 就别用手写索引,需要索引或修改结构时再退回来。
2.1 四种遍历写法的取舍
// 写法一:传统 for + length for (int i = 0; i < arr.length; i++) { System.out.println(arr[i]); } // 写法二:增强 for,又称 for-each for (int x : arr) { System.out.println(x); } // 写法三:while 循环(不推荐,除非有特殊终止条件) int i = 0; while (i < arr.length) { System.out.println(arr[i]); i++; } // 写法四:Java 8 的 Arrays.stream + forEach(适合配合 Lambda 做链式处理) Arrays.stream(arr).forEach(x -> System.out.println(x));传统 for 是“全能选手”,你需要当前下标、需要逆序遍历、需要每隔一个取一个,都得用它。增强 for 是“只读选手”,它背后编译之后其实还是换成索引循环,但源码层面更简洁、更安全,你不需要关心下标边界,也不容易手抖写出<=这种越界条件。
Arrays.stream的写法适合你接下来要做过滤、映射、汇总这类操作。比如求数组里所有偶数的平方和:
int[] arr = {1, 2, 3, 4, 5, 6}; int result = Arrays.stream(arr) .filter(x -> x % 2 == 0) .map(x -> x * x) .sum();这一下就把命令式循环变成了声明式处理,代码短了一大截,还不太容易出错。不过 stream 会有装箱和拆箱的开销,十万级以下的数据量感觉不到,百万级起步时还是传统 for 更稳。
2.2 一个细节:增强 for 里能不能改数组元素
这个问题我被问过很多次。答案分两层:
- 如果数组元素是引用类型,你在增强 for 里拿到的是“引用”,可以修改这个引用指向的对象内部状态——比如
list[i].setName("x"),没问题; - 但你不能给
arr[i]重新赋值。
原因是增强 for 的循环变量只是一个局部拷贝,你给这个拷贝赋新值,影响不到原数组。很多人写:
for (String s : strArr) { s = "new value"; // 没用!strArr 里的内容完全不变 }这行代码一百个人里有八十个人以为能改,实际上 s 只是拿了一个引用副本,s = ...只是让这个局部变量指向了新对象。想批量改数组元素,老老实实写带下标的 for 循环。
3. 数组复制的三个层次:别再手动 for 循环拷贝了
写数组复制,最常见也最啰嗦的做法是:
int[] dest = new int[src.length]; for (int i = 0; i < src.length; i++) { dest[i] = src[i]; }我不否认这个写法能跑,但它把所有复杂度都摊在了你脸上:要管目标数组长度、要管下标边界、要处理拷贝部分区域的需求。Java 提供了三个更高级的复制手段,按需选择。
3.1 System.arraycopy:数组拷贝的地基
System.arraycopy是 native 方法,JVM 层面直接帮你做内存块拷贝,速度远快于手动 for 循环。它的签名是五个参数:
System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length);分别表示:源数组、源起始下标、目标数组、目标起始下标、拷贝数量。
int[] src = {1, 2, 3, 4, 5}; int[] dest = new int[5]; System.arraycopy(src, 0, dest, 0, src.length);这套参数设计最实用的一点是,你想把“从中间开始的一段”拷贝到另一段,不必再造临时数组:
int[] src = {1, 2, 3, 4, 5}; int[] dest = new int[5]; System.arraycopy(src, 1, dest, 2, 3); // dest = {0, 0, 2, 3, 4}源数组和目标数组可以是同一个对象,JVM 内部会处理好重叠问题,你不需要自己判断“是从前往后拷还是从后往前拷”这种 C 语言才需要操心的破事。
3.2 Arrays.copyOf:扩容神器
Arrays.copyOf在参数上更友好,你不需要预创建目标数组,它自己 new:
int[] original = {1, 2, 3}; int[] bigger = Arrays.copyOf(original, 5); // bigger = {1, 2, 3, 0, 0}第二个参数指定新数组长度:如果比原数组长,多出来的位置补默认值;如果比原数组短,只保留前几位,相当于截断。这个方法的内部实现就是System.arraycopy,所以性能同样有保障。
日常开发里最常见的用法,就是“动态扩容”:
int[] data = new int[10]; int count = 0; // 模拟不断往里加数据 while (需要新增) { if (count >= data.length) { data = Arrays.copyOf(data, data.length * 2); } data[count++] = value; }这就是 ArrayList 扩容策略的基础原理,只不过 ArrayList 默认扩容到 1.5 倍,而且把这一套逻辑封装好了。
3.3 clone:一条偷懒的路
数组对象实现了clone()方法,而且对一维数组来说,clone 出来的是“真正的副本”:
int[] src = {1, 2, 3}; int[] copy = src.clone(); copy[0] = 99; System.out.println(src[0]); // 1,互不影响但你要是对二维数组调用clone(),拿到的只是“外层数组的浅拷贝”——内层数组引用还是同一个。这一点会在多维数组小节里再展开。
什么时候用哪种:
| 需求 | 推荐方案 |
|---|---|
| 拷贝到已存在的目标数组,且要指定偏移 | System.arraycopy |
| 创建新数组,同时完成拷贝 | Arrays.copyOf |
| 快速复制整个一维数组,且不关心性能细节 | clone() |
| 做数组扩容 | Arrays.copyOf |
| 多维数组深拷贝 | 手动循环或逐层copyOf |
4. 排序与查找:别自己写冒泡了
很多教材到现在还在花大力气教冒泡排序、选择排序,我不否认这些是理解算法的基础,但在真实业务代码里,你几乎永远不需要手写排序。java.util.Arrays提供了一整套现成的高性能工具。
4.1 sort 与 parallelSort
int[] arr = {5, 2, 9, 1, 7}; Arrays.sort(arr); // arr = {1, 2, 5, 7, 9}这是最基本的全量升序排序。Java 的Arrays.sort会根据数据类型选择不同的排序算法:基本类型用双基准快速排序,对象类型用 TimSort(归并排序的优化版)。快排是不稳定排序,TimSort 是稳定排序——所以如果你有两个字段需要“先按 A 排,再按 B 排”,用对象数组 +Comparator才能保证第二轮的稳定性。
对象数组排序时传比较器:
String[] names = {"banana", "apple", "Cherry"}; Arrays.sort(names, String.CASE_INSENSITIVE_ORDER);对自定义对象,可以传Comparator或让对象实现Comparable:
Arrays.sort(users, Comparator.comparing(User::getAge));parallelSort是 Java 8 起的并行排序版本,数据量足够大(一般超过几千)时才可能有收益,数据量小反而因为线程调度开销变慢。我的建议是:默认用 sort,别提前优化。
4.2 二分查找的前提条件
Arrays.binarySearch是一个效率极高的查找方法,但有一个硬性前提:数组必须已经有序。在无序数组上调用它,结果不可预测,甚至可能返回一个完全不存在的下标。
int[] arr = {1, 3, 5, 7, 9}; int index = Arrays.binarySearch(arr, 5); // 返回 2 int missing = Arrays.binarySearch(arr, 6); // 返回负数:表示“插入点”的变形,这里是 -4当找不到目标时,返回值不是简单的 -1,而是-(插入点) - 1。插入点是指“如果把目标插进去,它应该待的位置”。-4表示 6 应该插在索引 3 的位置,因为原来的 7 比 6 大。这个设计的用途就是配合Arrays.binarySearch做“查找或插入”的组合操作:
int index = Arrays.binarySearch(arr, key); if (index >= 0) { // 找到了 } else { int insertPos = -index - 1; }我之前见过有同事在二分查找找不到时直接返回-(index),然后拿这个值去插,结果索引偏差一位,线上查了半天。这个-index - 1的公式值得记死。
4.3 equals 与 deepEquals
数组内容比较也是个高频暗坑。用==比较两个数组,比的是引用地址,不是内容:
int[] a = {1, 2, 3}; int[] b = {1, 2, 3}; System.out.println(a == b); // false,两个不同对象 System.out.println(Arrays.equals(a, b)); // true,逐个元素相等Arrays.equals有一系列重载方法,对基本类型不做装箱,性能很好。对二维数组要用Arrays.deepEquals——deepEquals会递归比较内层数组的内容,而equals只管外层数组的引用。
5. 多维数组:实际上是数组的数组
Java 的多维数组跟 C/C++ 的内存模型完全不同。C 的多维数组是“一整块连续内存”,用a[i][j]是根据偏移量算出来的;Java 的二维数组是“外层数组存内层数组的引用”,所以它天然支持“不规则数组”。
5.1 二维数组的内存布局与创建方式
int[][] matrix = new int[3][4];这一行代码创建了 1 个外层数组(长度 3)和 3 个内层数组(各长度 4),共 4 个堆对象。外层数组的元素类型是int[],默认值是 null——对,你没看错,内层数组创建后,外层数组里存的是 3 个引用,每个引用指向一个长度为 4 的 int 数组。
所以访问matrix[0][0]的含义是:先拿到外层数组下标 0 的元素(一个int[]引用),再从这个内层数组里取下标 0 的值。两步取址。
如果你只创建外层:
int[][] matrix = new int[3][];内层数组是 3 个 null,你要逐个初始化才能用。这种写法常用于不规则数组。
5.2 不规则数组(锯齿数组)与遍历技巧
不规则数组就是“每一行的列数不同”:
int[][] triangle = new int[5][]; for (int i = 0; i < triangle.length; i++) { triangle[i] = new int[i + 1]; // 第 0 行有 1 列,第 4 行有 5 列 }这在处理“杨辉三角”这类问题上非常自然,你不需要用new int[5][5]然后留一堆 0 占位。每一行都是独立的数组,内存不连续。
遍历二维数组时,判断维度顺序很重要:
for (int i = 0; i < matrix.length; i++) { for (int j = 0; j < matrix[i].length; j++) { // matrix[i].length 不是固定值,对不规则数组尤其要小心 System.out.print(matrix[i][j] + " "); } }matrix.length是外层长度,matrix[i].length是第 i 行的长度,两码事。
5.3 多维数组的复制是个坑
前面说过,clone()对多维数组是浅拷贝。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] = Arrays.copyOf(src[i], src[i].length); }改dest[0][0]不会影响src[0][0]。如果你用Arrays.copyOf(src, src.length),得到的只是一个“外层数组拷贝”,内层还是同一批数组对象,改一个全是连锁反应。
6. Arrays 工具类:高频方法的正确打开方式
这一节把java.util.Arrays里日常开发真正用得上的方法过一遍,按使用频率排序。
6.1 打印数组:toString 与 deepToString
数组自己没重写toString(),直接打印得到的是一个杂乱的地址串。Arrays.toString是唯一的省心方案:
int[] arr = {1, 2, 3}; System.out.println(Arrays.toString(arr)); // [1, 2, 3]二维数组用Arrays.deepToString:
int[][] matrix = {{1, 2}, {3, 4}}; System.out.println(Arrays.deepToString(matrix)); // [[1, 2], [3, 4]]deepToString的原理是递归调用,所以它对“数组的数组”能一层层展开,直到元素不是数组为止。
6.2 fill 与 setAll
Arrays.fill用来批量填充:
int[] arr = new int[10]; Arrays.fill(arr, -1); // 全部变成 -1 Arrays.fill(arr, 2, 5, 0); // 下标 2 到 4 变成 0,左闭右开一个典型场景是初始化访问标记数组:boolean[] visited = new boolean[n];默认就是 false,不用 fill。但如果你的标记数组要每轮重置,用fill比 for 循环清晰。
Arrays.setAll是按索引生成值:
int[] squares = new int[10]; Arrays.setAll(squares, i -> i * i); // [0, 1, 4, 9, ...]它等价于一个带索引的循环,但写成表达式后更容易读。
6.3 asList 与数组转集合的坑
Arrays.asList是最容易让人误用的方法。它的行为是:返回一个由原数组支持的固定大小 List。这句话有三层含义:
String[] arr = {"a", "b", "c"}; List<String> list = Arrays.asList(arr); list.set(0, "z"); // 可以,会同步改到原数组 list.add("d"); // 抛 UnsupportedOperationException!不能增删 arr[0] = "y"; // 也能通过原数组改,list 会同步你拿到的是一个“数组视图”,不是真正的 ArrayList,不能 add、remove。想让得到的是独立集合,用new ArrayList<>(Arrays.asList(arr)),它会把元素复制到真正的 ArrayList 里。
还有一个更隐蔽的坑:基本类型数组传进去,asList会把整个数组当成单个元素,因为泛型不认基本类型:
int[] arr = {1, 2, 3}; List<Integer> list = Arrays.asList(arr); // 编译不报错,但通不过泛型Java 会自动把int[]当成一个对象装进List<int[]>,你拿到的是一个长度为 1 的列表,里面装着一个 int 数组。解决方案是用 Java 8 的流式转换:
List<Integer> list = Arrays.stream(arr) .boxed() .collect(Collectors.toList());boxed()把 IntStream 转成 Stream<Integer>,才能进入集合框架。
6.4 集合转数组
反过来,集合转数组也有讲究。list.toArray()返回Object[],基本用不上;正确做法是传入目标类型数组:
List<String> list = new ArrayList<>(List.of("a", "b")); String[] arr = list.toArray(new String[0]);传入new String[0]而不是new String[list.size()]的原因很微妙:Java 8 之后有个被编译器优化过的技巧,toArray(new String[0])只用作类型标记,实际内部会按列表大小创建新数组,性能不差,而且避免了“数组传太长导致多余空间被填 null”的问题。这不是玄学,业内实测new String[0]并不会比预分配尺寸慢。
7. 数组与泛型、可变参数:几个必知组合
7.1 为什么不能直接 new T[]
泛型数组是个大坑,直接写:
T[] arr = new T[10]; // 编译错误:Cannot create a generic array of T原因是 Java 的数组在运行时会做类型检查(协变数组),而泛型在编译期会被擦除。JVM 在运行时不知道 T 具体是什么,也就无法确认你往数组里放的元素类型是否合法。
正确做法是先创建 Object 数组,再强转:
@SuppressWarnings("unchecked") T[] arr = (T[]) new Object[10];这个方法能跑,但类型安全由你自己保证。因为数组的运行时类型仍是 Object[],如果你把 arr 传到别处并被当成T[]使用,擦除后其实只是个 Object[],并没有真正检查元素类型。
更常见的选择是:能用集合就用集合,ArrayList<T>内部就是 Object 数组 + 类型检查,完全规避这个问题。
7.2 可变参数的本质就是数组
方法签名里写:
public void printAll(String... args) { for (String s : args) { System.out.println(s); } }调用时你既能传多个参数printAll("a", "b"),也能直接传数组printAll(new String[]{"a", "b"})。因为String... args在编译后就是String[] args。
有个细节要注意:可变参数必须放在参数列表最后,不然编译器分不清哪些参数归它。还有一个不常见的坑:调用时传 null 会被当作“传入一个 null 数组引用”,而不是“包含一个 null 元素的数组”。所以printAll((String[]) null)和printAll((String) null)是两种完全不同的调用,前者的args是 null,后者的args是{null},遍历时前者会 NPE。
7.3 数组协变带来的 ArrayStoreException
数组是协变的,意思是String[]是Object[]的子类型,这允许你把String[]赋给Object[]变量:
String[] strs = {"a", "b"}; Object[] objs = strs; // 编译通过 objs[0] = 1; // 编译通过,但运行时报 ArrayStoreException因为 JVM 在运行时检查到objs实际指向的是 String 数组,你塞一个 Integer 进去就违规了。这个检查是数组特有的,集合框架因为泛型不变性没有这个问题。日常开发中这种代码不多,但反序列化、反射场景偶尔会碰到。
8. 常见问题与性能调优实录
8.1 越界异常:判断逻辑对,但容易错在边界
ArrayIndexOutOfBoundsException是最常见的数组运行时异常。归纳起来主要有三类触发场景:
- 循环条件写错边界,比如
i <= arr.length; - 取的索引来自外部输入或计算结果,没有做范围校验;
- 从“空数组”里取元素,索引 0 直接越界——空数组长度是 0,
arr[0]就是越界。
排查时最好先打印arr.length,再打印当前索引。很多诡异问题最后都发现是数组长度不是你以为的那个数,比如从一个接口返回的列表转数组,列表是空的但你按非空处理了。
8.2 空指针:数组里装了一堆 null 是元凶
对象数组默认值是 null,如果创建后没有逐个初始化,遍历时一调用方法就 NPE。比如String[] names = new String[10];然后names[0].length(),直接崩。
排查思路是三步走:先确认数组本身不是 null(arr != null),再确认数组长度 > 0,然后确认你访问的那个下标的元素不是 null。三个条件缺一不可。
8.3 性能优化:什么时候数组比集合快
数组和ArrayList的性能差异很小,但大循环场景下数组有两个优势:没有泛型擦除带来的类型转换检查,基本类型数组没有装箱拆箱开销。我自己做过一个简单压测,百万次 int 累加,int[]比List<Integer>大概快几倍,内存占用也明显低。
所以如果你处理的是“大量基本类型 + 高频遍历 + 长度已知或可预估”的数据,直接用数组。反之,如果数据的增删频繁、长度动态变化,用集合更合理。
性能优化的一条实用建议:大数组遍历时注意缓存友好性。Java 数组在内存中是连续分配的,顺序访问时 CPU 缓存命中率高,所以尽量按内存顺序遍历,不要跳跃式访问二维数组的“列优先”模式——即先matrix[i][j]遍历行,再遍历列,比matrix[j][i]快很多。这在多维数组处理中是个不小的差距,实际项目里用双重循环转置矩阵时尤其明显。
8.4 Arrays.asList 引发的集合操作失败
前面讲过Arrays.asList返回的是定长视图。我见过不止一次有人在得到这个 List 后直接remove,然后收到UnsupportedOperationException跑去查框架源码。定位思路很简单:看这个 List 的类名——Arrays$ArrayList而不是java.util.ArrayList,就知道是数组视图。
规避方案一句话:new ArrayList<>(Arrays.asList(...))。如果确定不需要增删,数组视图反而有个好处:它和原数组数据同步,适合做只读转换。
9. 实操心得:把数组用对的几条原则
讲了这么多,最后沉淀几条我平时写代码实际遵循的原则。
第一,能静态初始化就别动态赋值。如果元素内容在写代码时已经确定,直接{"a", "b", "c"}比new String[3]再逐步赋值更简洁、更不容易漏项。
第二,复制数组认准Arrays.copyOf,别再手写 for 循环。它语义清晰、自动处理新数组创建,内部还复用System.arraycopy的高性能实现。需要定制起点终点时再用System.arraycopy。
第三,打印数组永远用Arrays.toString,不管是调试还是写日志。直接打印arr输出的[I@...对排查没有任何帮助。
第四,数组和集合之间转换看清方向:数组转 List 用Arrays.asList得到的是视图;集合转数组用list.toArray(new T[0])。基本类型数组转集合要经过boxed()流程。
第五,凡是从外部接收数组参数的方法,先判空再判长度。这两步写在方法体最前面,省得后续代码里到处提心吊胆。
数组在 Java 里是个“简单又不简单”的主题。简单在语法一眼就会,不简单在它的引用语义、类型协变、浅拷贝深拷贝、与泛型的冲突,这些全是初学者和资深开发者都会反复碰到的细节。把上面这些坑都摸透了,你在处理数据批处理、算法题、日常业务里的批量数据操作时,会顺手很多。