这是 Java 入门系列的第五天。前四天我们的变量一次只能装一个值,今天学习第一个”容器”——数组:一次声明就能管理一批同类型数据,它也是后面所有集合框架的底层基石。

本篇目标

  • 理解数组的内存模型:堆中连续空间 + 栈中引用
  • 掌握数组的三种定义与初始化方式
  • 独立完成遍历、取最值、反转、查找等基本操作
  • 会用 Arrays 工具类,理解二维数组”数组的数组”本质
  • 讲清 Java 参数传递为什么”基本类型不改、数组会改”

为什么需要数组

要存 50 个学生的成绩,定义 50 个 score1…score50 吗?显然不行——无法批量处理,也应对不了”人数不定”的情况。数组解决的就是这个问题:用一块连续的内存,存储同一种数据类型的多个值,通过下标统一访问。数组既能装基本类型,也能装引用类型。

数组的内存模型

先补一点 JVM 内存常识(入门阶段记住两块即可):

  • 栈:存放局部变量(方法里声明的变量),方法结束即弹栈销毁。
  • 堆:存放所有 new 出来的东西,由 JVM 管理,没有任何引用指向时才被垃圾回收。

看一行代码:

int[] arr = new int[3];

这行代码实际发生了两件事:在堆中开辟一块能容纳 3 个 int 的连续空间(每个 int 占 4 字节,共 12 字节),并自动填上默认值;在栈中声明引用变量 arr,把堆中那块空间的首地址存进去。arr 不是数组本身,而是指向数组的”遥控器”。

下标为什么从 0 开始? 因为下标的本质是偏移量。已知首地址,第 i 个元素的地址 = 首地址 + i × 每个元素的字节数。从 0 开始,arr[0] 的偏移就是 0,公式干干净净,一次乘法加一次加法就能定位任意元素——这正是数组随机访问是 O(1) 的原因。如果从 1 开始,每次寻址都得多做一次减 1 的修正,白白浪费。

动态初始化的默认值:整数类型默认 0,浮点默认 0.0,boolean 默认 false,char 默认 '\u0000',引用类型默认 null。直接打印 arr 会看到 [I@1b6d3586 这样的东西:[ 表示数组,I 表示 int 型,@ 后面是哈希值的十六进制(可近似看作地址标识)。

引用的别名效应:

int[] a = {1, 2, 3};
int[] b = a; // 把地址复制给 b,两个引用指向同一个数组
b[0] = 100;
System.out.println(a[0]); // 100:通过 a 访问也被改了

b = a 复制的不是数组内容,而是地址——栈上两个变量,堆上只有一个数组。

三种定义与初始化

int[] a = new int[3];               // 1. 动态初始化:指定长度,系统赋默认值
int[] b = new int[]{1, 2, 3}; // 2. 静态初始化完整版:给出值,长度由系统数
int[] c = {1, 2, 3}; // 3. 静态初始化简写版(声明赋值必须在同一行)

两个注意点:不能”动静结合”,new int[3]{1,2,3} 是语法错误;简写版不能拆成 int[] c; c = {1,2,3}; 两行。元素个数在编译期未知时用动态初始化(如长度来自用户输入),内容已知时用静态初始化。

两个经典异常

int[] arr = {1, 2, 3};
// System.out.println(arr[3]); // ArrayIndexOutOfBoundsException:访问了不存在的下标
arr = null; // 引用与堆中数组断开
// System.out.println(arr[0]); // NullPointerException:拿着空遥控器找电视

合法下标永远是 0 ~ arr.length - 1;空指针则是”引用还在、数组没了”。排查思路:先看异常类型,再看报错行号,回到代码检查下标来源和引用是否被置空。

四个基本操作

public class ArrayOps {
public static void main(String[] args) {
int[] arr = {12, 45, 7, 89, 23};

// 1. 遍历:length 是属性不是方法,最大下标是 length - 1
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i] + " ");
}

// 2. 取最大值:先假设第一个是冠军,再逐个接受挑战
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) {
max = arr[i];
}
}

// 3. 反转:首尾对称交换,只需走半程
for (int i = 0; i < arr.length / 2; i++) {
int temp = arr[i];
arr[i] = arr[arr.length - 1 - i];
arr[arr.length - 1 - i] = temp;
}

// 4. 顺序查找:找到返回下标,找不到返回 -1
int target = 7;
int index = -1;
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) {
index = i;
break;
}
}
System.out.println("\nmax=" + max + ", 7 的下标=" + index);
}
}

反转的循环条件是 i < arr.length / 2——走到中点就够了,再走一圈就换回去了。查找约定俗成返回 -1 表示没找到,因为 -1 不可能是合法下标。

Arrays:官方工具类

手写一遍是为了理解原理,开发中直接用 java.util.Arrays:

import java.util.Arrays;

public class ArraysDemo {
public static void main(String[] args) {
int[] arr = {3, 1, 4, 1, 5};
System.out.println(Arrays.toString(arr)); // [3, 1, 4, 1, 5],打印内容
Arrays.sort(arr); // 原地排序
System.out.println(Arrays.toString(arr)); // [1, 1, 3, 4, 5]
System.out.println(Arrays.binarySearch(arr, 4)); // 二分查找:输出 3,前提是数组有序
int[] copy = Arrays.copyOf(arr, 8); // 扩容复制,多余位补默认值
System.out.println(Arrays.toString(copy)); // [1, 1, 3, 4, 5, 0, 0, 0]
}
}

最容易踩的坑是 binarySearch:二分查找要求数组已经排好序,对无序数组调用它,结果是未定义的——要么先 sort,要么别用。

二维数组:数组的数组

Java 里没有真正的”矩阵”,二维数组的本质是一个元素为”一维数组引用”的一维数组:

int[][] arr = new int[3][2];

内存上,外层数组在堆中占 3 个槽,每个槽里存的不是数字,而是一个指向内层 int 数组的地址。正因为内层是独立的对象,二维数组的每一行长度可以不一样:

int[][] a = new int[3][];              // 只确定行数,每行暂时是 null
a[0] = new int[2];
a[1] = new int[4]; // 不规则数组完全合法
int[][] b = {{1, 2, 3}, {4, 5}, {6}}; // 静态初始化,每个大括号一行

遍历就是两层”外行内列”的循环:外循环拿到每一行,内循环遍历行内元素。

public class QuarterSales {
public static void main(String[] args) {
int[][] sales = {{22, 66, 44}, {77, 33, 88}, {25, 45, 65}, {11, 66, 99}};
int sum = 0;
for (int i = 0; i < sales.length; i++) { // 外循环:每个季度
for (int j = 0; j < sales[i].length; j++) { // 内循环:季度内各月
sum += sales[i][j];
}
}
System.out.println("全年销售额:" + sum + " 万元"); // 输出 640
}
}

注意内层条件是 sales[i].length——每行长度可能不同,写死长度正是越界异常的温床。

参数传递:为什么基本类型不改、数组会改

public class ParamPassing {
public static void main(String[] args) {
int x = 10;
change(x);
System.out.println(x); // 还是 10

int[] arr = {1, 2, 3};
change(arr);
System.out.println(arr[1]); // 变成 4
}

public static void change(int n) {
n = 99; // 改的只是副本
}

public static void change(int[] a) {
a[1] = 4; // 通过地址改的是同一个数组
}
}

Java 的参数传递永远是值传递,没有例外。区别只在于”值”是什么:基本类型传的是数据本身的拷贝,方法里改的只是一份副本,方法弹栈后无影无踪;数组传的是地址的拷贝,方法内外两个引用指向堆中同一个数组,所以方法里对元素的修改在方法返回后依然可见。把这个想通,就拿到了理解一切引用类型行为的总钥匙。

小结

  • 数组是堆中的连续空间,栈上的变量只是引用;下标从 0 开始是因为下标即偏移量,换来 O(1) 寻址。
  • 初始化三种写法:动态给长度、静态给内容、静态简写;不能动静结合。
  • 越界和空指针是数组的两大经典异常,合法下标是 0 ~ length - 1。
  • 遍历、最值、反转、查找要能默写;开发中打印、排序、查找交给 Arrays,binarySearch 前先排序。
  • 二维数组是”数组的数组”,各行可以不等长,内层循环条件用 arr[i].length。
  • 参数传递永远是值传递:基本类型传数据副本,数组传地址副本。