ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

重拾Java 三

重拾Java 三

一、Java方法

1.什么是方法

System.out.println()
Java的方法是语句的集合,它们一起执行一个功能
方法是解决一类问题的步骤的有序组合
方法包含于类或对象中
方法在程序中被创建,在其他地方被引用
设计方法的原则:方法的本意是功能块,就是实现某个功能的语句块的集合。我们设计方法的时候,最好保持方法的原子性,就是一个方法只完成 1 个功能,这样利于我们后期的扩展。

2.方法的定义及调用(重要)

方法的定义

◆ Java的方法类似于其它语言的函数,是一段用来完成特定功能的代码片段,一般情况下,定义一个方法包含以下语法:

◆方法包含一个方法头和一个方法体。 下面是一个方法的所有部分:

修饰符:修饰符,这是可选的,告诉编译器如何调用该方法。定义了该方法的访问类型。

返回值类型:方法可能会返回值。returnValueType 是方法返回值的数据类型。有些方法执行所需的操作,但没有返回值。在这种情况下,returnValueType 是关键字void。

方法名:是方法的实际名称。方法名和参数表共同构成方法签名。

参数类型:参数像是一个占位符。当方法被调用时,传递值给参数。这个值被称为实参或变量。参数列表是指方法的参数类型、顺序和参数的个数。参数是可选的,方法可以不包含任何参数。

形式参数:在方法被调用时用于接收外界输入的数据。

实参:调用方法时实际传给方法的数据。

方法体:方法体包含具体的语句,定义该方法的功能。

修饰符 返回值类型 方法名(参数类型 参数名){ ... 方法体 ... return 返回值; }

3.方法重载(重要)

  • 重载就是在一个类中,有相同的函数名称,但形参不同的函数。
  • 方法的重载的规则:
    • 方法名称必须相同。
    • 参数列表必须不同(个数不同、或类型不同、参数排列顺序不同等)。
    • 方法的返回类型可以相同也可以不相同。
    • 仅仅返回类型不同不足以成为方法的重载。
  • 实现理论:
    • 方法名称相同时,编译器会根据调用方法的参数个数、参数类型等去逐个匹配,以选择对应的方法,如果匹配失败,则编译器报错。

下面用一段代码演示方法重载:同一个类中定义了两个同名方法max,一个接收double参数,一个接收int参数,编译器会根据传入实参的类型自动匹配对应的方法。

package com.fan.method; public class Demo02 { public static void main(String[] args) { int max = max(10, 10); System.out.println(max); System.out.println(max(4, 6)); } // 比大小:double 版本 public static double max(double num1, double num2) { double result = 0; if (num1 == num2) { System.out.println("num1=num2"); return 0; } if (num1 > num2) { result = num1; } else { result = num2; } return result; } // 比大小:int 版本 public static int max(int num1, int num2) { int result = 0; if (num1 == num2) { System.out.println("num1=num2"); return 0; } if (num1 > num2) { result = num1; } else { result = num2; } return result; } }

要点说明:

  • 两个max方法名称相同,但参数类型不同(double与int),符合重载规则。
  • 调用max(10, 10)时传入的是两个int字面量,编译器会匹配int版本,返回值为int,可以直接赋给int max。
  • 调用max(4, 6)同样匹配int版本,输出结果为 6。
  • 如果传入max(4.0, 6.0),则会匹配double版本,返回值为double类型。
  • 两个方法在参数相等时都会打印num1=num2并返回 0,这是重载方法各自独立的实现,互不影响。
  • 重载只与参数列表有关,与返回值类型无关;如果两个方法只有返回值类型不同,编译器会直接报错。
  • 要以理解为主,后续会经常使用

4.命令行传参

命令行传参指的是在运行程序时,通过命令行把参数传递给main方法。前面已经看到main方法的签名是public static void main(String[] args),其中的String[] args就是用来接收命令行参数的字符串数组。

先写一个最简单的示例,把接收到的参数逐个打印出来:

package com.fan.method; public class Demo03 { public static void main(String[] args) { // args 数组用来接收命令行参数 for (int i = 0; i < args.length; i++) { System.out.println("args[" + i + "]:" + args[i]); } } }

运行步骤:

  1. 先编译源文件,在源码所在目录执行javac Demo03.java,生成Demo03.class。
  2. 回到包所在的根目录(即com目录的上一级),执行java com.fan.method.Demo03 参数1 参数2。
  3. 控制台会依次输出args[0]:参数1、args[1]:参数2。

要点说明:

  • 命令行参数以空格分隔,每个参数对应args数组中的一个元素,参数本身包含空格时需要用引号包裹。
  • 运行时必须写类的全限定名(包名 + 类名),不能直接写java Demo03,否则会提示找不到或无法加载主类。
  • 如果没有传入任何参数,args.length为 0,循环体不会执行,程序正常结束。
  • 直接使用args[0]取值前,最好先判断args.length,避免数组下标越界异常。
  • 在 IDEA 中调试时,可以在运行配置的 Program arguments 一栏填写参数,效果与命令行传参一致。

5.可变参数(重要)

在方法声明中,在指定参数类型后加一个省略号 (...)。

一个方法中只能指定一个可变参数,它必须是方法的最后一个参数。任何普通的参数必须在它之前声明。

可变参数(也叫可变长参数)允许调用方法时传入任意个数的同类型实参,方法内部会把这些实参自动封装成一个数组来处理。它的语法是在参数类型后面加三个点...,例如int... i、double... numbers。

先看第一个示例:定义一个接收可变参数的方法,调用时传入 5 个整数,方法内部按数组下标取值。

package com.fan.method; public class Demo04 { public static void main(String[] args) { Demo04 demo04 = new Demo04(); demo04.test(1, 2, 32, 16, 16); } public void test(int... i) { // 可变长参数必须放在最后 System.out.println(i[0]); System.out.println(i[1]); System.out.println(i[2]); System.out.println(i[3]); System.out.println(i[4]); } }

要点说明:

  • 调用test(1, 2, 32, 16, 16)时,这 5 个实参会被自动打包成int[]数组,方法内部可以像操作普通数组一样用i[0]、i[1]取值。
  • 可变参数本质上就是一个数组,因此test(new int[]{1, 2, 32, 16, 16})这种直接传数组的写法也是合法的。
  • 可变参数必须是参数列表中的最后一个参数,否则编译报错;一个方法中最多只能有一个可变参数。
  • 如果可变参数前面还有普通参数,普通参数必须写在可变参数之前,例如test(String name, int... i)。
  • 方法内部按下标取值时要注意数组长度,传入的实参个数不足会抛出数组下标越界异常。

第二个示例更实用:在任意个double数中找出最大值,演示可变参数配合循环遍历的常见用法。

package com.fan.method; public class Demo05 { public static void main(String[] args) { printMax(15, 321.1, 15.0, 15.3); } public static void printMax(double... numbers) { if (numbers.length == 0) { System.out.println("No argument passed"); return; } double result = numbers[0]; // 排序! for (int i = 1; i < numbers.length; i++) { if (numbers[i] > result) { result = numbers[i]; } } System.out.println("The max value is " + result); } }

要点说明:

  • 调用printMax(15, 321.1, 15.0, 15.3)时,4 个实参被封装成double[]数组,numbers.length为 4。
  • 先判断numbers.length == 0,没有传参时直接提示并return,避免后面访问numbers[0]时越界。
  • 把第一个元素numbers[0]作为初始最大值,再从下标 1 开始逐个比较,遇到更大的值就更新result,这是求最值的通用写法。
  • 本例输出结果为The max value is 321.1。
  • 可变参数让方法调用更灵活,不用提前创建数组,也不用为不同参数个数写多个重载方法。

6.递归

A 方法调用 B 方法,我们很容易理解!
递归就是:A 方法调用 A 方法!就是自己调用自己

利用递归可以用简单的程序来解决一些复杂的问题。它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的能力在于用有限的语句来定义对象的无限集合。

递归结构包括两个部分:
递归头:什么时候不调用自身方法。如果没有头,将陷入死循环。
递归体:什么时候需要调用自身方法。

递归的核心是「自己调用自己」,但必须要有终止条件,否则会不断压栈直到栈空间耗尽。下面用求阶乘的例子来演示递归的完整写法。

package com.fan.method; public class Demo06 { public static void main(String[] args) { Demo06 test = new Demo06(); // StackOverflowError 栈溢出异常 System.out.println(f(6)); } public static int f(int n) { // n 的阶乘 if (n == 1) { return 1; } else { return n * f(n - 1); } } }

要点说明:

  • if (n == 1) return 1;就是递归头,也叫边界条件,它决定了递归什么时候停止。没有递归头,方法会一直调用自身,最终抛出StackOverflowError栈溢出异常。
  • return n * f(n - 1);就是递归体,把规模为 n 的问题转化为规模为 n-1 的同类问题,逐步向递归头靠近。
  • 以f(6)为例,执行过程是6 * f(5)→6 * 5 * f(4)→ … →6 * 5 * 4 * 3 * 2 * f(1),最终返回 720。
  • 每次递归调用都会在栈中压入一个栈帧,递归层数过深(例如把参数改成很大的数)就会导致栈溢出,所以递归必须有明确的终止条件,且递归深度不宜过大。
  • 递归能用少量代码描述重复计算,但性能上不如循环,能改写为循环的场景优先考虑循环。
  • 本例中Demo06 test = new Demo06();创建了对象但并未使用,因为f是静态方法,可以直接通过类名调用,写成Demo06.f(6)即可。

小练习

下面用前面学过的方法定义、方法调用和 switch 语句,写一个简易的控制台计算器:支持加、减、乘、除四种运算,输入 0 退出程序。

package com.fan.method; import java.util.Scanner; public class Demo07 { static double add(double a, double b) { return a + b; } static double sub(double a, double b) { return a - b; } static double mul(double a, double b) { return a * b; } static double div(double a, double b) { return a / b; } public static void main(String[] args) { Scanner s = new Scanner(System.in); while (true) { System.out.print("1+ 2- 3* 4/ 0退出,请输入:"); int op = s.nextInt(); if (op == 0) break; System.out.print("数1:"); double a = s.nextDouble(); System.out.print("数2:"); double b = s.nextDouble(); double r = 0; switch (op) { case 1: r = add(a, b); break; case 2: r = sub(a, b); break; case 3: r = mul(a, b); break; case 4: if (b == 0) { System.out.println("不能除0"); continue; } r = div(a, b); } System.out.println("结果=" + r); } s.close(); } }

要点说明:

  • 四个运算方法都声明为static,因此可以在main方法中直接按方法名调用,不需要先创建Demo07对象。
  • 用while (true)配合if (op == 0) break;实现循环菜单,输入 0 时跳出循环并结束程序。
  • switch根据运算符编号分发到对应的方法,每个case处理完要写break,否则会继续执行后面的分支。
  • 除法分支先判断b == 0,为 0 时提示「不能除0」并用continue跳过本次循环,避免出现Infinity或异常。
  • 四个方法都返回double,所以统一用double r接收结果,整数运算也会自动提升为double输出。
  • 程序结束时调用s.close()关闭Scanner,释放输入流资源,这是良好的编程习惯。
  • 可以进一步扩展:把四个方法改成重载形式,或增加取余、幂运算等分支,练习方法定义与 switch 的组合使用。

二、Java数组

1.什么是数组

数组是相同类型数据的有序集合。

数组描述的是相同类型的若干个数据,按照一定的先后次序排列组合而成。

其中,每一个数据称作一个数组元素,每个数组元素可以通过一个下标来访问它们。

举例来说,如果声明一个保存 5 个学生成绩的数组,那么这 5 个成绩在内存中是连续存放的,可以通过scores[0]、scores[1]一直到scores[4]来访问,下标从 0 开始计数。

2.数组声明创建

使用数组需要两步:先声明一个数组变量,再为它创建(分配)实际的内存空间。

// 声明数组的两种写法 int[] nums; // 推荐写法,类型和变量名分离更清晰 int scores[]; // C/C++ 风格,Java 也支持,但不推荐 // 创建数组:使用 new 关键字分配空间 nums = new int[10]; // 创建一个长度为 10 的 int 数组,元素默认值为 0 // 声明和创建合并写法 int[] arr = new int[5]; // 声明的同时初始化 int[] a = {1, 2, 3, 4, 5}; int[] b = new int[]{1, 2, 3, 4, 5};

要点说明:

  • 声明数组时只是定义了一个引用变量,此时并没有分配数组元素的内存空间,变量值为null。
  • 使用new创建数组后,JVM 才会在堆中分配连续的内存空间,并把数组对象的引用赋给变量。
  • 数组长度在创建时通过new int[10]中的数字指定,也可以通过数组名.length获取。
  • 创建后如果没有显式赋值,元素会有默认值:整型为 0,浮点型为 0.0,布尔型为 false,引用类型为 null。
  • 数组下标从 0 开始,长度为 n 的数组,合法下标范围是 0 到 n-1,越界会抛出ArrayIndexOutOfBoundsException。

3.数组的四个基本特点

  • 长度是确定的:数组一旦被创建,它的大小就是不可以改变的。
  • 元素类型必须相同:不允许出现混合类型,一个int[]中不能存放String。
  • 元素可以是任何数据类型:包括基本类型和引用类型,例如int[]、double[]、String[]、Object[]。
  • 数组变量属引用类型:数组也可以看成是对象,数组中的每个元素相当于该对象的成员变量。数组本身就是对象,Java 中对象是在堆中的,因此数组无论保存原始类型还是其他对象类型,数组对象本身是在堆中的。

4.Java 内存分析

理解数组的存储方式,需要先了解 Java 运行时数据区的三个核心部分:

  • 堆(Heap):存放new出来的对象和数组,由 JVM 统一管理,所有线程共享,是垃圾回收的主要区域。
  • 栈(Stack):存放方法调用时的局部变量、基本类型变量和对象的引用,每个线程有独立的栈,方法执行完自动释放。
  • 方法区(Method Area):存放类的元信息、常量、静态变量等,所有线程共享。

下面用一段代码演示数组在内存中的分布:

package com.fan.array; public class Demo01 { public static void main(String[] args) { int[] nums = new int[3]; // 栈中 nums 保存引用,堆中分配 3 个 int 空间 nums[0] = 1; nums[1] = 2; nums[2] = 3; // 数组也是对象,可以调用 length 属性 System.out.println(nums.length); // 输出 3 // 引用赋值:两个变量指向同一个堆中的数组对象 int[] nums2 = nums; nums2[0] = 100; System.out.println(nums[0]); // 输出 100 } }

要点说明:

  • int[] nums = new int[3];执行时,栈中保存变量nums,堆中分配一块连续空间存放 3 个 int 元素,nums中保存的是这块空间的首地址。
  • nums2 = nums只是把引用复制了一份,两个变量指向同一个数组对象,因此通过nums2修改元素,nums看到的也是修改后的值。
  • 数组对象本身在堆中,方法结束后栈中的局部变量被回收,堆中的数组对象由垃圾回收器在合适时机回收。
  • 如果数组变量没有指向任何对象(例如int[] arr = null;),访问arr.length会抛出NullPointerException。

5.数组使用

数组最常见的操作是遍历、求最值和作为方法参数传递。

package com.fan.array; public class Demo02 { public static void main(String[] args) { int[] arrays = {1, 2, 3, 4, 5}; // 1. 普通 for 循环遍历,可以拿到下标 for (int i = 0; i < arrays.length; i++) { System.out.println("arrays[" + i + "] = " + arrays[i]); } // 2. 增强 for 循环(for-each),适合只取值不需要下标 for (int array : arrays) { System.out.println(array); } // 3. 数组作为方法参数 printArray(arrays); // 4. 求最大值 System.out.println("最大值:" + max(arrays)); } // 打印数组 public static void printArray(int[] arrays) { for (int i = 0; i < arrays.length; i++) { System.out.print(arrays[i] + " "); } System.out.println(); } // 求数组最大值 public static int max(int[] arrays) { int result = arrays[0]; for (int i = 1; i < arrays.length; i++) { if (arrays[i] > result) { result = arrays[i]; } } return result; } }

要点说明:

  • 普通for循环通过下标访问元素,适合需要下标的场景,例如倒序输出、按位置修改元素。
  • 增强for循环写法更简洁,但拿不到下标,也不能在循环中修改数组元素的值。
  • 数组作为方法参数传递的是引用,方法内部修改元素会影响原数组。
  • 求最大值时先把arrays[0]作为初始值,再从下标 1 开始比较,避免把初始值写成 0 导致全负数数组结果错误。
  • 遍历前要保证数组不为null,否则arrays.length会抛出空指针异常。

6.多维数组

多维数组可以看成是数组的数组,最常见的是二维数组,例如一个班级多名学生多门课的成绩表。

package com.fan.array; public class Demo03 { public static void main(String[] args) { // 二维数组:3 行 2 列 int[][] arrays = {{1, 2}, {3, 4}, {5, 6}}; // 遍历二维数组 for (int i = 0; i < arrays.length; i++) { for (int j = 0; j < arrays[i].length; j++) { System.out.print(arrays[i][j] + " "); } System.out.println(); } // 动态创建二维数组 int[][] nums = new int[3][4]; // 3 行 4 列,元素默认值为 0 System.out.println(nums[0][0]); // 输出 0 } }

要点说明:

  • int[][] arrays可以理解为「数组的数组」,arrays.length是行数,arrays[i].length是第 i 行的列数。
  • 二维数组的每一行长度可以不同,例如int[][] a = new int[3][];之后再分别给每行分配长度,这就是不规则数组。
  • 遍历二维数组需要两层循环,外层控制行,内层控制列。
  • 创建二维数组时只指定行数不指定列数,此时每行是null,直接访问元素会抛出空指针异常。

7.Arrays 类

java.util.Arrays是 JDK 提供的数组工具类,封装了排序、查找、比较、填充、转字符串等常用操作。

package com.fan.array; import java.util.Arrays; public class Demo04 { public static void main(String[] args) { int[] a = {1, 5, 3, 9, 7}; // 1. 打印数组内容 System.out.println(Arrays.toString(a)); // 2. 排序(默认升序) Arrays.sort(a); System.out.println(Arrays.toString(a)); // 3. 二分查找,必须先排序 int index = Arrays.binarySearch(a, 5); System.out.println("5 的下标:" + index); // 4. 填充 int[] b = new int[5]; Arrays.fill(b, 8); System.out.println(Arrays.toString(b)); // 5. 比较两个数组内容是否相等 System.out.println(Arrays.equals(a, b)); } }

要点说明:

  • Arrays.toString()可以直接把数组内容格式化为[1, 3, 5, 7, 9]的形式,比手写循环方便。
  • Arrays.sort()对基本类型数组使用双轴快速排序,对引用类型数组使用归并排序,默认都是升序。
  • Arrays.binarySearch()使用二分查找,前提是数组已经排好序,否则结果不确定。
  • Arrays.fill()可以把数组所有元素填充为同一个值,常用于初始化。
  • Arrays.equals()比较的是数组内容,而直接用==比较的是两个数组的引用地址。

8.稀疏数组

当一个数组中大部分元素为 0 或为同一值时,可以使用稀疏数组来保存,从而压缩存储空间。稀疏数组的处理方式是:记录数组一共有几行几列、有多少个不同的值,然后把具有不同值的元素的行、列、值记录在一个小规模的数组中。

package com.fan.array; public class Demo05 { public static void main(String[] args) { // 1. 创建一个 11 行 11 列的棋盘,0 表示空,1 表示黑子,2 表示白子 int[][] chessArr = new int[11][11]; chessArr[1][2] = 1; chessArr[2][3] = 2; // 2. 统计非 0 元素个数 int sum = 0; for (int[] row : chessArr) { for (int data : row) { if (data != 0) { sum++; } } } // 3. 创建稀疏数组:第一行记录原始数组的行数、列数、有效值个数 int[][] sparseArr = new int[sum + 1][3]; sparseArr[0][0] = 11; sparseArr[0][1] = 11; sparseArr[0][2] = sum; // 4. 遍历原始数组,把非 0 元素的行、列、值存入稀疏数组 int count = 0; for (int i = 0; i < 11; i++) { for (int j = 0; j < 11; j++) { if (chessArr[i][j] != 0) { count++; sparseArr[count][0] = i; sparseArr[count][1] = j; sparseArr[count][2] = chessArr[i][j]; } } } // 5. 输出稀疏数组 for (int i = 0; i < sparseArr.length; i++) { System.out.println(sparseArr[i][0] + "\t" + sparseArr[i][1] + "\t" + sparseArr[i][2]); } } }

要点说明:

  • 稀疏数组的第一行固定记录原始数组的行数、列数和有效值个数,后面的每一行记录一个有效值的行下标、列下标和值。
  • 稀疏数组的行数为「有效值个数 + 1」,列数固定为 3。
  • 稀疏数组适合有效数据远少于总容量的场景,例如棋盘、地图、矩阵;如果数组元素大部分都有值,使用稀疏数组反而更占空间。
  • 稀疏数组可以保存到文件中,实现数据的持久化,读取时再还原成原始二维数组。
  • 还原时先读取第一行得到原始数组的行列数,再按后续每行的行列下标把值填回去。

9.冒泡排序

冒泡排序(Bubble Sort)是最经典的排序算法之一,也是理解「双重循环 + 元素交换」的最佳入门案例。它的名字来源于排序过程中,较大的元素会像气泡一样一点点「浮」到数组末尾。

算法思路:

  1. 比较数组中两个相邻的元素,如果第一个数比第二个数大,就交换它们的位置。
  2. 每一轮比较结束后,都会产生一个最大(或最小)的数,被放到数组的末尾。
  3. 下一轮比较时,末尾已经排好的元素不再参与,因此比较次数少一次。
  4. 重复上述过程,直到没有元素需要交换为止。

下面用一段完整代码演示冒泡排序:先用Arrays.toString()打印初始数组,再调用自己写的sort方法排序,最后打印排序后的结果。

package com.fan.array; import java.util.Arrays; public class Demo08 { public static void main(String[] args) { int[] a = {1, 15, 15, 6, 468, 13, 16, 156, 651, 65, 165}; System.out.println(Arrays.toString(a)); // 初始的 a 数组 int[] sort = sort(a); // 调用自己写的排序算法 System.out.println(Arrays.toString(sort)); // 使用自己写的排序算法后的 a 数组 } // 冒泡排序 // 1. 比较数组中两个相邻的元素,如果第一个数比第二个数大,交换他们的位置 // 2. 每次比较,都会产生一个最大,或者一个最小数 // 3. 下一次就少一次排序 // 4. 循环到结束 public static int[] sort(int[] a) { int temp = 0; // 外层循环,判断我们这个要走多少次 for (int i = 0; i < a.length - 1; i++) { boolean flag = false; // 内层循环,比较两个数,如果第一个数比第二个数大,则交换位置 for (int j = 0; j < a.length - 1 - i; j++) { if (a[j] > a[j + 1]) { temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; flag = true; } } if (flag == false) { break; } } return a; } }

要点说明:

  • 外层循环for (int i = 0; i < a.length - 1; i++)控制比较的轮数,长度为 n 的数组最多需要 n-1 轮。
  • 内层循环for (int j = 0; j < a.length - 1 - i; j++)控制每轮比较的次数,- i表示每轮结束后末尾已经排好的元素不再参与比较。
  • a[j] > a[j + 1]是升序判断条件,如果改成<就变成降序排序。
  • 交换两个元素需要借助临时变量temp,先把a[j]存起来,再依次赋值,否则会丢失数据。
  • flag是优化标记:如果某一轮比较中没有发生任何交换,说明数组已经有序,直接break跳出循环,减少不必要的比较。
  • 冒泡排序的时间复杂度为 O(n²),空间复杂度为 O(1),数据量较大时效率不高,实际开发中更常用Arrays.sort()。
  • 冒泡排序是稳定的排序算法,相等元素在排序前后的相对位置不会改变。

执行过程示例:以数组{3, 1, 2}为例,第一轮比较3和1交换得到{1, 3, 2},再比较3和2交换得到{1, 2, 3},此时最大值 3 已经「浮」到末尾;第二轮只需比较前两个元素,发现已经有序,flag保持false,循环提前结束。


三、面向对象


四、异常

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进