Java 数组
数组是最基础的数据容器。集合框架底层大量使用数组,比如 ArrayList 本质上就是动态扩容的数组。理解数组,有助于理解集合、缓存、批量处理和性能优化。
数组是什么
数组是一块连续的、长度固定的容器,用下标访问元素。
int[] scores = new int[3];
scores[0] = 90;
scores[1] = 95;
scores[2] = 88;数组特点:
| 特点 | 说明 |
|---|---|
| 长度固定 | 创建后长度不能改变 |
| 下标访问 | 第一个元素下标是 0 |
| 类型统一 | 一个数组只能保存同一种类型 |
| 访问快 | 根据下标可以快速定位 |
数组内存原理
flowchart TD
A["局部变量 scores"] --> B["堆中的 int 数组"]
B --> C["index 0: 90"]
B --> D["index 1: 95"]
B --> E["index 2: 88"]scores 是引用变量,真正的数组对象在堆里。数组元素有默认值:
| 类型 | 默认值 |
|---|---|
| 整数 | 0 |
| 浮点数 | 0.0 |
boolean | false |
| 引用类型 | null |
数组本身也是对象,所以它有 length 属性,也会参与 GC。局部变量里保存的只是数组引用,方法结束后如果没有其他地方引用这个数组,数组对象就可以被垃圾回收。
基本类型数组和引用类型数组
基本类型数组里直接保存值:
int[] ids = {1001, 1002, 1003};引用类型数组里保存的是对象引用,不是对象本身:
class Order {
long id;
String status;
Order(long id, String status) {
this.id = id;
this.status = status;
}
}
Order[] orders = new Order[2];
orders[0] = new Order(1001L, "CREATED");
orders[1] = new Order(1002L, "PAID");内存关系:
flowchart TD
A["局部变量 orders"] --> B["堆中的 Order[] 数组"]
B --> C["index 0 保存引用"]
B --> D["index 1 保存引用"]
C --> E["Order 对象 1001"]
D --> F["Order 对象 1002"]这解释了两个常见问题:
new Order[10]只创建了一个长度为 10 的数组,没有创建 10 个Order对象。- 引用类型数组的默认值是
null,使用元素前必须确认该位置已经放入对象。
错误示例:
Order[] orders = new Order[10];
// orders[0].status = "PAID"; // NullPointerException,因为 orders[0] 还是 null正确写法:
orders[0] = new Order(1001L, "CREATED");
orders[0].status = "PAID";下标为什么从 0 开始
数组底层可以理解为:起始位置 + 偏移量。
第 0 个元素位置 = 起始地址 + 0 * 元素大小
第 1 个元素位置 = 起始地址 + 1 * 元素大小
第 2 个元素位置 = 起始地址 + 2 * 元素大小所以第一个元素偏移量是 0。
遍历数组
public class ArrayLoopDemo {
public static void main(String[] args) {
int[] scores = {90, 95, 88};
for (int i = 0; i < scores.length; i++) {
System.out.println("第 " + i + " 个分数:" + scores[i]);
}
for (int score : scores) {
System.out.println("分数:" + score);
}
}
}如果需要下标,用普通 for;如果只关心元素,用增强 for。
数组越界
public class ArrayOutDemo {
public static void main(String[] args) {
int[] scores = {90, 95, 88};
// System.out.println(scores[3]); // ArrayIndexOutOfBoundsException
}
}长度为 3 的数组,合法下标是 0、1、2。访问 3 会越界。
数组越界属于运行时异常,编译器不一定能提前发现。
数组拷贝
import java.util.Arrays;
public class ArrayCopyDemo {
public static void main(String[] args) {
int[] source = {1, 2, 3};
int[] target = Arrays.copyOf(source, 5);
System.out.println(Arrays.toString(target));
}
}Arrays.copyOf 会创建一个新数组,再把原数组内容复制过去。
flowchart TD
A["原数组长度 3"] --> B["创建新数组长度 5"]
B --> C["复制已有元素"]
C --> D["多出来的位置填默认值"]这也是 ArrayList 扩容的核心思路。
浅拷贝和深拷贝
Arrays.copyOf、System.arraycopy 对引用类型数组做的是浅拷贝:复制的是引用,不会自动复制引用指向的对象。
import java.util.Arrays;
class User {
String name;
User(String name) {
this.name = name;
}
}
public class ShallowCopyDemo {
public static void main(String[] args) {
User[] source = {new User("Tom")};
User[] target = Arrays.copyOf(source, source.length);
target[0].name = "Jerry";
System.out.println(source[0].name); // Jerry
}
}为什么源数组也变了?因为两个数组的第 0 个位置保存的是同一个 User 对象引用。
flowchart TD
A["source[0]"] --> C["同一个 User 对象"]
B["target[0]"] --> C
C --> D["name = Jerry"]如果要深拷贝,必须创建新的对象:
public class DeepCopyDemo {
public static User[] deepCopy(User[] source) {
User[] target = new User[source.length];
for (int i = 0; i < source.length; i++) {
if (source[i] != null) {
target[i] = new User(source[i].name);
}
}
return target;
}
}订单、库存、用户权限这类对象数组,如果只是浅拷贝,后续修改副本可能影响原数据。商业项目里要明确:你复制的是容器,还是连元素对象一起复制。
二维数组
二维数组可以理解为数组里面再放数组。
public class MatrixDemo {
public static void main(String[] args) {
int[][] table = {
{1, 2, 3},
{4, 5, 6}
};
System.out.println(table[0][1]);
}
}内存上它不是一整块严格的二维表,而是多个一维数组的组合。
Java 二维数组每一行长度可以不同:
int[][] table = new int[3][];
table[0] = new int[]{1, 2};
table[1] = new int[]{3, 4, 5};
table[2] = new int[]{6};所以访问二维数组时不要默认每一行长度一样,应该使用 table[i].length。
for (int i = 0; i < table.length; i++) {
for (int j = 0; j < table[i].length; j++) {
System.out.println(table[i][j]);
}
}可变参数
可变参数底层也是数组。
public class VarArgsDemo {
public static int sum(int... values) {
int total = 0;
for (int value : values) {
total += value;
}
return total;
}
public static void main(String[] args) {
System.out.println(sum(1, 2, 3));
}
}int... values 在方法内部就是 int[] values。
Arrays 工具类
import java.util.Arrays;
public class ArraysUtilDemo {
public static void main(String[] args) {
int[] values = {3, 1, 2};
Arrays.sort(values);
System.out.println(Arrays.toString(values));
System.out.println(Arrays.binarySearch(values, 2));
}
}常用方法:
| 方法 | 作用 |
|---|---|
Arrays.toString | 打印数组内容 |
Arrays.sort | 排序 |
Arrays.copyOf | 拷贝并扩容 |
Arrays.asList | 数组转 List |
Arrays.binarySearch | 二分查找 |
注意:Arrays.asList 转出来的 List 长度固定,不能直接 add 或 remove。
Arrays.asList 的底层坑
Arrays.asList(array) 返回的是 java.util.Arrays 内部的固定长度 List,它背后仍然持有原数组。它支持 set 修改元素,但不支持改变长度的 add/remove。
import java.util.Arrays;
import java.util.List;
public class AsListDemo {
public static void main(String[] args) {
String[] values = {"A", "B"};
List<String> list = Arrays.asList(values);
list.set(0, "X");
System.out.println(values[0]); // X
// list.add("C"); // UnsupportedOperationException
}
}如果需要独立可变集合:
List<String> list = new java.util.ArrayList<>(Arrays.asList(values));还有一个坑:基本类型数组传给 Arrays.asList 不会变成多个元素,而是把整个数组当成一个元素。
int[] ids = {1, 2, 3};
List<int[]> list = Arrays.asList(ids);
System.out.println(list.size()); // 1原因是泛型不能直接使用基本类型,int[] 本身是一个引用类型对象。
binarySearch 的前提
Arrays.binarySearch 使用二分查找,前提是数组已经按同样规则排序。
int[] values = {3, 1, 2};
System.out.println(Arrays.binarySearch(values, 2)); // 结果不可靠
Arrays.sort(values);
System.out.println(Arrays.binarySearch(values, 2)); // 正确二分查找为什么快?因为每次比较都能排除一半数据,时间复杂度是 O(log n)。但如果数组没有排序,“排除一半”的前提不存在,结果就没有意义。
商业 Demo:批量计算订单金额
import java.math.BigDecimal;
public class OrderAmountArrayDemo {
public static BigDecimal sum(BigDecimal[] amounts) {
BigDecimal total = BigDecimal.ZERO;
for (BigDecimal amount : amounts) {
if (amount == null) {
continue;
}
total = total.add(amount);
}
return total;
}
public static void main(String[] args) {
BigDecimal[] amounts = {
new BigDecimal("19.90"),
new BigDecimal("88.00"),
null
};
System.out.println(sum(amounts));
}
}这个例子强调两点:
- 引用类型数组元素可能是
null。 - 金额汇总不要使用
double。
商业 Demo:批量导入错误收集
数组常用于固定批次、固定列数、底层缓冲区。下面用数组模拟一批导入记录的校验结果。
public class ImportErrorArrayDemo {
public static void main(String[] args) {
String[] rows = {
"1001,CREATED",
"1002,",
"",
"1004,PAID"
};
String[] errors = new String[rows.length];
for (int i = 0; i < rows.length; i++) {
errors[i] = validate(rows[i]);
}
for (int i = 0; i < errors.length; i++) {
if (errors[i] != null) {
System.out.println("第 " + (i + 1) + " 行失败:" + errors[i]);
}
}
}
private static String validate(String row) {
if (row == null || row.trim().length() == 0) {
return "空行";
}
String[] parts = row.split(",", -1);
if (parts.length != 2) {
return "列数不正确";
}
if (parts[0].length() == 0) {
return "订单号为空";
}
if (parts[1].length() == 0) {
return "订单状态为空";
}
return null;
}
}这里 split(",", -1) 很关键。默认 split(",") 会丢弃末尾空字符串,"1002," 可能只得到一个元素,导致你无法准确判断“第二列为空”。批量导入、CSV 解析、接口字段解析都要注意这个细节。
数组和性能
数组的优势是结构简单、连续存放引用或基本类型值、下标访问快。代价是长度固定,插入删除不灵活。
| 操作 | 数组特点 | 原因 |
|---|---|---|
| 按下标读取 | 快,通常 O(1) | 下标能直接计算位置 |
| 尾部写入 | 快,前提是还有空间 | 直接写到指定位置 |
| 中间插入 | 慢,通常 O(n) | 后续元素要整体右移 |
| 中间删除 | 慢,通常 O(n) | 后续元素要整体左移 |
| 扩容 | 慢,通常 O(n) | 必须创建新数组并复制元素 |
这也是为什么 ArrayList 随机访问快、尾部追加快,但中间插入删除慢。
线上问题排查
flowchart TD
A["数组相关异常"] --> B{"异常类型"}
B -- "ArrayIndexOutOfBoundsException" --> C["检查循环边界和下标计算"]
B -- "NullPointerException" --> D["检查引用类型数组元素是否初始化"]
B -- "UnsupportedOperationException" --> E["检查是否 Arrays.asList 后 add/remove"]
B -- "数据被意外修改" --> F["检查是否浅拷贝共享了元素对象"]
B -- "内存上涨" --> G["检查大数组、批量缓存、一次性读取文件"]排查建议:
- 越界先看循环条件是不是
i <= array.length,正确通常是i < array.length。 - 对象数组空指针先确认
array[i]是否创建了对象,而不只是创建了数组。 - 大批量数据不要一次性放入巨大数组,优先分页、分批、流式处理。
- 复制对象数组前明确浅拷贝还是深拷贝。
- 使用
Arrays.asList后如果要增删,立刻包一层new ArrayList<>(...)。
数组和集合的区别
| 对比项 | 数组 | 集合 |
|---|---|---|
| 长度 | 固定 | 通常可变 |
| 类型 | 支持基本类型和引用类型 | 只能放引用类型 |
| API | 较少 | 丰富 |
| 性能 | 下标访问快,开销小 | 功能多,有额外封装 |
| 场景 | 固定长度、底层结构 | 业务数据处理 |
常见风险
| 问题 | 后果 | 建议 |
|---|---|---|
| 下标越界 | 运行时报错 | 循环条件使用 < array.length |
| 引用类型元素为空 | 空指针 | 使用前判空 |
| 误以为数组可扩容 | 数据丢失或逻辑错误 | 扩容必须创建新数组 |
Arrays.asList 后 add | 抛出异常 | 转成 new ArrayList<>(...) |
| 对象数组只 new 数组没 new 元素 | 空指针 | 逐个创建元素对象 |
| 浅拷贝后修改对象 | 原数组数据也被改 | 需要独立对象时做深拷贝 |
| 未排序就 binarySearch | 查找结果不可靠 | 先按同样规则排序 |
面试常问
数组为什么下标从 0 开始?
可以把数组访问理解为起始位置加偏移量。第一个元素相对起始位置的偏移是 0,所以它的下标是 0;第 n 个元素的偏移是 n * 元素大小。
对象数组创建后,里面的对象也创建了吗?
没有。new User[10] 只创建了一个长度为 10 的数组,里面每个位置默认是 null。必须给每个位置赋值为具体对象后,才能访问对象字段或方法。
数组拷贝是深拷贝还是浅拷贝?
对基本类型数组,复制的是值;对引用类型数组,默认复制的是对象引用,也就是浅拷贝。两个数组可能指向同一批元素对象,修改对象内容会互相影响。需要完全独立时要手动创建新元素对象。
Arrays.asList 有什么坑?
它返回固定长度 List,不支持 add/remove;它背后仍然持有原数组,set 会影响原数组;基本类型数组传进去会被当成一个元素。需要可变集合时用 new ArrayList<>(Arrays.asList(array))。
数组和 ArrayList 区别?
数组长度固定,能存基本类型和引用类型,API 简单;ArrayList 底层基于数组,长度可动态扩容,只能存引用类型,提供更多集合操作。ArrayList 的自动扩容本质上仍然是创建新数组并复制旧元素。
本章小结
数组是长度固定、下标访问、类型统一的基础容器。学习数组要理解引用关系、默认值、越界、拷贝、二维数组和工具类。集合框架中很多“自动扩容”的能力,底层仍然离不开数组拷贝。
