一维数组
白歌:"数组是 Java 中最基础的数据结构,也是理解 JVM 内存模型的第一个窗口。我在面试中经常发现——很多候选人能写出排序算法,却说不清数组在堆栈中的布局。这一节,我们从底层开始构建数组的完整认知。"
什么是数组
数组(Array) 是 Java 中最基本的数据结构之一,它是一组相同数据类型、连续存储、固定长度的数据元素的集合。
在 Java 中,数组本身是引用类型——即使数组中存储的是基本类型数据(如 int),数组对象本身也分配在 JVM 堆上,栈中只保存对其的引用。
| 特性 | 说明 | 类比 |
|---|---|---|
| 同类型性 | 数组中所有元素必须是同一类型(或兼容类型) | 一列火车车厢,每节装同一种货物 |
| 连续存储 | 元素在堆内存中地址连续排列 | 小区里紧挨着的连排别墅 |
| 零下标 | 下标从 0 开始,最大下标为 length - 1 | 楼层的"第0层"思维模型 |
| 长度不可变 | 数组一旦创建,length 属性就固定不变 | 建好的楼房,层数不会变 |
| 引用类型 | 数组变量是引用,数组对象在堆上 | 遥控器 vs 电视机 |
数组的声明方式
Java 支持两种声明语法:
| 声明方式 | 语法 | 说明 | 推荐度 |
|---|---|---|---|
| 类型在前 | int[] arr; | C/C++ 风格的改良,可读性好 | ★★★★★ |
| 类型在后 | int arr[]; | 兼容 C/C++ 传统语法,混合声明时易混淆 | ★★ |
| 混合声明(反例) | int[] arr1, arr2; → 两者都是数组 | 易误读 | 仅作了解 |
| 混合C风格(反例) | int arr1[], arr2; → arr1 是数组,arr2 是 int | 容易出错 | 不推荐 |
白歌的建议:永远使用
int[] arr风格。它不仅读起来清晰("int数组 arr"),而且避免了混合声明时的歧义。团队代码规范中应统一这一条。
// ✅ 推荐:类型在前
int[] scores; // 学生成绩数组
String[] names; // 学生姓名数组
double[] salaries; // 员工薪资数组
// ⚠️ 可用但不推荐:类型在后
int scores2[]; // 兼容C语法,但可读性差
// ❌ 混合声明的陷阱
int arr1[], arr2; // arr1是数组,arr2只是int!极易引起误解
数组的三种初始化方式
方式一:静态初始化
在声明的同时用花括号指定所有元素的值,由 JVM 自动推断数组长度。
// 语法格式
数据类型[] 数组名 = {元素1, 元素2, ...};
// 示例:飞翔科技学生成绩
int[] scores = {85, 92, 78, 95, 88};
注意:静态初始化必须在声明的同时完成。不能先声明再静态赋值。
// ❌ 错误:声明与初始化分离时不能使用花括号
int[] scores;
scores = {85, 92, 78}; // 编译错误!Array initializer is not allowed here
// ✅ 正确:声明与初始化分离时,必须使用 new
int[] scores;
scores = new int[]{85, 92, 78}; // 匿名数组语法
方式二:动态初始化
指定数组长度,由 JVM 自动为每个元素赋予默认值(零值)。
// 语法格式
数据类型[] 数组名 = new 数据类型[长度];
// 示例:飞翔科技——预留10个学生成绩的位置
int[] scores = new int[10];
| 数组元素类型 | 默认值 |
|---|---|
byte / short / int / long | 0 |
float / double | 0.0 |
char | '\u0000'(空字符,不是空格) |
boolean | false |
| 引用类型(类 / 接口 / 数组) | null |
方式三:默认初始化
当数组作为类的实例变量或静态变量时,即使不显式赋值,JVM 也会在类加载或对象创建时自动赋予默认值。
public class ScoreManager {
private int[] scores = new int[5]; // 实例变量数组,元素自动初始化为 0
// ...
}
三种初始化方式对比:
| 初始化方式 | 适用场景 | 长度来源 | 是否可分离声明与赋值 |
|---|---|---|---|
静态初始化 {a,b,c} | 数据已知且固定 | 由花括号内元素个数确定 | 否(必须同时) |
动态初始化 new int[n] | 数据未知,但容量确定 | 由 new 指定的长度确定 | 是 |
| 默认初始化(成员变量) | 数组作为类的字段 | 由 new 指定的长度确定 | 是 |
数组的内存模型
这是本章最重要的部分。理解数组在内存中的布局,是诊断 NullPointerException 和画面试内存图的前提。
关键原理:
栈上存引用,堆上存对象:
int[] scores = new int[]{85, 92, 78, 95, 88}执行后,栈上的scores变量只存储一个指向堆中数组对象的引用地址(32位JVM中为4字节,64位JVM中为8字节,若开启指针压缩则为4字节)。数组对象的内存结构:堆上的数组对象由三部分组成:
组成部分 说明 大小 对象头(Object Header) 包含 Mark Word(锁信息、GC分代年龄、hashCode)和 Class Pointer(指向 int[]的类元数据)12~16字节(64位) length 字段 存储数组长度值 4字节(int) 数据区 连续存储每个数组元素 元素类型大小 × 数组长度连续存储的优势:
- O(1) 随机访问:元素的地址可以通过公式
基地址 + 下标 × 元素字节大小直接计算,不需要遍历 - 缓存友好:连续内存使得 CPU 缓存行(Cache Line)可以被充分利用,遍历时预取效率极高
- 对比 ArrayList:ArrayList 底层也是数组,但数组本身比 ArrayList 少一层封装,访问更快
- O(1) 随机访问:元素的地址可以通过公式
// 内存地址计算示例(概念演示,非实际代码)
// 假设 scores 数据区起始地址为 0x1000,每个 int 占 4 字节
// scores[0] 地址 = 0x1000 + 0 × 4 = 0x1000
// scores[1] 地址 = 0x1000 + 1 × 4 = 0x1004
// scores[2] 地址 = 0x1000 + 2 × 4 = 0x1008
// ...
// scores[n] 地址 = 0x1000 + n × 4
length 属性
length 是数组对象的 public final 成员变量,不是方法。它存储了数组的长度,创建时确定,不可更改。
int[] scores = {85, 92, 78, 95, 88};
System.out.println(scores.length); // 输出 5 —— 注意:没有括号,不是 length()
小崔的疑问:"为什么
String用length()方法,而数组用length属性?"白歌解答:
String.length()是一个方法,因为String类的实现可能变化(如 JDK 9 后改用byte[]替代char[]存储),方法提供了封装。而数组的length是 JVM 在创建数组对象时写入的一个不可变字段,直接暴露即可。这是语言层面的设计——数组在 Java 中是特殊的对象,length由 JVM 直接维护。
数组遍历
方式一:标准 for 循环
适用于需要操作下标的场景(修改元素、部分遍历、逆序遍历等)。
// 语法格式
for (int i = 0; i < 数组名.length; i++) {
// 通过 数组名[i] 访问元素
}
// 示例:计算飞翔科技第一季度员工工资总和
double[] salaries = {12000.0, 15000.0, 9800.0, 13200.0, 16800.0};
double total = 0.0;
for (int i = 0; i < salaries.length; i++) {
total += salaries[i];
}
System.out.println("第一季度工资总额:" + total);
方式二:增强 for 循环(for-each / foreach)
JDK 5 引入,适用于只读遍历全部元素的场景。
// 语法格式
for (数据类型 变量名 : 数组名) {
// 变量名代表当前元素的值(副本)
}
// 示例:打印所有员工姓名
String[] names = {"大翔", "白歌", "小崔", "孔蓝"};
for (String name : names) {
System.out.println(name);
}
两种遍历方式对比:
| 对比维度 | 标准 for 循环 | 增强 for 循环 |
|---|---|---|
| 是否可获取下标 | 是(i) | 否 |
| 是否可修改元素 | 是(arr[i] = xxx) | 否:操作的是副本 |
| 是否可部分遍历 | 是(控制 i 的范围) | 否:遍历全部 |
| 是否可逆序遍历 | 是 | 否 |
| 代码简洁度 | 一般 | 简洁 |
| 适用场景 | 需要下标操作 | 只读遍历全部元素 |
| 底层实现 | 直接访问 | 编译后仍为普通 for(数组类型)或 Iterator(Iterable 类型) |
底层原理:增强 for 循环在编译时对数组类型会退化为标准 for 循环(索引遍历),对
Iterable类型则使用迭代器。因此对数组而言,增强 for 循环没有性能损耗,但失去了下标控制能力。
完整代码示例
示例一:学生成绩分析系统
场景:飞翔科技为某 985 高校开发学生成绩管理系统。小崔需要用数组存储 6 名学生的 Java 课程成绩,并完成统计分析。
/**
* 飞翔科技 —— 学生成绩分析系统
* 演示一维数组的基本操作:遍历、求和、求最值、求平均
*/
public class ScoreAnalyzer {
public static void main(String[] args) {
// 静态初始化:6名学生的Java成绩
int[] scores = {85, 92, 78, 95, 88, 73};
// 1. 使用标准 for 循环计算总分和平均分
int sum = 0;
int max = scores[0];
int min = scores[0];
for (int i = 0; i < scores.length; i++) {
sum += scores[i];
if (scores[i] > max) {
max = scores[i];
}
if (scores[i] < min) {
min = scores[i];
}
}
double average = (double) sum / scores.length;
System.out.println("========== 飞翔科技学生成绩分析 ==========");
System.out.println("学生人数:" + scores.length);
System.out.println("总 分:" + sum);
System.out.printf("平均分:%.2f\n", average);
System.out.println("最高分:" + max);
System.out.println("最低分:" + min);
// 2. 使用增强 for 循环统计及格人数
int passCount = 0;
for (int score : scores) {
if (score >= 60) {
passCount++;
}
}
System.out.println("及格人数:" + passCount);
System.out.println("及格率:" + String.format("%.1f%%", 100.0 * passCount / scores.length));
// 3. 打印成绩详情(带序号)
System.out.println("\n====== 成绩详情 ======");
for (int i = 0; i < scores.length; i++) {
String level;
if (scores[i] >= 90) {
level = "优秀";
} else if (scores[i] >= 80) {
level = "良好";
} else if (scores[i] >= 70) {
level = "中等";
} else if (scores[i] >= 60) {
level = "及格";
} else {
level = "不及格";
}
System.out.printf("学号 %d:%d 分 (%s)\n", i + 1, scores[i], level);
}
}
}
运行输出:
========== 飞翔科技学生成绩分析 ==========
学生人数:6
总 分:511
平均分:85.17
最高分:95
最低分:73
及格人数:6
及格率:100.0%
====== 成绩详情 ======
学号 1:85 分 (良好)
学号 2:92 分 (优秀)
学号 3:78 分 (中等)
学号 4:95 分 (优秀)
学号 5:88 分 (良好)
学号 6:73 分 (中等)
示例二:飞翔科技"工资条"系统
场景:技术总监大翔要求小崔写一个程序,输入各部门的月度工资数据,计算工资排名(升序展示),并找出中位数。
import java.util.Arrays;
/**
* 飞翔科技 —— 工资条系统
* 演示动态初始化、排序(手动冒泡)、中位数计算
*/
public class SalaryReport {
public static void main(String[] args) {
// 动态初始化:预留10个工资数据的位置,先填充5个
double[] salaries = new double[10];
salaries[0] = 12500.0; // 大翔
salaries[1] = 15000.0; // 白歌
salaries[2] = 8200.0; // 小崔
salaries[3] = 10500.0; // 孔蓝
salaries[4] = 9300.0; // 其他员工
int employeeCount = 5; // 实际员工数
System.out.println("========== 飞翔科技2024年6月工资条 ==========");
String[] names = {"大翔", "白歌", "小崔", "孔蓝", "李明"};
System.out.println("\n原始工资数据:");
for (int i = 0; i < employeeCount; i++) {
System.out.printf(" %s:¥%.2f\n", names[i], salaries[i]);
}
// 手动冒泡排序(升序)—— 理解排序本质
double[] sortedSalaries = Arrays.copyOf(salaries, employeeCount);
for (int i = 0; i < sortedSalaries.length - 1; i++) {
for (int j = 0; j < sortedSalaries.length - 1 - i; j++) {
if (sortedSalaries[j] > sortedSalaries[j + 1]) {
double temp = sortedSalaries[j];
sortedSalaries[j] = sortedSalaries[j + 1];
sortedSalaries[j + 1] = temp;
}
}
}
System.out.println("\n工资排名(升序):");
for (int i = 0; i < sortedSalaries.length; i++) {
System.out.printf(" 第%d名:¥%.2f\n", i + 1, sortedSalaries[i]);
}
// 计算中位数
double median;
int mid = sortedSalaries.length / 2;
if (sortedSalaries.length % 2 == 0) {
median = (sortedSalaries[mid - 1] + sortedSalaries[mid]) / 2.0;
} else {
median = sortedSalaries[mid];
}
System.out.printf("\n工资中位数:¥%.2f\n", median);
// 计算总支出和平均工资
double total = 0.0;
for (int i = 0; i < employeeCount; i++) {
total += salaries[i];
}
System.out.printf("本月工资总支出:¥%.2f\n", total);
System.out.printf("平均工资:¥%.2f\n", total / employeeCount);
}
}
运行输出:
========== 飞翔科技2024年6月工资条 ==========
原始工资数据:
大翔:¥12500.00
白歌:¥15000.00
小崔:¥8200.00
孔蓝:¥10500.00
李明:¥9300.00
工资排名(升序):
第1名:¥8200.00
第2名:¥9300.00
第3名:¥10500.00
第4名:¥12500.00
第5名:¥15000.00
工资中位数:¥10500.00
本月工资总支出:¥55500.00
平均工资:¥11100.00
数组下标越界:ArrayIndexOutOfBoundsException
这是 Java 初学者最常遇到的运行时异常之一。
根本原因
数组下标超出了 [0, length-1] 的合法范围。这是 JVM 在运行时抛出的异常——编译器无法提前发现下标越界问题(因为下标通常是运行时计算出来的)。
易错场景
// ❌ 反例一:for 循环条件写成 <= length
int[] scores = {85, 92, 78};
for (int i = 0; i <= scores.length; i++) { // 错误!应该是 i < scores.length
System.out.println(scores[i]); // i=3 时抛出 ArrayIndexOutOfBoundsException
}
// ❌ 反例二:循环条件中错误使用 length - 1
for (int i = 0; i < scores.length - 1; i++) { // 漏掉了最后一个元素!
System.out.println(scores[i]); // 不会报异常,但数据不完整
}
// ❌ 反例三:空数组访问
int[] empty = new int[0]; // 长度为0的数组(合法,但不是null)
System.out.println(empty[0]); // ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
白歌的经验:写出
<=是新手最常见的错误。记住口诀——"零下标,开区间":[0, length),左闭右开,永远是i < length。
空指针异常:NullPointerException
这是 Java 中最"臭名昭著"的异常。对于数组而言,空指针的发生场景非常明确。
数组中的三种空指针场景
// ❌ 场景一:声明了但未初始化
int[] scores;
System.out.println(scores[0]);
// 编译错误!Variable 'scores' might not have been initialized
// 局部变量必须显式初始化
// ❌ 场景二:显式赋值为 null
int[] scores = null;
System.out.println(scores.length);
// NullPointerException: Cannot read the array length because "scores" is null
// ❌ 场景三:方法返回 null 数组
public static int[] getScores() {
return null; // 某些情况下返回了 null
}
int[] result = getScores();
System.out.println(result[0]); // NullPointerException!
防御性编程
// ✅ 正确:访问数组前进行 null 检查
int[] scores = getScores();
if (scores != null && scores.length > 0) {
System.out.println(scores[0]);
} else {
System.out.println("暂无成绩数据");
}
// ✅ 更好:让方法返回空数组而非 null
public static int[] getScores() {
// 没有数据时返回空数组,而不是 null
return new int[0]; // 长度0的空数组,不是 null
}
白歌的建议:在团队代码规范中,约定方法返回数组时永远不要返回 null,返回空数组
new int[0]即可。调用方不需要防御 null,代码更简洁。
易错场景汇总
反例一:数组的赋值是引用传递
小崔以为数组赋值是复制,结果两个变量指向了同一个数组对象:
// ❌ 错误:数组赋值是引用复制,不是元素复制
int[] scores1 = {85, 92, 78};
int[] scores2 = scores1; // scores2 指向了 scores1 的同一个数组对象!
scores2[0] = 100; // 修改 scores2 的同时也修改了 scores1
System.out.println(scores1[0]); // 输出 100,而非 85!
// ✅ 正确:使用 Arrays.copyOf 或手动复制元素
int[] scores1 = {85, 92, 78};
int[] scores2 = Arrays.copyOf(scores1, scores1.length); // 真正的复制
scores2[0] = 100;
System.out.println(scores1[0]); // 输出 85,不受影响
反例二:增强 for 循环中的"无效修改"
// ❌ 错误:增强 for 循环中修改元素无效
int[] scores = {85, 92, 78};
for (int score : scores) {
score = score + 10; // score 是局部变量副本,不影响数组元素
}
System.out.println(scores[0]); // 仍是 85!
// ✅ 正确:用标准 for 循环修改
for (int i = 0; i < scores.length; i++) {
scores[i] = scores[i] + 10;
}
System.out.println(scores[0]); // 输出 95
反例三:混淆 length 和 length()
// ❌ 错误:对数组调用 length() 方法
int[] arr = {1, 2, 3};
System.out.println(arr.length()); // 编译错误!数组没有 length() 方法
// ✅ 正确
System.out.println(arr.length); // 输出 3 —— 注意没有括号
// 对比:String 是 length() 方法
String str = "hello";
System.out.println(str.length()); // 输出 5 —— 有括号
面试考点
Q1:Java 中数组是基本类型还是引用类型?为什么?
数组是引用类型。证据有三: ① 数组变量存储在栈上,数组对象存储在堆上,变量存的是引用; ② 数组赋值是引用传递(浅拷贝),修改副本会影响原数组; ③ 数组可以赋值为
null,基本类型不能。即使数组存储的是
int等基本类型元素,数组本身也仍是引用类型——int[]是一个对象类型,它的 Class 对象是int[].class。
Q2:int[] arr = new int[0] 和 int[] arr = null 有什么区别?
new int[0]:在堆上创建了一个长度为 0 的数组对象,占用一些内存(对象头 + length字段),arr指向该对象,可以安全地调用arr.length(返回 0)和在增强 for 循环中使用(不执行循环体)。null:arr不指向任何对象,任何访问操作(arr.length、arr[0]、for-each)都会抛出NullPointerException。最佳实践:返回空数组时使用
new int[0],调用方无需防御 null。
Q3:数组的 length 属性可以改变吗?为什么?
不能。
length是数组对象的一个public final字段,由 JVM 在数组创建时写入,之后不可修改。这也意味着数组大小一旦确定就无法改变。如果需要动态扩容,应使用ArrayList或手动Arrays.copyOf()创建新数组。
Q4:增强 for 循环遍历数组时,底层是如何实现的?
对数组类型,增强 for 循环在编译后会被 javac 翻译为等价的标准 for 循环(索引遍历),不存在性能差异:
// 源代码 for (int x : arr) { System.out.println(x); } // 编译后等价于 for (int i = 0; i < arr.length; i++) { int x = arr[i]; System.out.println(x); }对
Iterable类型(如ArrayList),则翻译为迭代器模式。数组走索引方式,因此数组的 for-each 效率与原生的 for-i 完全一致。
Q5:如何判断一个引用是数组类型?
使用
instanceof或反射:Object obj = new int[]{1, 2, 3}; System.out.println(obj instanceof int[]); // true System.out.println(obj.getClass().isArray()); // true System.out.println(obj.getClass().getComponentType()); // int
白歌的结语:"一维数组是 Java 数据结构的起点。当你真正理解了数组在堆栈中的布局、length 的不可变性、增强 for 循环的本质以及空指针的防御,你就已经迈过了新手阶段。下一节,我们将把维度扩展——进入多维数组的世界。"