说实话,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、冒泡排序这些散点全部串起来。我当时做完之后,很多“似懂非懂”的感觉一下消失了。如果你也卡在这一天,强烈建议亲手码一遍,比刷十道题都管用。