☰
Java面向对象进阶:类名接口参数、内部类与常用API实战解析
2026/10/15 1:23:57 网站建设 项目流程

说实话,Java基础阶段学到第12天,难度是突然往上跳了一截的。这天要啃的东西很杂但每个又很重要:类名、抽象类名、接口名作为形参和返回值,内部类那几种绕来绕去的写法,Math、System、toString()、Arrays这些常用API,再加上经典的冒泡排序。很多人在这一天会开始怀疑自己是不是不适合写代码,因为知识密度实在太大。这篇文章是我把当天内容重新整理、消化后的学习笔记,适合正在学Java面向对象、准备把“多态”往方法层面打通的人,也适合想快速复习这些零散知识点的初学者。

1. 类名、抽象类名、接口名作为形参和返回值:把“多态”彻底吃透

1.1 类名作为形参和返回值:传的是对象引用,不是类本身

先说一个最原始的问题:方法参数和返回值到底能不能写一个“类名”?当然能。但你传入的、返回的,永远是这个类创建出来的对象,而不是这个类的字节码文件本身。这里的“类名”只是参数类型、返回类型,真正流动的是对象的引用。

Java中的参数传递只有一种规则:值传递。基础类型传的是值的副本,引用类型传的是引用地址的副本。看这段代码:

class Dog { String name; void setName(String name) { this.name = name; } } public class Test { public void changeName(Dog dog) { dog.setName("旺财"); } public Dog createDog() { Dog dog = new Dog(); dog.setName("来福"); return dog; } }

调用changeName(dog)时,方法里的dog和外面的dog指向堆中同一个对象,所以方法内部改了name,外面的对象也会变。但如果方法内部写的是dog = new Dog(),那只是把方法里的引用指向了新对象,外面的引用还是指向原来的对象,两码事。这个细节面试经常考,理解之后很多“为什么方法里改了对象外面变了”的问题就不用死记了。

返回值用类名也是同理,createDog()返回的是Dog对象。要注意的是,如果方法返回null,调用方不做判空就直接调用对象方法,马上就是空指针异常。

1.2 抽象类作为形参和返回值:给代码留扩展口

抽象类不能new,那抽象类作为参数到底能接什么?答案是:该抽象类的任意子类对象。这是多态最常见的体现之一。写一个支付流程模拟:

abstract class Pay { public abstract void pay(double money); } class PayByCard extends Pay { @Override public void pay(double money) { System.out.println("卡支付:" + money); } } class PayByCash extends Pay { @Override public void pay(double money) { System.out.println("现金支付:" + money); } }

此时定义一个方法:

public void doPay(Pay pay, double money) { pay.pay(money); }

调用的时候可以传new PayByCard(),也可以传new PayByCash()。以后再加新的支付方式,比如PayByCode,doPay方法一行都不用改。如果参数写死成某个具体子类,每加一个支付方式就要重载一次方法,代码会越来越臃肿。

返回值也是一样。比如一个工厂方法:

public Pay createPay(int type) { if (type == 1) { return new PayByCard(); } return new PayByCash(); }

返回类型写成抽象类,调用方拿到的是Pay类型的引用。这样做的代价是,你通过这个引用只能调用抽象类中定义的方法。如果PayByCard自己有特殊方法,比如queryCardBalance(),直接pay.getCardBalance()编译都过不了,必须先向下转型,并且转型前最好用instanceof判断一下。

1.3 接口作为形参和返回值:比抽象类更灵活的设计契约

接口比抽象类更抽象,它只定义“能干什么”,不关心“怎么干”。把刚才的例子改成接口:

interface IPay { void pay(double money); }

参数写IPay pay,就能接收任意实现类对象。接口可以有多个实现类,而一个类也只能实现多个接口,所以接口参数的“接纳范围”比抽象类更大。日常开发里,方法参数和返回值最推荐写成接口,这就是常说的“面向接口编程”。

为什么推荐接口?举个例子。一个数据保存功能:

interface IDataSaver { void save(String data); } class FileSaver implements IDataSaver { public void save(String data) { // 存文件 } } class DBSaver implements IDataSaver { public void save(String data) { // 存数据库 } }

业务方法只需要依赖IDataSaver,具体传入哪个Saver完全由调用方决定。以后要换存储方案,业务代码不用动,只要换个实现类,或者动态传入。这种解耦能力在框架源码里到处都是,比如各种监听器、回调接口、配置处理器。

还有一点要注意:接口没有构造方法,不能new IPay(),接口名作为返回值返回的必须是实现类的对象,否则编译直接报错。

1.4 三类参数/返回值对比:一张表理清使用边界

类型形参/返回值实际接收什么编译期能调用哪些方法典型场景
具体类名该类对象及其子类对象具体类中定义的方法直接处理明确类型的对象
抽象类名任意子类对象抽象类中定义的方法模板方法、公共流程封装
接口名任意实现类对象接口中声明的方法策略模式、回调、系统解耦

写到这里,你会发现三者的本质是一件事:参数或返回值声明成父类型,实际塞进去的是子类型对象。父类型可以是具体类、抽象类、接口,区别只是“约束范围”和“可调用方法”的宽窄。把这一点想明白,面向对象才算真正往前迈了一步。

2. 内部类:Java的“套娃”玩法,四种形态一次讲清

2.1 成员内部类:定义在类里的普通成员

成员内部类就像外部类的一个成员变量,位置在类内部、方法外面。它可以有private、public、protected修饰符,可以访问外部类的所有成员,包括私有成员,因为内部类默认持有一个外部类对象的引用。

创建方式比较特别:

class Outer { private int x = 10; class Inner { public void show() { System.out.println(x); } } } Outer outer = new Outer(); Outer.Inner inner = outer.new Inner(); inner.show();

看到outer.new Inner()这种语法不要慌,意思就是“用outer这个对象去创建一个属于它的内部类对象”。成员内部类不能像普通类一样直接new Outer.Inner(),必须先有外部类对象,原因就是它要拿到外部类引用。

一个很容易被忽略的面试细节:如果外部类和内部类有同名字段,在内部类中访问外部类字段要写成Outer.this.字段名。比如:

class Outer { int x = 10; class Inner { int x = 20; void show() { System.out.println(x); // 20 System.out.println(Outer.this.x); // 10 } } }

这个语法在开发中不常见,但理解了就知道内部类确实持有外部类的this引用。

有没有坑?有。因为内部类持有外部类引用,如果内部类对象生命周期长,而外部类对象不再被使用,外部类就可能一直无法被垃圾回收。在一些GUI程序或者Android开发里,这种写法容易导致内存泄漏,所以现在大型项目里成员内部类用得并不算多。

2.2 局部内部类:方法里的临时类

局部内部类定义在方法或者代码块里面,作用域跟局部变量一样,只能在所在方法内创建和使用。它不能加public、private这些修饰符,因为对方法外部来说它根本不存在。

这种类什么时候用?当你发现方法内部的一段逻辑需要一个“类”来组织,但这个方法外部永远用不到这个类,就可以写局部内部类。不过实际开发中,局部内部类经常被匿名内部类替代,所以它更多是学习语法用。

局部内部类最需要记住的坑是:它访问方法里的局部变量时,那个变量必须是final或者“实际上没被修改过”(effectively final)。为什么?因为局部变量在栈帧里,方法执行完就销毁了,但局部内部类对象可能还在堆里存活,Java为了保证它还能读到变量,会把局部变量复制一份到内部类对象里。既然复制了,就必须保证原变量不能变,否则两边数据就对不上了。

public void test() { int num = 100; // 这个num之后不能再被修改 class Inner { public void print() { System.out.println(num); } } new Inner().print(); }

如果你在num后面再写num = 200,编译直接报错。这个规则对匿名内部类同样适用。

2.3 匿名内部类:一个“用完即走”的子类对象

匿名内部类最实用,也是最常见的一种。它是“定义类+创建对象”一起完成的写法,没有类名。语法长这样:

new 父类/接口(参数) { // 类体 };

最常见的场景是启动线程:

Thread t = new Thread(new Runnable() { @Override public void run() { System.out.println("线程执行了"); } }); t.start();

这里的new Runnable() {...}并不是在实例化接口,而是在创建Runnable的一个匿名实现类对象。编译器会帮我们生成一个类似Outer$1.class的类文件。

匿名内部类有四个特点需要记住:

  • 只能创建一次对象,没有名字,无法再new第二次。
  • 如果是接口,必须实现接口里所有抽象方法。
  • 如果是抽象类,必须实现所有抽象方法。
  • 可以访问外部类的成员,但访问局部变量时必须满足final或effectively final。
  • 在大多数JDK版本里,匿名内部类中不能定义静态成员。

匿名内部类是回调、监听器、事件处理的首选,因为代码紧凑。后来Java 8推出Lambda表达式,如果接口只有一个抽象方法,可以直接用() -> {}替代匿名内部类,代码更简洁。但匿名内部类还能用于继承普通类、抽象类,这点Lambda取代不了。

2.4 静态内部类:不依赖外部类实例的独立嵌套类

静态内部类用static修饰,它不会持有外部类对象的引用。创建方式也比成员内部类简单:

class Outer { static class StaticInner { public void say() { System.out.println("静态内部类"); } } } Outer.StaticInner inner = new Outer.StaticInner();

不需要outer.new,直接new Outer.StaticInner()。因为它是静态的,所以只能访问外部类的静态成员,不能访问外部类的实例成员。

静态内部类在实际项目里非常有用。典型场景是数据模型中的嵌套结构,比如一个响应结果类里再定义BaseResult和Data结构。Builder设计模式里也经常用静态内部类当作Builder。因为它是“静态”的,不依赖外部类实例,所以不会造成外部类无法回收的问题,这一点比成员内部类更安全。

2.5 四种内部类怎么选?放在一起看更清楚

内部类类型是否持有外部类this创建方式能访问外部类什么常见用途
成员内部类是outer.new Inner()所有成员和外部类强相关、需要频繁访问私有属性的组件
局部内部类是在所在方法内new所有成员 + effectively final局部变量方法内需要复杂类型时
匿名内部类是直接在new时定义所有成员 + effectively final局部变量一次性回调、简化代码
静态内部类否new Outer.StaticInner()只能访问静态成员Builder、嵌套数据模型

从使用频率上看,匿名内部类和静态内部类最高,成员内部类次之,局部内部类最低。学习阶段每个都要写得出来,但真正写项目时,优先考虑静态内部类和匿名内部类。

3. 常用API一次摸清:Math、System、toString、Arrays

3.1 Math类:数学计算别自己造轮子

Math类是一个工具类,构造方法是私有的,所有方法都是静态的,直接用Math.方法名()调用就行。常用方法列一下:

  • Math.abs(int a):取绝对值。但要小心,Math.abs(Integer.MIN_VALUE)返回的还是Integer.MIN_VALUE,因为Integer.MIN_VALUE的绝对值超出了int范围。
  • Math.ceil(double a):向上取整,返回double。Math.ceil(3.2)结果是4.0。
  • Math.floor(double a):向下取整,返回double。Math.floor(3.8)结果是3.0。
  • Math.round(double a):四舍五入,返回long。Math.round(3.5)结果是4。
  • Math.max/min:返回两个数里较大/较小的那个。
  • Math.pow(double a, double b):a的b次方,返回double。比如Math.pow(2, 10)是1024.0。
  • Math.random():返回0.0 <= x < 1.0的随机double。想要1到100的随机整数,可以写(int)(Math.random() * 100) + 1,注意先乘后取整。

Math.round有个非常经典的坑:Math.round(-1.5)的结果不是-2,而是-1。原因要看源码,Math.round(a)等价于Math.floor(a + 0.5)。-1.5 + 0.5 = -1.0,再向下取整还是-1。如果写成Math.round(-1.6),-1.6 + 0.5 = -1.1,向下取整就是-2。这个细节很多题会考,记住思路就不容易错。

3.2 System类:与运行时环境打交道的入口

System类也是一个静态工具类,最常用的几个方法:

  • System.currentTimeMillis():返回当前时间距离1970年1月1日0时0分0秒的毫秒数。常用来测代码耗时:
long start = System.currentTimeMillis(); for (int i = 0; i < 1000000; i++) { // 模拟耗时操作 } long end = System.currentTimeMillis(); System.out.println("耗时(ms):" + (end - start));

注意currentTimeMillis()返回的是绝对时间戳,不是精确计时器,受系统时间调整影响。如果需要更精确的纳秒级计时,可以用System.nanoTime()。

  • System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length):数组复制。Arrays.copyOf底层就是用它实现的。参数从左到右是:源数组、源数组起始位置、目标数组、目标数组起始位置、复制长度。使用前要确保目标数组长度够,否则会抛ArrayIndexOutOfBoundsException。

  • System.gc():请求JVM执行垃圾回收。注意只是“请求”,JVM不一定立刻响应,正式代码里不要依赖它。

  • System.exit(int status):终止当前JVM进程,0表示正常退出,非0表示异常终止。这个方法能不用就不用,尤其在企业级应用里,随手exit会把整个服务停掉。

3.3 toString():为什么打印对象不是想要的内容

所有类都继承自Object,Object类里有一个toString()方法,默认实现返回的是类名@十六进制哈希值,比如com.demo.Dog@1b6d3586。这就是为什么你直接System.out.println(对象)时,输出的是一串看不懂的地址。

其实System.out.println在打印对象时,会自动调用这个对象的toString()方法。所以只要重写toString(),打印出来的内容就能变成你想要的样子:

class Dog { private String name; @Override public String toString() { return "Dog{name='" + name + "'}"; } }

IDE一般都有自动生成toString()的功能,自己不用手敲。重写时保持简洁,别在toString()里写复杂业务逻辑,因为字符串拼接、日志框架、异常信息都可能悄悄调用它,逻辑太复杂会影响性能。

数组的toString有点特殊。int[] arr = {1,2,3},直接System.out.println(arr)会输出[I@1b6d3586这种,不会输出元素。因为数组没有真正重写toString(),用的还是Object里的那套。但char[]例外,System.out.println(charArr)会直接打印字符序列,因为PrintStream有重载的print(char[])方法。这个如果不注意,调试时遇到字符数组“能打印、其他数组不能打印”的现象,会一脸懵。

3.4 Arrays类:数组操作的标准答案

java.util.Arrays是专门操作数组的工具类,全都封装成了静态方法。

  • Arrays.toString(int[] arr):把数组内容转成字符串,输出[1, 2, 3]这样。这是调试利器。
  • Arrays.sort(int[] arr):原地升序排序。注意是“原地”,排序后会修改原数组。底层对基本类型用的是双轴快排,对对象数组用的是归并排序或者TimSort。
  • Arrays.binarySearch(int[] arr, int key):二分查找,返回找到的下标。如果没找到,返回的不是-1,而是-(插入点) - 1。比如在{1, 3, 5}里查找2,插入点是1,返回值就是-2。使用前提是数组必须先排序,否则结果不可预期。
  • Arrays.copyOf(int[] arr, int newLength):复制数组并指定新长度。扩容时多出来的位置补默认值,缩容时多余的直接丢弃。底层用System.arraycopy实现。
  • Arrays.copyOfRange(int[] arr, int from, int to):复制范围,包左不包右。
  • Arrays.fill(int[] arr, int val):把数组的所有位置都填成同一个值。
  • Arrays.equals(int[] a, int[] b):比较两个数组的“内容”是否相等,而不是比较引用。很多人喜欢写arr1 == arr2来比较数组,这比较的是两个引用是不是指向同一个对象,即使内容完全一样也会返回false。

还要注意一个坑:Arrays.sort(int[], Comparator)是不存在的。因为Comparator是泛型接口,需要传对象,而int是基本类型。如果想让int[]降序排列,要么换成Integer[],要么自己写排序,要么先升序再反转。初学者很容易在这里编译报错。

4. 冒泡排序:把每个最大值“冒”到末尾

4.1 核心思路与手动推演

冒泡排序是排序算法里最直观的一个。思路就一句话:从头开始,相邻两个数两两比较,如果左边比右边大,就交换位置。每一轮比较结束后,当前参与比较的范围里最大的那个数会“冒泡”到范围末尾,下一轮就不再管它。

以数组[5, 1, 4, 2, 8]为例,手动走一遍第一轮:

  • 比较5和1,5 > 1,交换,数组变成[1, 5, 4, 2, 8]
  • 比较5和4,5 > 4,交换,数组变成[1, 4, 5, 2, 8]
  • 比较5和2,5 > 2,交换,数组变成[1, 4, 2, 5, 8]
  • 比较5和8,5 < 8,不交换,数组保持[1, 4, 2, 5, 8]

第一轮结束后,8已经在最后一位。第二轮只需要处理[1, 4, 2, 5]这四个数,比较3次,结束之后5到倒数第二位。第三轮处理[1, 4, 2],比较2次。第四轮处理[1, 2],比较1次。总共比较4+3+2+1=10次,正好是n(n-1)/2,因为n=5,所以5*4/2=10。

4.2 标准代码实现与边界解释

手写标准冒泡排序:

public static void bubbleSort(int[] arr) { if (arr == null || arr.length < 2) { return; } for (int i = 0; i < arr.length - 1; i++) { for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }

为什么外层循环是i < arr.length - 1?因为当只剩最后一个元素时,它天然有序,不需要再比较。为什么内层是j < arr.length - 1 - i?因为每轮结束后,末尾已经有i个元素排好序,不需要再碰它们;同时循环里要访问arr[j + 1],所以j最大只能到倒数第二位的下标,也就是length - 2,写成< length - 1刚好。

外层循环从i = 0开始,第一轮结束时最大值到末尾;i = 1,第二轮结束前二大值到倒数第二位;以此类推。这段代码必须要能默写,不只是为了面试,而是因为它能锻炼“循环边界”的敏感度,写错一点就是越界异常。

4.3 稳定性、时间复杂度与优化

冒泡排序是稳定排序,前提是交换条件严格使用>,相等时不交换,相同元素的相对顺序不会改变。如果你写成>=,相等的两个元素也会交换,排序结果虽然一样,但稳定性就被破坏了。在只比较int时看不出来,但如果排序的是对象,比如按年龄排序学生对象,两个学生年龄相同,稳定排序能保持他们原来的先后顺序,不稳定排序可能颠倒。

时间复杂度分情况:

  • 最好情况:数组已经有序。优化前仍要跑完所有轮,复杂度是O(n^2);优化后只需要一检测轮,复杂度降到O(n)。
  • 平均和最坏情况:O(n^2)。
  • 空间复杂度:O(1),因为只用了一个临时变量,没有额外数组。

优化版本加一个标志位,记录当前轮是否发生过交换:

public static void bubbleSortOptimized(int[] arr) { if (arr == null || arr.length < 2) { return; } for (int i = 0; i < arr.length - 1; i++) { boolean swapped = false; for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; swapped = true; } } if (!swapped) { break; } } }

这个优化对“大部分已经有序”的数据非常友好。比如[1, 2, 3, 4, 5, 6, 7],第一轮内层跑完发现一次交换都没有,直接break,只需要比较6次,效率极高。

4.4 手写冒泡最常见的三类错误

第一类:内层循环边界写成j < arr.length - i,当j到length - i - 1时,arr[j + 1]就越界了,抛ArrayIndexOutOfBoundsException。记住一个口诀:因为比较的是j和j+1,所以j最多到倒数第二位,也就是length - 2。

第二类:外层循环写成i < arr.length,虽然不会越界,但会多跑一轮无用循环。最后只剩一个元素时,根本不需要再比较。

第三类:没有意识到Arrays.sort和冒泡排序都是“原地排序”,数组在排序方法内部被修改了。如果调用方后面还要用原始顺序,必须先用Arrays.copyOf复制一份再排序。

5. 学习中的常见问题与排查技巧实录

5.1 方法参数写接口,子类特有方法调不出来

场景:方法参数是接口类型,但实现类里有一个额外方法,在方法内部直接调用编译报错。原因前面说过,编译期看的是左边的类型,接口类型只能调用接口里的方法。解决办法:

if (obj instanceof Circle c) { c.setRadius(10); }

从JDK 16开始,instanceof可以用模式匹配,直接拿到变量,不需要再强转。不过从设计角度看,如果调用方频繁需要向下转型,说明接口定义得太薄,该把公共方法提到接口里,或者拆分成细粒度接口。

5.2 Arrays.sort 不能直接给 int[] 传 Comparator 降序

“为什么我给int[]传Comparator会报错?”因为Comparator只适用于对象类型,int是基本类型,不满足泛型约束。想用Arrays.sort降序时:

  • 方案一:用Integer[],然后Arrays.sort(arr, (a, b) -> b - a)。
  • 方案二:先升序Arrays.sort(arr),再手动反转。
  • 方案三:自己写降序冒泡。

这个坑的本质是理解基本类型和包装类型在泛型中的区别。int[]在泛型方法里就是个整体对象,没法拆成一个个Integer元素去套Comparator。

5.3 打印数组到底用什么?

一维数组用Arrays.toString(),二维数组用Arrays.deepToString()。如果用了Arrays.toString()打印二维数组,看到的是一堆[I@xxx地址,不是具体元素。调试时直接:

System.out.println(Arrays.deepToString(matrix));

一眼就能看清所有行和列。

5.4 内部类编译后的class文件疑问

编译包含内部类的外部类后,目录里会出现Outer$Inner.class这样的文件,匿名内部类会生成Outer$1.class、Outer$2.class这种带数字的文件。看到这个不要觉得奇怪,这是Java编译器的正常产物。如果你用命令行javac编译并手动运行,必须把这些class文件都带上,漏了就会ClassNotFoundException。IDE开发时这些细节被隐藏了,但底层逻辑要清楚。

5.5 匿名内部类里改局部变量为什么编译失败

因为局部变量必须是final或effectively final。你可以在匿名内部类里“读取”它,但不能再回头“修改”它。如果非要改,常见做法是定义一个长度为1的数组,用数组下标来绕。但更推荐的做法是把这个值改成外部类的成员变量,或者从设计层面换一种写法,别为了省事钻语言限制的空子。

5.6 冒泡排序结果不对怎么定位

第一步,检查输出的是不是原数组。因为排序是原地的,如果你先打印原数组再排序,打印的是排序后的结果,容易误判。

第二步,在每轮内层交换的位置加一行日志:

if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; System.out.println("交换下标 " + j + " 和 " + (j + 1) + ":" + Arrays.toString(arr)); }

这样能看到每个交换动作,边界问题一目了然。

第三步,写一个验证方法:

static boolean isSorted(int[] arr) { for (int i = 0; i < arr.length - 1; i++) { if (arr[i] > arr[i + 1]) { return false; } } return true; }

排序结束后调用一下,返回false就说明还有元素没排好。这个验证方法以后自己写排序时也能用。

最后分享一个小习惯。学完这一天的内容后,我建议做一个小练习:设计一个精简版的学生成绩处理程序,Service层方法的参数和返回值都用接口类型,内部用匿名内部类或Lambda处理排序规则,最后对成绩数组手写一次冒泡排序,再用Arrays.sort对比结果。这个练习不复杂,但能把类名/接口作为参数返回值、内部类、常用API、冒泡排序这些散点全部串起来。我当时做完之后,很多“似懂非懂”的感觉一下消失了。如果你也卡在这一天,强烈建议亲手码一遍,比刷十道题都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询