Java(三):3.0 [核心] 面向对象编程

3.0 [核心] 面向对象编程

在完成了对Java基础语法、数据类型和运算符的学习之后,我们掌握了构建程序的“砖块”与“水泥”。现在,我们将开始学习如何运用这些材料来设计和建造宏伟的“建筑”——这便是面向对象编程(OOP)的范畴。它是现代软件工程的基石,也是Java语言设计的核心哲学。

3.0 面向对象思想

面试题引入

面试官:“请谈谈你对面向对象编程(OOP)和面向过程编程(POP)的理解,以及它们之间的主要区别是什么?”

核心思想:思维模型的转变

要理解面向对象,最好的方式是将其与我们更熟悉的面向过程进行对比。这代表了两种截然不同的解决问题的思维模型。

  • 面向过程 (Procedural-Oriented Programming, POP)
    面向过程的思维模型,就像一份详尽的菜谱操作手册。它将解决问题的步骤清晰地列出,核心是**“流程”和“函数”**。比如要实现“把大象装进冰箱”这个任务,面向过程的代码会是这样:

    1. 定义一个打开冰箱门()的函数。
    2. 定义一个抬起大象()的函数。
    3. 定义一个放进大象()的函数。
    4. 定义一个关闭冰箱门()的函数。然后按照顺序调用这些函数来完成任务。在这种模型中,数据(大象、冰箱)和操作数据的函数是分离的。
  • 面向对象 (Object-Oriented Programming, OOP)
    面向对象的思维模型,则更像是在扮演一个总设计师。我们不再关注具体的操作步骤,而是首先分析问题领域中存在哪些**“实体”(即对象)。对于“把大象装进冰箱”这个任务,我们会识别出三个对象:大象冰箱操作者。我们为每个实体设计一个“图纸”——也就是类(Class)**。

    • 冰箱类:它有自己的属性(如品牌、容量)和行为(如开门()关门()存储(物品))。
    • 大象类:它有自己的属性(如体重)和行为(如被放进(容器))。
    • 操作者类:它的行为是操作(冰箱, 大象)。任务的完成,变成了对象之间的交互:操作者调用冰箱开门()方法,然后调用冰箱存储(大象)方法,最后调用冰箱关门()方法。

    在这种模型中,数据和操作数据的方法被紧密地“封装”在对象内部,程序由对象间的协作和消息传递来驱动。

两大范式的优缺点对比

对比维度面向过程 (POP)面向对象 (OOP)
优点性能高。因为是直接的函数调用,没有对象创建、方法动态派发等开销,更接近底层。因此在操作系统、嵌入式、驱动开发等对性能要求极致的领域仍被广泛使用。易维护、易复用、易扩展。通过封装、继承、多态,可以构建出高内聚、低耦合的系统,模块清晰,修改一个模块不易影响其他模块。
缺点维护、复用、扩展困难。数据与操作分离,导致代码耦合度高,一个数据结构的改变可能需要修改所有相关的函数,牵一发而动全身。性能相对较低。对象创建、垃圾回收、方法调用等会引入一定的开销。但对于绝大多数现代商业应用而言,这点性能差异可以忽略不计,换来的可维护性收益远大于此。

三大基本特性概览

面向对象的强大之处,源于其三大核心支柱。我们将在后续的章节中逐一深度剖析它们。

  1. 封装

    • 一句话理解:隐藏对象的内部实现细节,仅对外暴露必要的、受控的访问接口。
    • 生活中的比喻:就像我们使用电视遥控器。我们只需按动“音量+”这个按钮(公共接口),就能增大电视音量,而完全不必关心遥控器内部的电路板(私有数据和实现)是如何工作的。
  2. 继承

    • 一句话理解:基于一个已存在的类,创建出一个新的类,新的类将拥有父类的所有属性和行为,并可以添加自己独有的特性。
    • 生活中的比喻特斯拉继承自汽车。作为一辆汽车,它天然就拥有轮子、方向盘等属性和前进、后退等行为。同时,它又增加了自己独有的特性,如“自动驾驶”和“电能驱动”。
  3. 多态

    • 一句话理解:“一种接口,多种形态”。指同一个行为(方法),作用于不同的对象上,会产生不同的实现效果。
    • 生活中的比喻:一个USB接口。你可以插入鼠标、键盘或者U盘。对于电脑来说,都是“接收USB设备插入”这同一个行为,但鼠标会实现“移动光标”,键盘会实现“输入文字”,U盘会实现“传输文件”,展现出了不同的形态。

3.1 万物皆对象:类与对象

在Java的世界里,我们秉持“万物皆对象”的哲学。要构建任何复杂的系统,我们首先需要学会如何定义和创造构成这个系统的基本“物件”。“类”就是我们用来定义这些物件的图纸,而“对象”就是根据图纸制造出来的、独一无二的实体。

3.1.1 类的定义与对象的创建

核心概念
  • 类 (Class):类是模板,是蓝图。它是一个静态的、在编译时就已确定的概念。一个类定义了一类事物所共有的属性(状态)和行为(方法)。例如,“汽车图纸”就是一个类,它定义了所有汽车都应该有轮子、颜色、品牌等属性,以及能够前进、刹车等行为。

  • 对象 (Object):对象是类的一个具体实例 (Instance)。它是一个动态的、在程序运行时才存在的实体,真实地存在于内存之中,并拥有自己独立的状态。例如,根据同一份“汽车图纸”,我们可以制造出“一辆红色的法拉利”和“一辆黑色的宝马”,这两辆车就是两个独立的对象。

类的构成

一个设计良好的类通常包含以下三个核心部分:

  1. 字段 (Fields):也称为成员变量,用于描述对象的状态或属性。例如,一个Car类的字段可以是String brand;String color;
  2. 方法 (Methods):也称为成员函数,用于描述对象的行为或功能。例如,Car类的方法可以是void startEngine()void accelerate()
  3. 构造器 (Constructors):一种特殊的方法,用于在创建对象时进行初始化操作。
代码示例:定义Car类并创建对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example;

// 这是一个Car类,是所有具体汽车的“图纸”
class Car {
// 字段(Fields),描述汽车的状态
String brand;
String color;
int speed;

// 方法(Methods),描述汽车的行为
void startEngine() {
System.out.println(brand + " 的引擎启动了!");
}

void accelerate() {
speed += 10;
System.out.println("当前车速: " + speed + " km/h");
}
}

public class Main {
public static void main(String[] args) {
// 使用 new 关键字,根据Car类的图纸,创建一个具体的Car对象
Car myCar = new Car();

// 为这个对象的字段赋值,赋予它独立的状态
myCar.brand = "特斯拉";
myCar.color = "红色";
myCar.speed = 0;

System.out.println("我有一辆" + myCar.color + "的" + myCar.brand);

// 调用对象的行为
myCar.startEngine();
myCar.accelerate();

System.out.println("--------------------");

// 创建另一个独立的对象
Car yourCar = new Car();
yourCar.brand = "保时捷";
yourCar.color = "白色";
System.out.println("你有一辆" + yourCar.color + "的" + yourCar.brand);
yourCar.startEngine();
}
}

3.1.2 构造器 (Constructors)

核心用途

构造器的唯一使命,就是在通过new关键字创建对象时,对这个新生的对象进行初始化,为其属性赋予有意义的初值。

特点与规则
  1. 名称必须与类名完全相同
  2. 没有返回值类型,连void也不能写。
  3. 默认构造器:如果一个类没有显式地定义任何构造器,Java编译器会自动为其提供一个公共的、无参数的默认构造器。但一旦你手动定义了任何一个构造器,编译器就不会再自动提供默认构造器了,这一点是初学者常犯的错误。
  4. 构造器重载:一个类可以有多个构造器,只要它们的参数列表不同(参数个数、类型或顺序不同),这就构成了构造器重载,为对象的创建提供了多种灵活的方式。
代码示例:Car类的多种构造器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example;

class Car {
String brand;
String color;

// 1. 无参构造器
public Car() {
System.out.println("无参构造器被调用,创建了一辆默认汽车。");
this.brand = "未知品牌";
this.color = "黑色";
}

// 2. 带一个参数的构造器
public Car(String brand) {
System.out.println("带品牌参数的构造器被调用。");
this.brand = brand;
this.color = "白色"; // 默认颜色
}

// 3. 带两个参数的构造器 (重载)
public Car(String brand, String color) {
System.out.println("带品牌和颜色参数的构造器被调用。");
this.brand = brand;
this.color = color;
}

void printInfo() {
System.out.println("这是一辆" + this.color + "的" + this.brand);
}
}

public class Main {
public static void main(String[] args) {
// 使用无参构造器
Car defaultCar = new Car();
defaultCar.printInfo();

System.out.println("----------");

// 使用带品牌参数的构造器
Car bmw = new Car("宝马");
bmw.printInfo();
}
}

3.1.3 this 关键字的核心用法

this是Java中一个非常重要的关键字,它代表当前对象实例的引用。简单来说,在方法或构造器内部,this就指向了“调用这个方法或构造器的那个对象本身”。它只能在实例方法或构造器中使用。

用法一:区分同名变量(最常用)

当方法的参数名或局部变量名与类的成员变量名相同时,使用this.成员变量名可以明确地指代成员变量。

1
2
3
4
5
6
7
8
9
10
11
12
package com.example;

class Car {
String brand;

// 构造器的参数brand与成员变量brand同名
public Car(String brand) {
// this.brand 指的是当前对象的成员变量
// brand 指的是传入的参数
this.brand = brand;
}
}
用法二:调用本类其他构造器

在一个构造器中,可以使用 this(...) 的语法来调用本类中其他的构造器,以达到代码复用的目的。
规则this(...)的调用必须是构造器中的第一条语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example;

class Car {
String brand;
String color;

// 两个参数的构造器
public Car(String brand, String color) {
this.brand = brand;
this.color = color;
}

// 无参构造器通过 this(...) 调用了上面的构造器,为其提供了默认值
public Car() {
this("奥迪", "黑色"); // 调用本类中其他构造器,必须放在第一行
System.out.println("无参构造器执行完毕");
}
}

3.1.4 [面试高频] 对象的创建过程

面试官:“当执行 new Car() 时,JVM内部到底发生了什么?”

这是一个考察Java底层知识的经典问题。一个对象的创建过程大致可以分为以下几个步骤:

  1. 类加载检查:JVM首先检查Car.class是否已经被加载。如果没有,则触发类加载机制(加载、链接、初始化),将类的元信息加载到方法区。
  2. 分配内存:JVM在堆内存中为新的Car对象分配一块大小合适的内存空间。
  3. 零值初始化:JVM将这块新分配的内存空间“清零”,即对象的所有实例字段都被赋予其数据类型的默认值(例如,int为0,booleanfalse,所有引用类型为null)。
  4. 设置对象头:JVM设置对象的头部信息(Object Header),这部分信息包含了对象的哈希码、GC分代年龄、锁状态标志以及指向其类元数据的指针等。
  5. 执行实例初始化:这是程序员可见的初始化部分,按顺序执行:
    a. 父类初始化:(如果存在继承)先执行父类的实例初始化过程。
    b. 实例变量初始化/代码块:执行当前类中定义的实例变量的初始化语句(如int speed = 10;)和实例初始化代码块{}
    c. 执行构造器:最后,执行与new关键字匹配的构造器代码。

至此,一个完整的对象才算创建成功,并将它的内存地址返回给引用变量。


3.2 第一大特性:封装

封装,顾名思义,就是将物体的某些部分“包装并隐藏”起来。在面向对象的世界里,它指的是将对象的状态(字段)和行为(方法)捆绑在一起,形成一个不可分割的独立实体(即类),同时尽可能地隐藏对象的内部实现细节,仅对外暴露有限的、受控的访问接口。

可以将其想象成一个“胶囊”或“黑盒”。用户只需知道如何使用这个黑盒的按钮(公共方法),而无需关心其内部复杂的电路(私有数据和实现逻辑)。

3.2.1 封装的核心:信息隐藏

面试题引入

面试官:“什么是封装?请谈谈它在实际开发中的好处。”

核心概念与实现

封装的核心思想是信息隐藏。在Java中,我们主要通过**访问修饰符(Access Modifiers)**来实现这一目标。

最核心的封装实践原则是:将类的字段(成员变量)声明为 private,并提供 publicgettersetter方法作为外部世界与这些私有字段交互的唯一通道。

  • private 字段:确保了对象的内部状态不能被外部代码随意篡改,保护了数据的完整性和安全性。
  • public getter/setter 方法
    • getter 提供了一个只读的访问点。
    • setter 提供了一个受控的写入点,我们可以在setter方法中加入验证逻辑,确保赋给字段的值是合法的。
封装的好处
  1. 安全性:防止了外部代码对对象内部状态的非法访问和破坏。例如,我们可以在 setAge(int age) 方法中检查年龄是否为负数,从而杜绝无效数据的产生。
  2. 易用性:调用者无需关心对象内部复杂的实现逻辑,只需调用我们提供的简单、清晰的公共方法即可,降低了类的使用门槛。
  3. 可维护性:封装实现了调用者与实现者的解耦。只要公共方法(API)的签名不变,我们可以随时修改类内部的实现细节,而不会影响到任何调用它的外部代码。这使得系统升级和重构变得更加容易。
访问修饰符对比表

下表清晰地展示了Java中四种访问修饰符的权限范围:

修饰符本类同包子类 (不同包)任何地方
public
protected
default (无修饰符)
private

3.2.2 JavaBean 规范:封装的最佳实践

JavaBean并非一种具体的技术,而是一套广为遵循的设计约定或标准,用于创建可重用的、高度封装的Java组件。几乎所有的Java框架(如Spring)都深度依赖JavaBean规范。

核心规则

  1. 类必须是 public 的。
  2. 必须提供一个 public 的无参构造器。
  3. 所有字段都必须是 private 的。
  4. 为每个私有字段提供 publicgettersetter方法,并遵循命名约定:
    • getXxx() 用于获取字段xxx的值。
    • setXxx(type xxx) 用于设置字段xxx的值。
    • 对于boolean类型的字段,getter方法可以是isXxx()
代码示例:一个标准的Person JavaBean
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.example;

// 引入Serializable接口是JavaBean的一个推荐实践,用于对象持久化或网络传输
import java.io.Serializable;

// 1. 类是 public 的
public class Main implements Serializable {
// 3. 字段是 private 的
private String name;
private int age;
private boolean isStudent;

// 2. public 的无参构造器
public Main() {
}

// 4. public 的 getter 和 setter
public String getName() {
return name;
}

public void setName(String name) {
// 可以在setter中加入验证逻辑
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("Name cannot be empty.");
}
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age.");
}
this.age = age;
}

// boolean类型的getter可以是 'is' 开头
public boolean isStudent() {
return isStudent;
}

public void setStudent(boolean student) {
isStudent = student;
}
}

3.2.3 代码块:特殊的初始化封装

代码块是类中用于封装初始化逻辑的特殊结构,它没有方法名,只有一对 {}

1. 构造代码块 (实例初始化块)
  • 语法:直接在类中用 {} 包裹的代码。
  • 执行时机每次创建对象时都会执行,且执行顺序在构造器之前
  • 核心用途:用于提取所有构造器中公共的初始化代码,减少冗余。
2. 静态代码块
  • 语法:使用 static { ... } 包裹。
  • 执行时机仅在类第一次被加载到JVM时执行一次。它的执行时机非常早,在任何对象创建之前。
  • 核心用途:用于对类级别的静态(static)变量进行一次性的、复杂的初始化。例如,加载数据库驱动、初始化静态资源等。
[面试高频] 初始化执行顺序

面试官:“当new一个子类对象时,父类和子类的静态代码块、构造代码块、构造器的执行顺序是怎样的?”

铁律:父类静态部分 -> 子类静态部分 -> 父类实例初始化(构造代码块 -> 构造器) -> 子类实例初始化(构造代码块 -> 构造器)。


3.3 第二大特性:继承

继承,在现实世界中指子女从父母那里继承特征。在Java中,这一概念非常相似:一个类(子类派生类)可以从另一个类(父类超类基类)那里获取其非私有的字段和方法。这种机制不仅极大地促进了代码复用,更重要的是,它建立了一种“is-a”(是一个)的关系,例如,“狗”是一个“动物”,这是实现多态的前提。

3.3.1 继承的实现与本质 (extends)

面试题引入

面试官:“谈谈Java中的继承,它解决了什么问题?super关键字有什么用?”

extends 关键字

在Java中,我们使用 extends 关键字来声明一个类继承自另一个类。Java只支持单继承,即一个类最多只能有一个直接父类,但支持多层继承(A继承B,B继承C)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Animal是父类
class Animal {
String name;
public void eat() {
System.out.println(name + " 正在吃东西...");
}
}

// Dog是子类,使用extends继承Animal
class Dog extends Animal {
public void bark() {
System.out.println(name + " 正在汪汪叫!");
}
}
继承的内容

子类会继承父类所有 publicprotected 的成员。如果子类与父类在同一个包中,default(包私有)成员也会被继承。需要注意的是,父类的 private 成员虽然也被子类“拥有”了(存在于子类对象的内存中),但子类无法直接访问它们,只能通过父类提供的publicprotected方法间接使用。

super 关键字的两种核心用法

super 关键字是对当前对象的直接父类的引用。它主要用于解决子类与父类成员重名时的访问冲突,以及调用父类的构造器。

  1. 调用父类构造器 super(...)

    • 铁律:子类的构造器在执行时,其第一行必须是调用父类的构造器。
    • 隐式调用:如果你没有在子类构造器中显式地写 super(...),编译器会自动为你插入一句 super(),即调用父类的无参构造器。
    • [避坑指南]:如果父类没有提供无参构造器(只提供了带参数的构造器),那么在子类的构造器中,必须显式地使用 super(...) 来调用父类某个已存在的构造器,否则代码将无法通过编译。
  2. 调用父类成员 super.xxx

    • 当子类重写了父类的方法,或者定义了与父类同名的字段时,如果你想在子类中访问父类被“覆盖”的版本,就需要使用super.方法名()super.字段名
[面试高频] 子类实例化过程

当执行 new Child() 时,遵循“先有父,再有子”的原则。JVM会先完成父类部分的初始化,然后再执行子类部分的初始化。

3.3.2 方法重写 (Overriding)

方法重写是子类根据自己的需求,重新定义从父类继承来的方法的实现。这是实现多态的关键。

@Override 注解

虽然不是语法强制,但强烈建议在所有重写的方法上都加上@Override注解。它像一个“安全卫士”,会请编译器帮忙检查你写的方法签名是否真的与父类中的某个方法完全一致。如果签名有误(如方法名拼写错误、参数列表不同),编译器会报错,从而避免了许多难以察觉的BUG。

重写的规则

俗称“两同两小一大”原则:

  1. 方法名相同,参数列表相同
  2. 子类的返回值类型小于或等于父类方法的返回值类型(协变返回类型)。
  3. 子类抛出的异常类型小于或等于父类方法声明抛出的异常类型。
  4. 子类的访问修饰符大于或等于父类方法的访问修饰符 (public > protected > default)。
  5. 父类中 privatefinal 的方法不能被重写。

3.3.3 final 关键字在继承中的应用

final 在继承体系中的含义是“最终的,不可改变的”。

final 方法

当一个方法被final修饰后,它就不能被任何子类重写。这样做通常是为了保证父类中某个核心方法的逻辑不被篡改,确保体系的稳定性。

1
2
3
4
5
6
7
8
9
10
11
12
package com.example;

class Parent {
public final void coreAlgorithm() {
System.out.println("这是核心算法,不许修改!");
}
}

class Child extends Parent {
// 下面的代码将导致编译错误
// public void coreAlgorithm() { ... }
}
final

当一个类被final修饰后,它就不能被任何类继承,它成为了继承链的“终点”。

  • 著名示例:Java核心库中的 java.lang.String 类就是final的。这样做主要是出于安全和性能的考虑,确保了String对象的不可变性,使其可以在多线程环境中安全共享,并放心地用于HashMap的键。
1
2
3
4
5
6
7
8
package com.example;

// String类是final的,无法被继承
// class MyString extends String { ... } // 这行代码会导致编译错误

public final class SealedBox {
// 这个类是最终的,不能被继承
}

3.4 第三大特性:多态

“多态”一词源于希腊语,意为“多种形态”。在面向对象编程中,它指代的是同一种行为(方法调用),作用于不同类型的对象上时,能够产生不同的执行结果。多态性允许我们将子类的对象视为其父类的类型,从而在不关心对象具体子类型的情况下,编写出通用的代码。

生活中的比喻:想象一个电脑的USB接口。这个接口是统一的(同一个方法),但当你插入鼠标时,它表现出的行为是“移动光标”;当你插入U盘时,它的行为是“传输文件”;当你插入一个USB小风扇时,它的行为又是“吹风”。这个USB接口就是多态的体现:同一个接口,根据插入设备(对象)的不同,展现出多种形态。

3.4.1 多态的实现前提与表现形式

面试题引入

面试官:“什么是多态?在Java中要实现多态,需要满足哪些条件?”

三大前提

要在Java中实现多态,必须同时满足以下三个条件:

  1. 继承 (Inheritance):必须存在类之间的继承关系,或者类与接口之间的实现关系。
  2. 方法重写 (Overriding):子类必须重写父类中的方法(或实现接口中的方法)。
  3. 父类引用指向子类对象:这是多态在代码中的最终体现,例如 Animal myPet = new Dog();
表现形式:动态方法分派

当一个父类引用指向子类对象时,我们通过这个父类引用去调用一个被子类重写了的方法,此时Java虚拟机会执行动态方法分派。这意味着,JVM在运行时才会去判断该引用指向的堆内存中对象的实际类型,然后调用该实际类型所对应的方法。

简单来说:编译看左边,运行看右边

  • 编译看左边:编译器在检查语法时,只看引用变量的类型(父类)。因此,你只能调用父类中已定义的方法,否则编译不通过。
  • 运行看右边:在实际运行时,JVM会看new出来的对象实例(子类),并执行子类重写后的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.example;

public class Main {
public static void main(String[] args) {
System.out.println("--- 动态方法分派演示 ---");

// 1. 编译看左边
// 我们声明一个父类类型的引用变量。
// 编译器此时只知道 'myPet' 是 Animal 类型,
// 因此它只允许调用 Animal 类中定义的方法。
Animal myPet;

// 尝试调用 Dog 特有的方法,会在编译时报错:
// myPet = new Dog(); // 如果先赋值,编译器仍然看 'Animal'
// myPet.fetch(); // !!! 这行会编译失败 !!!
// 原因:编译器不知道 myPet 具体指向什么,
// 它只知道引用类型是 Animal,而 Animal 没有 fetch() 方法。

// 2. 运行看右边
// 创建了一个 Dog 对象,并让父类引用指向它。
myPet = new Dog(); // myPet 的声明类型是 Animal,但实际指向的是一个 Dog 对象。

// 调用 speak() 方法:
// 在运行时,JVM会检查 myPet 指向的实际对象是什么类型。
// 它发现对象是 Dog 类型的,并且 Dog 类重写了 speak() 方法。
// 所以,JVM 会执行 Dog 类中的 speak() 方法。
System.out.print("通过 Animal 引用调用 speak(): ");
myPet.speak();

// 如果 Dog 没有重写 speak(),则会执行 Animal 的 speak()。
// 如果有其他子类如 Cat 没有重写 speak(),指向 Cat 对象时就会执行 Animal 的 speak()。

System.out.println("--- 演示结束 ---");
}
}

// 父类 (Parent Class)
class Animal {
// 一个可以被子类重写的方法
public void speak() {
System.out.println("Animal makes a sound.");
}

// 一个子类特有的方法,父类没有定义
public void fetch() {
System.out.println("Animal is fetching.");
}
}

// 子类 (Child Class)
class Dog extends Animal {
// 重写父类的 speak() 方法
@Override
public void speak() {
System.out.println("Dog barks.");
}

// Dog 类特有的方法,Animal 类没有
public void fetch() { // 注意:这里 Dog 重写了 Animal 的 fetch,但本例重点是 speak
System.out.println("Dog fetches the ball.");
}
}

3.4.2 向上转型与向下转型

向上转型 (Upcasting)

将一个子类对象赋值给一个父类引用,这个过程被称为向上转型。它是自动发生的,无需任何强制转换。

  • 代码形式Animal myPet = new Dog();
  • 效果myPet这个引用虽然指向一个Dog对象,但它的“视野”被限制在了Animal的范围内。你只能通过myPet调用Animal类中定义的方法,而无法调用Dog类中独有的方法(如wagTail())。
向下转型 (Downcasting)

当我们需要调用子类独有的方法时,就必须将父类引用“还原”回子类类型,这个过程被称为向下转型。它需要强制类型转换

  • 代码形式Dog myRealDog = (Dog) myPet;
  • [避坑指南] instanceof 关键字:向下转型是有风险的。如果一个父类引用实际指向的是一个Cat对象,你却试图将它强转为Dog,程序会在运行时抛出ClassCastException(类转换异常)。因此,在进行向下转型之前,使用instanceof关键字进行类型检查是一种安全、专业的编程习惯。
代码示例:转型与instanceof的现代用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.example;

class Animal {
public void makeSound() {
System.out.println("动物发出声音...");
}
}

class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("汪汪!");
}

// Dog的特有方法
public void wagTail() {
System.out.println("狗狗在摇尾巴。");
}
}


class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("喵喵~");
}

// Cat的特有方法
public void scratchSofa() {
System.out.println("猫在抓沙发!");
}
}


public class Main {
// 这个方法可以接收任何Animal的子类对象,体现了多态
public static void petAction(Animal pet) {
System.out.println("宠物开始行动...");
pet.makeSound();// 动态分派:运行时调用的是具体子类的方法

// 想要调用子类的特有方法,必须进行安全的向下转型
// 传统写法
if (pet instanceof Dog) {
Dog dog = (Dog) pet;
dog.wagTail();
}

// [Java 16+] 使用模式匹配的写法,更简洁安全
if (pet instanceof Cat cat) {
// 如果pet是Cat类型,会自动转换并赋值给新变量cat
cat.scratchSofa();
}

}

public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();

System.out.println("--- 操作狗对象 ---");
petAction(myDog);
System.out.println("\n--- 操作猫对象 ---");
petAction(myCat);
}
}

3.4.3 接口:更彻底的抽象与多态

如果说继承是基于“is-a”(是一个)关系的多态,那么接口则提供了一种基于“can-do”(能做什么)关系的多态,它将抽象推向了极致。

  • 核心思想:接口只定义行为规范(契约),即一个对象应该“能做什么”(拥有哪些方法),但完全不关心“如何做”(方法的具体实现)。任何类,无论它处于继承树的哪个位置,只要它承诺遵守这个契约,就可以通过implements关键字实现该接口。
  • 优势:接口打破了Java的单继承限制,一个类可以实现多个接口。这使得完全不相关的类,只要它们实现了同一个接口,就可以被多态地统一处理。

想象一个场景,我们需要让系统里不同类型的事物“飞起来”。这些事物可能是一个鸟 (Bird)、一架飞机 (Airplane),甚至是一个超人 (Superman)

从继承关系(is-a)来看,是一个动物飞机是一个机器超人是一个超能英雄。它们之间没有任何共同的父类(除了Object),因此无法用继承来实现统一的fly()行为。

这时,接口就派上了用场。我们可以定义一个 Flyable (可飞行的) 接口,它只规定一个行为:fly()

1. [行为契约] 定义 Flyable 接口

这个接口就是我们的“can-do”契约。任何实现了它的类,都必须提供飞行的具体方法。

1
2
3
4
5
6
7
8
9
/**
* 定义了一个“可飞行”的行为契约 (can-do contract)。
* 任何类只要实现了这个接口,就意味着它具备了飞行的能力。
*/
public interface Flyable {
// 接口中的方法默认是 public abstract 的,所以可以省略关键字。
// 它只定义了“应该能飞”,但不关心“如何飞”。
void fly();
}
2. [独立实现] 创建不相关的实现类

现在,我们创建三个完全不同的类,它们唯一的共同点就是都承诺遵守 Flyable 契约。

  • 鸟 (Bird)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Bird是一个具体的类,它实现了Flyable接口。
public class Bird implements Flyable {
private String name;

public Bird(String name) {
this.name = name;
}

@Override
public void fly() {
// 这是Bird类对fly()方法的具体实现
System.out.println("小鸟 '" + name + "' 扇动翅膀,在空中飞翔。");
}
}
  • 飞机 (Airplane)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Airplane是另一个完全不相关的类,它也实现了Flyable接口。
public class Airplane implements Flyable {
private String model;

public Airplane(String model) {
this.model = model;
}

@Override
public void fly() {
// 这是Airplane类对fly()方法的具体实现
System.out.println("飞机 '" + model + "' 启动引擎,高速巡航。");
}
}
  • 超人 (Superman)
1
2
3
4
5
6
7
8
// Superman是第三个不相关的类。
public class Superman implements Flyable {
@Override
public void fly() {
// 这是Superman类对fly()方法的具体实现
System.out.println("超人披风一甩,像一颗子弹一样飞向天空!");
}
}
3. [多态应用] 统一处理所有“能飞”的对象

这是最关键的一步。在主程序中,我们可以将这些不同类型的对象,因为它们都实现了 Flyable 接口,而将它们视为同一种类型——Flyable 类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.ArrayList;
import java.util.List;

public class Main {
public static void main(String[] args) {
// 创建一个列表,它的类型是Flyable接口。
// 这意味着任何实现了Flyable接口的对象都可以被放进去。
List<Flyable> flyingThings = new ArrayList<>();

// 1. 添加各种不同具体类型、但都“能飞”的对象
flyingThings.add(new Bird("画眉"));
flyingThings.add(new Airplane("波音747"));
flyingThings.add(new Superman());
// 如果未来有了一个新的“风筝”类也实现了Flyable,可以直接加进来,无需修改这里的代码。
// flyingThings.add(new Kite());

// 2. 统一处理所有能飞的对象
// 遍历这个列表,调用每个对象的fly()方法
for (Flyable thing : flyingThings) {
// 这里发生了多态:
// 编译时,编译器只知道thing是一个Flyable,并且它一定有fly()方法。
// 运行时,JVM会根据thing的实际类型(Bird, Airplane, or Superman)
// 来调用各自类中重写的那个具体fly()方法。
thing.fly();
}
}
}

3.4.4 抽象与接口:更高层次的设计

在掌握了继承和多态后,我们进入了更高层次的抽象设计。有时,我们设计的类本身并不代表一个具体的实体,而是一种概念的提炼或行为的规范。Java为此提供了另一大工具:抽象类(abstract class)

抽象类 (abstract class)
核心用途与场景

当多个子类拥有一部分共同的状态(字段)和行为(方法),同时又各自拥有一些独特的行为时,我们可以将这些共性部分向上抽取,形成一个抽象类。

  • 核心思想:抽象类作为一个“不完整”的模板,它既可以包含具体实现的方法(供所有子类直接复用),也可以定义抽象方法(强制子类必须提供自己的实现)。
  • 最佳场景
    • 当你想在多个紧密相关的类之间共享代码时。
    • 当你设计的类包含一些公共的字段或方法,但其本身作为一个概念不应该被实例化时。
类型介绍与语法
  • 使用 abstract 关键字来修饰类和方法。
  • 抽象方法:只有方法签名,没有方法体(没有{}),以分号结尾。例如:public abstract void makeSound();
  • 规则
    1. 包含任何一个抽象方法的类,必须被声明为抽象类。
    2. 抽象类可以不包含任何抽象方法。这样做仅仅是为了禁止该类被实例化。
    3. 抽象类不能被实例化(不能new),它只能被继承。
    4. 子类继承一个抽象类后,必须实现父类中所有的抽象方法,除非该子类自己也是一个抽象类。
    5. 抽象类可以有构造器,其目的是为了供子类在初始化时通过super()调用。
[设计模式] 模板方法模式

这是抽象类最经典的应用场景。父类定义一个算法的整体骨架(模板),而将算法中某些可变的步骤延迟到子类中去实现。

  • 代码示例:制作饮品
    Beverage(饮品)这个抽象类定义了制作饮品的通用流程 prepareRecipe(),这个流程是固定的(final),但其中的brew()(冲泡)和addCondiments()(加调料)两个步骤,对于咖啡和茶来说是不同的,因此定义为抽象方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example;

// 抽象类:饮品
abstract class Beverage {
// 这是一个模板方法,它定义了算法的骨架,且不希望被子类修改
public final void prepareRecipe() {
boilWater();
brew(); // <-- 变化的步骤,由子类实现
pourInCup();
addCondiments(); // <-- 变化的步骤,由子类实现
}

// 抽象方法,子类必须实现
protected abstract void brew();
protected abstract void addCondiments();

// 具体方法,所有子类共用
public void boilWater() {
System.out.println("烧开水");
}
public void pourInCup() {
System.out.println("倒入杯中");
}
}

// 具体子类:咖啡
class Coffee extends Beverage {
@Override
protected void brew() {
System.out.println("用沸水冲泡咖啡");
}
@Override
protected void addCondiments() {
System.out.println("加糖和牛奶");
}
}

// 具体子类:茶
class Tea extends Beverage {
@Override
protected void brew() {
System.out.println("用沸水浸泡茶叶");
}
@Override
protected void addCondiments() {
System.out.println("加柠檬");
}
}

public class Main {
public static void main(String[] args) {
System.out.println("--- 制作咖啡 ---");
Beverage myCoffee = new Coffee();
myCoffee.prepareRecipe();

System.out.println("\n--- 制作茶 ---");
Beverage myTea = new Tea();
myTea.prepareRecipe();
}
}

3.4.5 [面试高频] 抽象类 vs. 接口 对比

现在,我们已经分别详细了解了抽象类和接口,可以对它们进行一个全面的对比。

面试题引入

“抽象类和接口有什么区别?在项目中你是如何选择的?”

抽象类 vs. 接口 对比表
对比维度抽象类 (abstract class)接口 (interface)
继承/实现单继承 (extends),一个类只能继承一个抽象类。多实现 (implements),一个类可以实现多个接口。
成员变量可以有各种类型的成员变量(实例变量、静态变量)。只能有public static final类型的常量
构造器有构造器,用于子类初始化。没有构造器
方法可包含抽象方法具体方法在Java 8之前只能有抽象方法,Java 8+可包含抽象方法default默认方法static静态方法。
设计目的倾向于表达“is-a”关系(是一个),对一类事物的共性进行抽象,强调“是什么”。倾向于表达“can-do”关系(能做什么),对一种能力行为进行定义,强调“能做什么”。
如何选择?
  • 优先选择接口:在大多数情况下,接口是更好的选择,因为它更灵活,耦合度更低。面向接口编程是软件设计的重要原则。
  • 使用抽象类的情况
    • 当你想在多个子类中**共享代码和状态(字段)**时。
    • 当这些子类共享一个明显的“is-a”关系,并且具有共同的基础行为时。
    • 当你需要控制非public的成员时(接口成员都是public的)。
面试题引入

“抽象类和接口有什么区别?”

抽象类 vs. 接口 对比表
对比维度抽象类 (abstract class)接口 (interface)
继承/实现单继承 (extends),一个类只能继承一个抽象类。多实现 (implements),一个类可以实现多个接口。
成员变量可以有各种类型的成员变量(实例变量、静态变量)。只能有public static final类型的常量
构造器有构造器,用于子类初始化。没有构造器
方法可包含抽象方法具体方法可包含抽象方法default默认方法和**static静态方法**。
设计目的倾向于表达“is-a”关系,对一类事物的共性进行抽象。倾向于表达“can-do”关系,对一种能力行为进行定义。

3.5 Object 类:万物之源

在Java的类继承体系中,java.lang.Object 类是位于金字塔最顶端的、唯一的根节点。无论我们创建任何类,如果它没有用extends关键字明确指定父类,那么它就默认继承自Object类。可以说,Object类是所有Java对象的“创世神”,它提供的方法是每个对象都具备的通用能力。

3.5.1 equals(Object obj) 方法详解

面试题引入

==equals() 有什么本质区别?”

这是一个入门级但极其重要的面试题,回答的深度能直接反映候选人的基础水平。

  • == 运算符

    • 当用于基本数据类型时,它比较的是是否相等。
    • 当用于引用数据类型时,它比较的是两个引用变量是否指向同一个内存地址,即是否为同一个对象实例。
  • Object.equals() 的默认行为
    如果我们查看Object类的源码,会发现它的equals()方法实现极其简单:

    1
    2
    3
    public boolean equals(Object obj) {
    return (this == obj);
    }

    这清晰地表明,在未被重写的情况下,equals()方法与==对于引用类型的比较,行为完全一致,都是比较对象的身份(内存地址)

  • 重写的必要性与契约
    在实际业务中,我们往往不关心两个引用是否指向同一个对象,而是关心它们所代表的逻辑内容是否相等。例如,两个不同的Person对象,只要它们的身份证号相同,我们就应认为它们是“相等”的。为此,我们必须重写equals()方法来定义自己的逻辑相等性。

    在重写equals()时,必须遵守Java官方定义的五大契约,以保证其行为的正确和可预测性:

    1. 自反性: 对于任何非null的引用xx.equals(x)必须返回true
    2. 对称性: 对于任何非null的引用xy,如果x.equals(y)true,那么y.equals(x)也必须为true
    3. **传递性 **: 如果x.equals(y)true,且y.equals(z)true,那么x.equals(z)也必须为true
    4. **一致性 **: 只要xy对象中用于比较的信息没有被修改,无论调用多少次x.equals(y),都应返回相同的结果。
    5. null的比较: 对于任何非null的引用xx.equals(null)必须返回false

    所以使用Idea的快速重写equals方法时,或增加Lombok注解,最终返回的即是如下的代码示例

代码示例:正确地重写equals
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example;

import java.util.Objects;

class Person {
private String idCard;
private String name;

public Person(String idCard, String name) {
this.idCard = idCard;
this.name = name;
}

// 正确的、健壮的equals方法实现模板
@Override
public boolean equals(Object obj) {
// 1. 检查是否为同一个对象引用
if (this == obj) return true;

// 2. 检查obj是否为null,以及类型是否完全一致
if (obj == null || getClass() != obj.getClass()) return false;

// 3. 强制类型转换
Person person = (Person) obj;

// 4. 比较核心字段的内容是否相等
return Objects.equals(idCard, person.idCard);
}
}

public class Main {
public static void main(String[] args) {
Person p1 = new Person("12345", "张三");
Person p2 = new Person("12345", "三·张"); // 姓名不同
Person p3 = new Person("54321", "李四");

System.out.println("p1.equals(p2): " + p1.equals(p2)); // true,因为idCard相同
System.out.println("p1.equals(p3): " + p1.equals(p3)); // false,因为idCard不同
}
}

3.5.2 hashCode() 方法详解

面试题引入

“为什么重写equals时必须重写hashCode?请解释它们之间的契约关系。”

核心契约

hashCode()方法返回一个对象的哈希码(一个int值),这个值主要供HashMapHashSet等哈希集合使用。equals()hashCode()之间存在一个必须被严格遵守的契约:

  1. 如果两个对象通过equals()方法比较是相等的,那么它们的hashCode()值必须相等。
  2. 如果两个对象的hashCode()相等,它们的equals()不一定相等(这被称为哈希冲突)。
违反契约的后果

如果你只重写了equals()而没有重写hashCode(),那么Object类默认的hashCode()方法(通常基于内存地址计算)依然会被使用。这将导致两个内容上equals的、但地址不同的对象,拥有不同的hashCode。当这样的对象被放入HashSet或作为HashMap的键时,集合将无法正常工作。

代码示例:违反hashCode契约的后果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example;

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

class PersonWithoutHashCode {
private String idCard;
public PersonWithoutHashCode(String id) { this.idCard = id; }

@Override
public boolean equals(Object obj) { // 只重写了equals
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
return Objects.equals(idCard, ((PersonWithoutHashCode) obj).idCard);
}
// 未重写hashCode()!
}

public class Main {
public static void main(String[] args) {
Set<PersonWithoutHashCode> set = new HashSet<>();
set.add(new PersonWithoutHashCode("123"));
set.add(new PersonWithoutHashCode("123")); // 试图添加一个“相等”的对象

// 因为hashCode不同,HashSet认为它们是两个不同的对象
System.out.println("集合大小: " + set.size()); // 输出: 2,错误!本应是1

// contains方法也无法正常工作
System.out.println("集合是否包含身份证为123的人: " +
set.contains(new PersonWithoutHashCode("123"))); // 输出: false,错误!
}
}

所以这也是说明了,为什么Idea提供的快捷插入指令会将二者绑定到一起

3.5.3 toString() 方法

  • 核心用途:返回一个对象的“自我描述”字符串,这对于日志记录、调试打印和程序输出至关重要。
  • 默认行为Object类的toString()默认返回"类名@哈希码的十六进制表示",如com.example.Person@1a2b3c4d,信息量很小。
  • 重写建议:强烈建议所有自定义类都重写toString(),以提供有意义的对象状态信息。
1
2
3
4
5
6
// 在Person类中添加
@Override
public String toString() {
return "Person{idCard='" + idCard + "', name='" + name + "'}";
}
// 之后 System.out.println(personObject); 就会打印出格式化的内容。

3.5.4 clone() 方法与深/浅拷贝

面试题引入

“谈谈你对深拷贝和浅拷贝的理解,在Java中如何实现对象克隆?”

clone()方法用于创建并返回一个对象的副本。要使用它,一个类必须:

  1. 实现java.lang.Cloneable接口(这是一个标记接口,本身没有方法)。
  2. 重写Objectclone()方法,并将其访问修饰符提升为public
浅拷贝 (Shallow Copy)

super.clone()执行的是浅拷贝。它会创建一个新对象,然后将原始对象中所有字段的值原封不动地复制到新对象中。

  • 对于基本类型字段,复制的是值。
  • 对于引用类型字段,复制的是内存地址

这意味着,浅拷贝后,原对象和克隆对象的引用类型字段将指向同一个子对象。修改任何一个都会影响另一个。

深拷贝 (Deep Copy)

深拷贝不仅复制对象本身,还会递归地复制其内部引用的所有可变对象,直到所有对象都被复制为新的实例。最终,原对象和克隆对象完全独立,互不影响。

代码示例:深浅拷贝对比
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example;

// Address是可变对象
class Address implements Cloneable {
public String city;
public Address(String city) { this.city = city; }

@Override
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

class User implements Cloneable {
public String name;
public Address address; // 引用类型字段

public User(String name, Address address) { this.name = name; this.address = address; }

// 实现深拷贝
@Override
public Object clone() throws CloneNotSupportedException {
// 1. 先进行浅拷贝,复制基本类型和引用地址
User clonedUser = (User) super.clone();
// 2. 对引用类型的字段,手动进行单独的克隆
clonedUser.address = (Address) this.address.clone();
return clonedUser;
}
}

public class Main {
public static void main(String[] args) throws CloneNotSupportedException {
Address address = new Address("北京");
User user1 = new User("张三", address);

// 使用深拷贝克隆
User user2 = (User) user1.clone();

// 修改克隆对象的地址
user2.address.city = "上海";

// 验证原对象是否受影响
System.out.println("原用户的城市: " + user1.address.city); // 输出:北京,未受影响
System.out.println("克隆用户的城市: " + user2.address.city); // 输出:上海
}
}

3.5.5 其他方法简介

  • getClass(): 反射的入口,返回一个对象的运行时Class实例。
  • wait(), notify(), notifyAll(): 用于多线程协作的底层方法,它们必须在synchronized代码块中被锁对象调用。详细内容将在后续的并发编程章节中深入探讨。

3.6 static 关键字深度剖析

static是Java中一个非常基础但功能强大的修饰符。它的核心作用是声明一个不依赖于任何对象实例而存在的成员,这个成员直接隶属于本身。理解static是区分“实例成员”与“类成员”的关键,也是掌握单例模式、工具类设计等高级技巧的前提。

3.6.1 static 的核心本质:属于类,而非对象

面试题引入

“请谈谈你对static关键字的理解,它的生命周期是怎样的?”

核心概念

一个类就像一张“图纸”,而对象是根据这张图纸制造出来的“产品”。

  • 非静态成员(实例成员):属于每个“产品”各自的属性。比如,对于Car类,color(颜色)字段就是实例成员,因为每辆车都可以有不同的颜色。你必须先有一辆具体的车(对象),才能谈论它的颜色(myCar.color)。
  • 静态成员(类成员):属于“图纸”本身的属性,被所有产品共享。比如,我们可以定义一个static int numberOfWheels = 4;,因为“所有汽车都有4个轮子”是这张图纸的固有设定,与任何一辆具体的车无关。你可以通过图纸直接访问它(Car.numberOfWheels)。
内存与生命周期
  • 内存位置
    • 静态成员(静态变量、静态方法)存储在JVM的方法区(在Java 8及之后称为Metaspace)。无论这个类创建了多少个对象,静态成员在内存中只有一份副本
    • 实例成员(非静态字段)存储在**堆内存(Heap)**中,每创建一个对象,就会在堆上为它的实例成员分配一块新的内存。
  • 生命周期static成员的生命周期与类绑定,遵循“先有类,后有对象”的原则。
    1. 类加载时:当一个类的.class文件首次被JVM加载时,其static成员就会被分配内存并进行初始化。这个过程只会发生一次
    2. 对象创建时:之后,每当使用new关键字创建对象时,才会在堆上为该对象的实例成员分配内存。

3.6.2 static 的四种核心应用场景

**1. 静态变量 **
  • 用途:用于定义被一个类的所有实例共享的状态或数据。
  • 场景示例:实现一个对象创建计数器,统计某个类总共被实例化了多少次。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example;

class User {
// 静态变量,属于User类,用于计数,所有User对象共享此变量
private static int userCounter = 0;

// 实例变量,每个User对象独有一份
private int userId;
private String name;

public User(String name) {
// 每当构造器被调用(即创建一个新对象),静态计数器加1
userCounter++;
this.userId = userCounter; // 将当前的计数值赋给新用户的ID
this.name = name;
System.out.println("第 " + this.userId + " 个用户 '" + this.name + "' 已创建。");
}

public static int getTotalUsers() {
return userCounter;
}
}

public class Main {
public static void main(String[] args) {
new User("Alice");
new User("Bob");
new User("Charlie");

// 可以通过类名直接访问静态成员
System.out.println("总用户数: " + User.getTotalUsers());
}
}
**2. 静态方法 **
  • 用途:用于定义那些不依赖于任何对象内部状态(实例字段)的工具类行为。
  • 场景示例:几乎所有的工具类,如java.lang.Mathjava.util.Arrays,其方法都是静态的,因为它们的计算只依赖于传入的参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example;

// 一个简单的数学工具类
final class MathUtils {
// 构造器私有化,防止外部创建实例,因为所有方法都是静态的,无需对象
private MathUtils() {}

// 静态方法,计算两数之和
public static int add(int a, int b) {
return a + b;
}

// 静态方法,计算圆的面积
public static double circleArea(double radius) {
return Math.PI * radius * radius;
}
}

public class Main {
public static void main(String[] args) {
// 直接通过类名调用静态方法,无需创建MathUtils对象
int sum = MathUtils.add(15, 27);
double area = MathUtils.circleArea(10.0);

System.out.println("15 + 27 = " + sum);
System.out.println("半径为10的圆面积: " + area);
}
}
3. 静态代码块
  • 用途:用于执行类级别的、仅在类首次加载时运行一次的复杂初始化操作。
  • 场景示例:加载数据库驱动,或者从配置文件中读取信息来初始化一个静态的Map
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example;

import java.util.HashMap;
import java.util.Map;

class ConfigLoader {
// 定义一个静态Map用于存储配置
public static final Map<String, String> CONFIG_MAP = new HashMap<>();

// 静态代码块,在ConfigLoader类被加载时执行一次
static {
System.out.println("静态代码块执行:正在加载系统配置...");
// 模拟从文件或数据库加载配置
CONFIG_MAP.put("db.driver", "com.mysql.cj.jdbc.Driver");
CONFIG_MAP.put("api.url", "https://api.example.com");
CONFIG_MAP.put("default.timeout", "3000");
System.out.println("配置加载完毕!");
}
}

public class Main {
public static void main(String[] args) {
// 第一次访问ConfigLoader的静态成员时,会触发静态代码块的执行
System.out.println("获取数据库驱动: " + ConfigLoader.CONFIG_MAP.get("db.driver"));

System.out.println("---");

// 第二次访问时,静态代码块不会再次执行
System.out.println("获取API地址: " + ConfigLoader.CONFIG_MAP.get("api.url"));
}
}
4. 静态内部类
  • 用途:定义一个逻辑上与外部类紧密相关,但实例化时不依赖于外部类对象的类。
  • 与非静态内部类的核心区别:非静态内部类会隐式地持有一个外部类实例的引用,而静态内部类则不会。
  • [设计模式] 场景示例建造者模式(Builder Pattern) 是静态内部类的绝佳应用场景。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package com.example;

// 假设Computer是一个复杂的对象,有很多可选配置
class Computer {
// 必需参数
private final String cpu;
private final String ram;
// 可选参数
private final String storage;
private final String gpu;

// 构造器是私有的,强制用户通过Builder来创建对象
private Computer(Builder builder) {
this.cpu = builder.cpu;
this.ram = builder.ram;
this.storage = builder.storage;
this.gpu = builder.gpu;
}

@Override
public String toString() {
return "Computer [CPU=" + cpu + ", RAM=" + ram + ", Storage=" + storage + ", GPU=" + gpu + "]";
}

// 静态内部类 Builder
public static class Builder {
private final String cpu; // 必需
private final String ram; // 必需
private String storage = "256GB SSD"; // 可选,提供默认值
private String gpu = "Integrated"; // 可选,提供默认值

public Builder(String cpu, String ram) {
this.cpu = cpu;
this.ram = ram;
}

public Builder storage(String storage) {
this.storage = storage;
return this; // 返回this以支持链式调用
}

public Builder gpu(String gpu) {
this.gpu = gpu;
return this;
}

public Computer build() {
return new Computer(this);
}
}
}

public class Main {
public static void main(String[] args) {
// 使用Builder链式调用来创建复杂的Computer对象,代码清晰易读
Computer gamingPC = new Computer.Builder("Intel i9", "32GB")
.storage("2TB NVMe SSD")
.gpu("NVIDIA RTX 4090")
.build();

Computer officePC = new Computer.Builder("Intel i5", "16GB").build();

System.out.println("游戏电脑配置: " + gamingPC);
System.out.println("办公电脑配置: " + officePC);
}
}

3.6.3 [面试核心] 静态上下文的限制

面试官:“静态方法为什么不能直接访问非静态成员(字段或方法)?”

原理解析:这个问题的根本原因在于生命周期的不同,即“先有类,后有对象”。

  1. **静态成员(类成员)**在类被加载到JVM时就诞生了,此时内存中可能还没有任何该类的对象实例。
  2. **非静态成员(实例成员)**必须依赖于具体的对象实例而存在。每new一个对象,才会在堆内存中为这些成员开辟一块空间。
  3. 因此,当你在一个静态方法(它属于类,不属于任何特定对象)中,试图去访问一个非静态字段(它必须属于某个特定对象)时,JVM会感到困惑:“你到底想访问哪一个对象的这个字段呢?”——因为此时可能一个对象都没有,也可能有一万个。在没有明确的对象实例(即没有this引用)的静态上下文中,访问实例成员是不合逻辑的,也是不被允许的。

反之,实例方法可以随意访问静态成员,因为当实例方法被调用时,必然已经存在一个对象实例,而这个对象所属的类也必然早已被加载,所以静态成员一定存在于内存中,可以安全访问。


3.7 内部类

内部类,顾名思义,就是定义在另一个类内部的类。它并非一个可有可无的语法糖,而是一种强大的编程工具,能够帮助我们编写出结构更清晰、封装性更好的代码。

3.7.1 为什么需要内部类?

面试题引入

“你为什么会在项目中使用内部类?它解决了什么问题?”

核心价值
  1. 逻辑组织与代码可读性:当一个类(如Engine)在逻辑上只为另一个类(如Car)服务时,将它作为内部类可以清晰地表达这种从属关系,避免了在包中创建大量仅被单一类使用的辅助类。
  2. 增强封装:这是内部类最强大的特性。内部类可以无条件地访问其外部类的所有成员,包括private修饰的字段和方法。这提供了一种比常规封装更紧密的耦合方式,允许外部类将实现细节完全隐藏在内部,仅通过内部类来操作。
  3. 优雅地实现回调:匿名内部类(将在后面讲到)是实现事件监听和回调机制的经典方式。

3.7.2 成员内部类

成员内部类是最普通的一种内部类,它作为外部类的一个非静态成员存在,地位与实例字段和实例方法相同。

  • 核心特性:成员内部类的实例隐式地持有一个外部类实例的引用。这意味着,它的生命周期与外部类对象绑定,并且可以直接访问外部类的所有实例成员。
  • 实例化方式:它的创建必须依赖于一个外部类的对象。
代码示例:CarEngine

Engine(引擎)是Car(汽车)的核心部件,引擎的状态(如转速)可能需要依赖汽车的状态(如油门深度)。将Engine作为Car的成员内部类,可以完美地模拟这种关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example;

class Car {
private String brand;
private int speed = 0; // 汽车的私有状态

public Car(String brand) {
this.brand = brand;
}

// 成员内部类 Engine
class Engine {
private int rpm = 0; // 引擎的私有状态

public void start() {
// 内部类可以直接访问外部类的私有字段 brand 和 speed
System.out.println(brand + " 的引擎启动了!外部车速: " + speed);
this.rpm = 800;
}

public void accelerate() {
// 内部类可以直接修改外部类的状态
speed += 20;
this.rpm += 500;
System.out.println("引擎转速: " + this.rpm + " RPM, 车速提升至: " + speed + " km/h");
}
}

// 外部类的方法,用于获取并使用内部类对象
public void startCar() {
Engine engine = new Engine(); // 在外部类内部,可以直接创建内部类实例
engine.start();
engine.accelerate();
}
}

public class Main {
public static void main(String[] args) {
// 1. 创建外部类实例
Car myCar = new Car("法拉利");
myCar.startCar();

System.out.println("---");

// 2. 在外部创建成员内部类实例(不常用,但语法上可行)
// 必须通过一个外部类对象来创建
Car.Engine anotherEngine = myCar.new Engine();
anotherEngine.start();
}
}

3.7.3 静态内部类

静态内部类是被static修饰的内部类。它与成员内部类的核心区别在于它不持有外部类实例的引用

  • 核心特性:因为它不依赖于任何外部类对象,所以它只能访问外部类的静态成员。本质上,静态内部类更像是一个被“藏”在外部类命名空间下的一个独立的顶层类。
  • 实例化方式:可以独立于外部类对象直接创建。
[设计模式] 场景回顾:建造者模式 (Builder Pattern)

静态内部类最经典的应用就是实现建造者模式,用于构建具有多个可选参数的复杂对象,使用lombok,仅需要加上@Builder注解,他在内部相当于做了和我们静态代码块相似的操作,最后返回一个携带好了的Computer对象供我们使用,无需使用new关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example;

import lombok.Builder;
import lombok.Data;

// Computer是一个复杂的对象
@Data
@Builder
class Computer {
private final String cpu;
private final String ram;
private final String gpu;
}

public class Main {
public static void main(String[] args) {
// 直接创建静态内部类实例,无需外部类对象
// 使用 Lombok 生成的构建器链式调用方法设置属性
Computer gamingPC = Computer.builder()
.cpu("Intel i9")
.ram("32GB")
.gpu("NVIDIA RTX 4090")
.build();
System.out.println(gamingPC);
}
}

3.7.4 局部内部类

局部内部类是定义在方法体内部的类,是四种内部类中用得最少的一种。

  • 核心特性:它的作用域被严格限制在定义它的那个方法之内,对外部世界完全不可见。它可以访问方法内的局部变量,但这些变量必须是final事实上的final(即初始化后未被再次赋值)。
  • 原因:因为方法执行完毕后,局部变量的生命周期就结束了,但此时局部内部类的对象可能还存活着(例如被返回或被其他对象持有)。为了保证内部类对象在未来还能访问到这个变量的值,Java会将被访问的局部变量的值复制一份给内部类。为了防止数据不一致,这个变量必须是不可变的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example;

class Outer {
public void display() {
final String message = "这是一个局部变量"; // 必须是final或事实final

// 定义在display方法内的局部内部类
class LocalInner {
public void print() {
// 可以访问外部类的成员,也可以访问方法内的final局部变量
System.out.println(message);
}
}

// 只能在方法内部创建和使用
LocalInner local = new LocalInner();
local.print();
}
}

public class Main {
public static void main(String[] args) {
Outer outer = new Outer();
outer.display();
}
}

3.7.5 匿名内部类

匿名内部类是一种没有名字的局部内部类。它通常用于快速地、一次性地实现一个接口或继承一个类,并立即创建一个该实现类的对象。

场景一:GUI事件监听器(最经典的应用)

在Java的图形界面编程(如Swing, AWT)中,为按钮、菜单等组件添加事件响应逻辑,是匿名内部类的“主战场”。

目的:为一个“注册”按钮添加点击事件。当按钮被点击时,执行一段特定的业务逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example;

import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class Main {
public static void main(String[] args) {
// 创建一个窗口和一个按钮
JFrame frame = new JFrame("匿名内部类示例");
JButton registerButton = new JButton("注册");

// --- 核心代码:使用匿名内部类作为事件监听器 ---
// addActionListener需要一个ActionListener接口的实例。
// 我们在这里直接用“new ActionListener()”来定义并创建一个实现类。
registerButton.addActionListener(new ActionListener() {
// 这是对ActionListener接口中唯一方法 actionPerformed 的实现
@Override
public void actionPerformed(ActionEvent e) {
// 这里是按钮被点击时要执行的逻辑
System.out.println("注册按钮被点击!");
JOptionPane.showMessageDialog(frame, "注册逻辑被触发!");
}
});
// ---------------------------------------------

// 将按钮添加到窗口并显示
frame.add(registerButton);
frame.setSize(300, 200);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setVisible(true);
}
}

分析:在这个场景中,我们只需要一个一次性的、与“注册按钮”紧密绑定的点击行为。专门为此定义一个独立的具名类会显得非常冗余。匿名内部类让我们可以在需要的地方,就地完成实现类的定义和实例化,代码紧凑且意图清晰。

场景二:自定义集合排序规则 (Comparator)

当我们需要对一个集合进行一次性的、非标准的排序时,匿名内部类是定义临时排序逻辑的绝佳工具。

目的:有一个Product(商品)列表,我们希望不修改Product类本身,而是根据价格对其进行降序排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

class Product {
String name;
double price;
public Product(String name, double price) { this.name = name; this.price = price; }
@Override
public String toString() { return name + " (价格: " + price + ")"; }
}

public class Main {
public static void main(String[] args) {
List<Product> products = new ArrayList<>();
products.add(new Product("笔记本电脑", 7999.0));
products.add(new Product("机械键盘", 499.0));
products.add(new Product("显示器", 1599.0));

System.out.println("排序前: " + products);

// --- 核心代码:使用匿名内部类实现一个临时的Comparator ---
// List.sort()方法需要一个Comparator接口的实例来定义排序规则。
products.sort(new Comparator<Product>() {
// 实现compare方法,定义排序逻辑
@Override
public int compare(Product p1, Product p2) {
// p2的价格减p1的价格,实现降序排序
return Double.compare(p2.price, p1.price);
}
});
// ----------------------------------------------------

// 使用Lambda表达式简化
products.sort((p1, p2 )-> Double.compare(p2.price, p1.price));


System.out.println("按价格降序排序后: " + products);
}
}

分析:这种“按价格排序”的逻辑可能只在此处使用一次。使用匿名内部类,我们可以将这个特定的排序规则直接定义在调用sort方法的地方,而无需污染代码库,增加一个几乎不会被复用的ProductPriceComparator类。


3.8 枚举 (enum):类型安全的“多例”模式

枚举(enum)是Java 5引入的一项关键特性。它远不止是“一组常量”的集合,而是一种功能强大的、类型安全的、面向对象的枚举模式实现。理解并善用枚举,是编写健壮、可读、可维护代码的重要一环。

3.8.1 enum 的诞生:告别“魔法值”与不安全

面试题引入

“枚举(enum)相比于用public static final int常量来表示一组固定值,有什么核心优势?”

“旧时代”的做法:使用静态常量

在没有枚举的时代,我们通常这样定义一组相关的常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 使用静态常量定义一周的星期
public class WeekConstants {
public static final int MONDAY = 1;
public static final int TUESDAY = 2;
// ...
}

// 使用时
public void schedule(int day) {
if (day == WeekConstants.MONDAY) {
// ...
}
}
传统方式的痛点
  1. 非类型安全schedule方法的参数是int,这意味着我可以传入任何整数,如schedule(999),编译器无法发现错误,只能在运行时产生逻辑BUG。
  2. 无意义的“魔法值”:数字1本身没有任何业务含义,它与“星期一”的关联全靠开发者的记忆和文档。
  3. 可读性差:在调试或日志中看到一个数字1,远不如看到MONDAY来得直观。
  4. 难以扩展:无法将更多的信息(如“星期一”的中文名)与常量1结构化地关联起来。

枚举的出现,完美地解决了以上所有问题。

3.8.2 enum 的基本用法与核心方法

基本定义

enum关键字用于定义一个枚举类型。每个枚举中列出的名称都代表该枚举类型的一个唯一的、公开的、静态的、final的实例。

1
2
3
4
// 定义一个简单的星期枚举
public enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
常用方法速查表
方法签名功能描述
values()静态方法,返回一个包含所有枚举实例的数组,常用于遍历。
valueOf(String name)静态方法,根据字符串名称返回对应的枚举实例(大小写敏感)。
name()返回枚举实例的声明名称(如 “MONDAY”)。
ordinal()返回枚举实例的序数(从0开始)。强烈不推荐在业务逻辑中依赖它,因为顺序改变会导致BUG。
toString()默认返回name()的值,但可以被重写以提供更友好的输出。
代码示例:在switch中使用enum

这是enum最常见的场景之一,代码不仅可读性高,而且switch表达式(Java 14+)还能利用编译器的穷尽性检查来保证安全性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example;

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

public class Main {
public static String getFeeling(Day day) {
// 使用switch表达式,代码简洁且安全
return switch (day) {
case MONDAY -> "有点不想上班...";
case FRIDAY -> "耶!就快放假了!";
case SATURDAY, SUNDAY -> "休息日,开心!";
default -> "平常心,努力工作中...";
};
}

public static void main(String[] args) {
// 遍历所有枚举实例
for (Day day : Day.values()) {
System.out.println(day.name() + " (" + day.ordinal() + "): " + getFeeling(day));
}
}
}

3.8.3 enum 的进阶用法:枚举也是类

这是enum最强大的地方——它本质上是一个特殊的类。这意味着它可以拥有自己的字段、构造器和方法。

面试题引入

“你能在枚举中定义方法和字段吗?请举例说明。”

场景一:为枚举添加自定义属性和方法

需求:定义一组支付方式,每种方式都有其中文名和对应的手续费率,并能计算手续费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.example;

import java.math.BigDecimal;
import java.math.RoundingMode;

enum PaymentType {
// 1. 枚举实例必须在最前面声明,并可调用构造器
ALI_PAY("支付宝", new BigDecimal("0.006")),
WECHAT_PAY("微信支付", new BigDecimal("0.006")),
CREDIT_CARD("信用卡", new BigDecimal("0.01")),
DEBIT_CARD("储蓄卡", BigDecimal.ZERO); // 0费率

// 2. 定义私有final字段
private final String displayName; // 显示名称
private final BigDecimal feeRate; // 费率

// 3. 构造器必须是private的(或包私有)
private PaymentType(String displayName, BigDecimal feeRate) {
this.displayName = displayName;
this.feeRate = feeRate;
}

// 4. 定义公共的getter方法
public String getDisplayName() {
return displayName;
}

public BigDecimal getFeeRate() {
return feeRate;
}

// 5. 定义自己的业务方法
public BigDecimal calculateFee(BigDecimal amount) {
// 计算公式为:金额 * 费率
return amount.multiply(this.feeRate).setScale(2, RoundingMode.HALF_UP);
}

@Override
public String toString() {
return this.displayName; // 重写toString,让打印更友好
}
}

public class Main {
public static void main(String[] args) {
BigDecimal orderAmount = new BigDecimal("1000.00");

PaymentType payment = PaymentType.CREDIT_CARD;

System.out.println("选择的支付方式: " + payment);
System.out.println("手续费率: " + payment.getFeeRate() + "%");
System.out.println("订单金额 " + orderAmount + "元,手续费为: " + payment.calculateFee(orderAmount) + "元");
}
}
场景二:为枚举实现接口,实现策略模式

需求:定义一组运算操作,每个操作都能执行自己的运算逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example;

// 1. 定义一个行为接口
interface Calculable {
int calculate(int a, int b);
}

// 2. 让枚举实现这个接口
public enum Operation implements Calculable {
// 3. 每个枚举实例都必须实现接口的方法,形成各自的策略
PLUS("+") {
@Override
public int calculate(int a, int b) {
return a + b;
}
},
MINUS("-") {
@Override
public int calculate(int a, int b) {
return a - b;
}
};
// 也可以有自己的字段和构造器
private final String symbol;
private Operation(String symbol) { this.symbol = symbol; }

@Override
public String toString() { return symbol; }
}

public class Main {
public static void main(String[] args) {
int a = 10;
int b = 5;

// 直接调用枚举实例的方法,实现多态
System.out.println(a + " " + Operation.PLUS + " " + b + " = " + Operation.PLUS.calculate(a, b));
System.out.println(a + " " + Operation.MINUS + " " + b + " = " + Operation.MINUS.calculate(a, b));
}
}

3.9 [设计模式] 面向对象设计模式

3.9.1 设计模式思想

面试题引入

“你最熟悉的设计模式有哪些?你认为,我们为什么要使用设计模式?”

核心思想:可复用的解决方案

设计模式并非一种具体的代码、框架或算法,而是在软件设计过程中,针对特定问题的、一套可复用的、经过无数次实践验证的解决方案

可以把设计模式理解为软件开发的“兵法”或“棋谱”。就像古代将军打仗有各种阵法(一字长蛇阵、八门金锁阵),象棋高手有各种开局和残局的定式一样,这些“阵法”和“定式”都是前人耗费了大量心血,从无数次成功与失败中总结出的、在特定情境下最高效、最稳妥的策略。

学习设计模式,不是为了死记硬背,而是为了:

  1. 站在巨人的肩膀上:我们遇到的大多数设计问题,前人都已经遇到过并找到了优雅的解决方案。使用设计模式可以让我们避免“重复发明轮子”,直接采用成熟、可靠的设计。
  2. 提升代码质量:设计模式的核心目标是提升软件的可维护性、可复用性和可扩展性,遵循设计模式编写的代码通常具有更好的结构,更容易被理解和修改。
  3. 提供通用词汇:当你说“我这里用了一个单例模式”,团队里的其他工程师能立刻理解你的设计意图,这极大地提高了沟通效率。

设计模式通常分为三大类:创建型模式(如何创建对象)、结构型模式(如何组合类和对象)和行为型模式(对象之间如何交互和分配职责)。本章将聚焦于几种最基础、最核心的模式。

3.9.2 单例模式: 保证实例的独一无二

核心思想与用途

单例模式是一种创建型模式,其核心目标是:确保一个类在整个应用程序的生命周期中,只有一个实例存在,并提供一个全局的、统一的访问点来获取这个唯一的实例。

应用场景

当一个对象需要被系统中的多个部分共享,且它的存在只需要一份时,就应该使用单例模式。这份“唯一”的实例通常代表着一种全局性的资源或服务。

  • 配置管理器:整个应用的配置信息只需要加载一次,并由一个统一的对象管理,供各处读取。
  • 数据库连接池:连接池的初始化和管理是重量级操作,整个应用共享一个连接池实例可以避免资源的浪费和竞争。
  • 日志对象:应用中的所有模块都应该使用同一个日志记录器,以便将日志输出到同一个地方。
  • 操作系统中的任务管理器、回收站:这些在整个系统中都只能有一个实例存在。
  • Spring框架中的Bean:在Spring容器中,默认作用域(Scope)的Bean就是单例的。
实现方式详解
1. 饿汉式

“饿汉式”正如其名,非常“饥渴”,不管你将来用不用,在类被加载的时候,它就立刻把实例创建出来了。

  • 代码实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    package com.example;

    // 饿汉式单例
    public class Main {
    // 1. 构造器私有化,防止外部通过 new 来创建实例
    private Main() {
    System.out.println("饿汉式单例的构造器被调用。");
    }

    // 2. 在类加载时就直接创建并持有一个静态的、final的实例
    private static final Main INSTANCE = new Main();

    // 3. 提供一个公共的静态方法,作为全局唯一的访问点
    public static Main getInstance() {
    return INSTANCE;
    }

    public void doSomething() {
    System.out.println("饿汉式单例正在工作...");
    }

    public static void main(String[] args) {
    Main instance1 = Main.getInstance();
    Main instance2 = Main.getInstance();

    System.out.println("instance1 和 instance2 是否是同一个对象? " + (instance1 == instance2));
    instance1.doSomething();
    }
    }
  • 思想与优缺点
    • 优点:实现非常简单。因为实例是在类加载的静态初始化阶段创建的,这个过程由JVM保证线程安全,所以天生就是线程安全的。
    • 缺点可能造成资源浪费。如果这个单例对象非常消耗资源(比如,它在构造时需要加载一个很大的文件),而你的程序在整个运行过程中一次都没有使用过它,那么这次实例化的开销就白白浪费了。
2. 懒汉式

“懒汉式”则比较“懒惰”,它不会在类加载时就创建实例,而是等到第一次有人调用getInstance()方法时,才去检查并创建实例。

  • 基础懒汉式(线程不安全)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 这是一个线程不安全的版本,仅用于理解思想
    class LazySingleton {
    private static LazySingleton instance;
    private LazySingleton() {}
    public static LazySingleton getInstance() {
    if (instance == null) {
    instance = new LazySingleton();
    }
    return instance;
    }
    }

    问题分析:在多线程环境下,假设线程A和线程B同时执行到if (instance == null),都判断为true,那么它们都会去执行new LazySingleton(),最终导致创建出两个不同的实例,违背了单例的原则。

  • [面试核心] DCL (Double-Checked Locking) 懒汉式
    为了解决懒汉式的线程安全问题,同时又尽可能地减少同步带来的性能开销,业界演进出了“双重检查锁定”这一工业级的标准实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    package com.example;

    public class Main {
    // 1. 使用 volatile 关键字确保多线程下的可见性和禁止指令重排
    private static volatile Main instance;

    private Main() {
    System.out.println("DCL懒汉式单例的构造器被调用。");
    }

    public static Main getInstance() {
    // 2. 第一次检查:如果实例已存在,直接返回,避免不必要的加锁
    if (instance == null) {
    // 3. 同步代码块:只在实例未创建时才进入,保证只有一个线程能创建实例
    synchronized (Main.class) {
    // 4. 第二次检查:防止其他线程已创建实例
    if (instance == null) {
    instance = new Main();
    }
    }
    }
    return instance;
    }

    public void doSomething() {
    System.out.println("DCL懒汉式单例正在工作...");
    }

    public static void main(String[] args) {
    // 模拟多线程并发访问
    for (int i = 0; i < 10; i++) {
    new Thread(() -> {
    Main instance = Main.getInstance();
    System.out.println(Thread.currentThread().getName() + " 获取到的实例哈希码: " + instance.hashCode());
    }).start();
    }
    }
    }
  • 思想与DCL细节解析

    • 双重检查if (instance == null) 检查了两次。第一次检查是为了在实例已经存在的情况下,让后续线程无需进入重量级的synchronized块,直接返回,极大地提高了性能。第二次检查是在锁内部,确保了即使有多个线程通过了第一次检查,也只有一个线程能真正创建实例。
    • volatile关键字:这是一个至关重要的点。new Main()这个操作在JVM中并非原子性的,它大致可以分为三步:
    • a. 分配内存空间;
    • b. 初始化对象;
    • c. 将instance引用指向分配的内存地址。
    • 由于指令重排序的存在,b和c的顺序可能会被颠倒。如果一个线程执行了a和c但还没执行b,另一个线程在第一次检查时就会看到instance不为null而直接返回一个“半成品”对象,使用时就会出错。volatile关键字可以禁止这种指令重排序,并保证instance变量在多线程间的可见性,确保任何线程拿到的都是完整的实例。

3.9.3 工厂模式: 解耦对象的创建与使用

核心思想与用途

工厂模式是一种创建型模式,它的核心思想是:定义一个用于创建对象的接口(或类),但让实现这个接口的类(或子类)来决定实例化哪个类。工厂方法让类的实例化推迟到子类中进行。

简单来说,就是将对象的创建过程对象的使用过程中分离出来。客户端代码不再需要自己去new一个具体的产品对象,而是向一个“工厂”索要。这样做的好处是,如果未来需要更换产品的具体实现,或者增加新的产品,客户端代码完全不需要修改,只需要修改工厂内部的逻辑即可。

应用场景
  • 当你需要一个能生产多种产品,但具体生产哪一种是在运行时才决定的系统。
  • 当你希望将产品的创建逻辑封装起来,不让客户端知道具体的实现细节。
  • JDBC数据库连接DriverManager.getConnection()就是一个典型的工厂方法,你传入不同的数据库URL,它会返回不同厂商(如MySQL, Oracle)的Connection实现类的实例。
  • 各种解析器(XML, JSON)的创建。
  • 日志框架中根据配置创建不同类型的Logger。
实现方式详解(简单工厂模式)

简单工厂模式是工厂模式家族中最基础的一种。它有一个专门的工厂类,负责根据传入的参数创建并返回不同产品的实例。

  • 代码示例:一个生产各种形状的工厂
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    package com.example;

    // 1. 定义接口
    interface Shape {
    void draw();
    }

    // 2. 实现类
    class Circle implements Shape {
    @Override
    public void draw() {
    System.out.println("画圆圈");
    }
    }

    class Rectangle implements Shape {
    @Override
    public void draw() {
    System.out.println("画矩形");
    }
    }

    class Triangle implements Shape {
    @Override
    public void draw() {
    System.out.println("画三角形");
    }
    }

    // 3. 工厂类
    class ShapeFactory {
    public static Shape getShape(String shapeType) {
    if (shapeType == null) {
    return null;
    }
    switch (
    shapeType
    ) {
    case "CIRCLE":
    return new Circle();
    case "RECTANGLE":
    return new Rectangle();
    case "TRIANGLE":
    return new Triangle();
    default:
    return null;
    }

    }
    }

    public class Main {
    public static void main(String[] args) {
    // 客户端代码通过工厂来获取对象,而不需要知道Circle或Rectangle的存在
    Shape shape1 = ShapeFactory.getShape("CIRCLE");
    shape1.draw();

    Shape shape2 = ShapeFactory.getShape("RECTANGLE");
    shape2.draw();

    Shape shape3 = ShapeFactory.getShape("TRIANGLE");
    shape3.draw();
    }
    }


总结:工厂模式完美体现了面向对象中的依赖倒置原则——高层模块(客户端Main)不应该依赖于低层模块(具体产品Circle),两者都应该依赖于抽象(接口Shape)。

3.9.4 代理模式: 控制对象的访问

核心思想与用途

代理模式是一种结构型模式,它的核心思想是:为一个对象提供一个代理(Proxy),以控制对这个对象的访问。

代理对象和真实对象通常会实现同一个接口。客户端代码只与代理对象交互,代理对象内部再决定何时以及如何调用真实对象。这种方式可以在不修改真实对象代码的前提下,为其增加额外的功能或控制逻辑。

应用场景

代理模式的应用极其广泛,是实现许多高级功能的基石。

  • 权限控制:代理在调用真实业务方法前,先检查当前用户是否有执行该操作的权限。
  • 懒加载:如果一个对象的创建非常耗时耗资源(如加载一张高清大图),可以先创建一个轻量级的代理对象。只有当客户端真正需要使用这个对象时,代理才去创建并加载真实的重量级对象。
  • 日志记录:代理可以在真实方法被调用前后,记录下方法的入参、返回值、执行时间等日志信息。
  • 事务管理:代理在方法开始前开启事务,在方法成功结束后提交事务,在方法抛出异常时回滚事务。
  • 远程代理(RPC):代理对象在本地,但它封装了与远程服务器通信的细节,使得客户端调用本地代理就像调用本地对象一样简单。
实现方式详解(静态代理)

静态代理是在编译时就已经确定了代理关系。我们需要手动为每个真实服务类创建一个代理类。

  • 代码示例:一个实现懒加载的图片查看器
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    package com.example;

    // 1. 定义共同的接口
    interface Image {
    void display();
    }

    // 2. 创建真实的、重量级的服务类
    class RealImage implements Image {
    private String fileName;

    public RealImage(String fileName) {
    this.fileName = fileName;
    loadFromDisk(); // 构造时就执行耗时操作
    }

    private void loadFromDisk() {
    System.out.println("正在从磁盘加载图片: " + fileName + " (这是一个耗时操作)...");
    }

    @Override
    public void display() {
    System.out.println("正在显示图片: " + fileName);
    }
    }

    // 3. 创建代理类,它也实现Image接口
    class ProxyImage implements Image {
    private RealImage realImage; // 持有一个真实对象的引用
    private String fileName;

    public ProxyImage(String fileName) {
    this.fileName = fileName;
    }

    @Override
    public void display() {
    // 实现懒加载:只有在display方法被调用时,才真正创建RealImage对象
    if (realImage == null) {
    realImage = new RealImage(fileName);
    }
    // 调用真实对象的方法
    realImage.display();
    }
    }

    public class Main {
    public static void main(String[] args) {
    // 创建代理对象,此时并不会加载图片
    Image image = new ProxyImage("风景照.jpg");
    System.out.println("代理对象已创建,但图片尚未加载。");

    System.out.println("---");

    // 第一次调用display,会触发真实对象的创建和加载
    image.display();

    System.out.println("---");

    // 第二次调用display,直接使用已创建的真实对象
    image.display();
    }
    }
[进阶] 动态代理简介

静态代理的缺点是,如果接口很多,或者接口发生变化,需要手动维护大量的代理类。为了解决这个问题,Java提供了动态代理机制。

  • JDK动态代理:基于接口实现。它可以在运行时,动态地为一个或多个接口生成一个代理对象,无需手动编写代理类。
  • CGLIB动态代理:基于继承实现。它可以为一个没有实现接口的类生成一个子类作为其代理。这两种技术是Spring AOP等框架实现“面向切面编程”的底层基石,它们与反射技术密切相关。我们将在后续章节中深入探讨。

3.10 [深度] 泛型 (Generics):编写类型安全、可复用的代码

泛型是Java 5引入的里程碑式特性,它将“类型”这个概念参数化,允许我们在编写代码时使用一个“类型占位符”,而在实际使用时再指定具体的类型。掌握泛型,是编写现代化、类型安全且高度可复用Java代码的基础,也是理解所有Java集合框架源码的前提。

3.10.1 泛型的诞生:没有泛型的“黑暗时代”

面试题引入

“什么是泛型?它解决了什么核心问题(为什么需要泛型)?”

场景回溯:一个不安全的ArrayList

在Java 5之前,所有的集合类都只能持有Object类型的引用。这带来了一系列严重的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.example;

import java.util.ArrayList;
import java.util.List;

public class Main {
public static void main(String[] args) {
// 在没有泛型的时代,List只能存储Object
List list = new ArrayList();

// 1. 类型不安全:可以向集合中添加任何类型的对象
list.add("这是一个字符串");
list.add(123); // 存入一个整数,编译器完全不反对
list.add(new Object());

System.out.println("原始列表内容: " + list);

// 2. 代码繁琐且有风险:取出元素时必须强制类型转换
try {
// 我们“期望”第一个元素是字符串
String firstItem = (String) list.get(0);
System.out.println("成功取出字符串: " + firstItem);

// 但当我们试图将第二个元素也当作字符串时...
System.out.println("尝试取出第二个元素...");
String secondItem = (String) list.get(1); // 这行代码在运行时会抛出异常!
System.out.println(secondItem);

} catch (ClassCastException e) {
// 3. 运行时错误:ClassCastException在程序运行时才暴露出来
System.err.println("错误:发生了ClassCastException!整数无法被转换为字符串。");
}
}
}
泛型的核心价值

泛型的出现,正是为了解决以上三大痛点,其核心价值在于:

  1. 类型安全:将类型的检查工作从运行时提前到了编译期List<String>就明确告诉编译器,这个列表只能存放String,任何试图存入其他类型的操作都会直接导致编译失败。
  2. 代码简洁:从泛型集合中获取元素时,不再需要手动进行强制类型转换,编译器会自动处理。
  3. 提升可读性与代码复用:代码的意图变得一目了然(List<User>显然比List更易懂),同时我们可以编写一次泛型类或方法,就能安全地服务于多种数据类型。

3.10.2 泛型的核心概念与用法

1. 泛型类

最常见的泛型应用,即在定义类时声明一个或多个类型参数。

  • 代码示例:一个可以容纳任何物品的Box<T>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
        package com.example;

    import lombok.Data;

    // T 是一个类型参数(占位符),在使用Box类时会被替换为具体类型
    @Data
    class Box<T> {
    private T item;
    }

    public class Main {
    public static void main(String[] args) {
    // 创建一个只能存放String的Box
    Box<String> stringBox = new Box<>();
    stringBox.setItem("Hello, Generics!");
    // stringBox.setItem(123); // 这行会导致编译错误,保证了类型安全
    System.out.println("stringBox里的物品: " + stringBox.getItem());

    // 创建一个只能存放Integer的Box
    Box<Integer> integerBox = new Box<>();
    integerBox.setItem(999);
    System.out.println("integerBox里的物品: " + integerBox.getItem());
    }
    }
2. 泛型接口

与泛型类类似,接口也可以定义类型参数。

  • 代码示例:一个通用的内容生成器Generator<T>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    package com.example;

    interface Generator<T> {
    T next();
    }

    // 实现泛型接口,生成随机数字
    class RandomNumberGenerator implements Generator<Integer> {
    @Override
    public Integer next() {
    return (int) (Math.random() * 100);
    }
    }

    public class Main {
    public static void main(String[] args) {
    Generator<Integer> numberGen = new RandomNumberGenerator();
    System.out.println("生成的随机数: " + numberGen.next());
    }
    }
3. 泛型方法

泛型方法允许方法的类型参数独立于其所在类的类型参数。

  • 代码示例:一个可以打印任何类型数组的工具方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    package com.example;

    class Utils {
    // <T>是该方法的类型参数声明,它在返回值类型之前
    public static <T> void printArray(T[] inputArray) {
    System.out.print("[");
    for (int i = 0; i < inputArray.length; i++) {
    System.out.print(inputArray[i]);
    if (i < inputArray.length - 1) {
    System.out.print(", ");
    }
    }
    System.out.println("]");
    }
    }

    public class Main {
    public static void main(String[] args) {
    Integer[] intArray = {1, 2, 3, 4, 5};
    String[] stringArray = {"A", "B", "C"};

    System.out.print("整型数组: ");
    Utils.printArray(intArray);

    System.out.print("字符串数组: ");
    Utils.printArray(stringArray);
    }
    }

3.10.3 [重点] 泛型通配符:处理未知的类型

问题根源:List<Dog> 不是 List<Animal>

在Java中,即使DogAnimal的子类,List<Dog>不是List<Animal>的子类。这是Java泛型设计中的一个核心原则,目的是为了保证类型安全。如果允许List<Animal> list = new ArrayList<Dog>();这样的赋值,那么我们就可以通过list.add(new Cat())向一个本应只存放Dog的列表中添加Cat,这会造成混乱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example;

import java.util.ArrayList;
import java.util.List;

// 定义Animal类作为父类
class Animal {
void makeSound() {
System.out.println("动物叫");
}
}

// 定义Dog类作为Animal的子类
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("汪汪");
}
}

// 定义Cat类作为Animal的子类
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("喵喵");
}
}

public class Main {
public static void main(String[] args) {
// 创建一个Dog列表
List<Dog> dogList = new ArrayList<>();
dogList.add(new Dog()); // 正确:添加Dog类型的对象

// 假设List<Dog>是List<Animal>的子类,尝试将dogList赋值给Animal列表
// List<Animal> animalList = dogList; // 这行代码会导致编译错误

// 但是如果我们注释掉上面的代码,直接使用List<Animal>,我们可以这样做:
List<Animal> animalList = new ArrayList<>();
animalList.add(new Dog()); // 正确:添加Dog类型的对象
animalList.add(new Cat()); // 正确:添加Cat类型的对象

// 如果List<Dog>是List<Animal>的子类,那么下面的代码在逻辑上是可以执行的
// 但是由于List<Dog>不是List<Animal>的子类,下面的代码会编译失败
// List<Animal> list = new ArrayList<Dog>();
// list.add(new Cat()); // 编译错误,不能添加Cat类型的对象到Dog列表中
}
}

为了解决这种需要处理“某一类”泛型集合的场景,Java引入了通配符

1. 上界通配符: ? extends T
  • 含义:“一个持有TT的某种未知子类的集合”。

  • PECS原则 (Producer Extends, Consumer Super)extends关键字在这里意味着集合是一个生产者(Producer),你只能从中读取(get)数据,而不能向其中**添加(add)**数据(null除外)。因为编译器无法确定?代表的是哪一个具体的子类型,所以不允许添加任何元素以防出错。

  • 场景示例:想象一下,我们正在开发一个电商系统,里面有各种不同类型的商品,比如Book(书)和Phone(手机)。它们虽然是不同的类,但都有一个共同的父类Product(商品),并且都包含一个getPrice()方法。

    现在的需求是:编写一个通用的工具方法,用来计算任何一个“商品列表”的总价,无论这个列表里装的是书、是手机,还是其他任何种类的商品。

如果我们不使用通配符,很自然地会写出这样的方法:

1
2
3
4
5
6
7
8
// 一个试图计算总价的“死板”方法
public static double calculateTotalPrice(List<Product> products) {
double sum = 0.0;
for (Product p : products) {
sum += p.getPrice();
}
return sum;
}

这个方法看起来没问题,但当 我们尝试使用它时,问题就暴露了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example;

import java.util.List;

// --- 类型层次结构 ---
class Product {
private double price;
public Product(double price) { this.price = price; }
public double getPrice() { return price; }
}
class Book extends Product {
public Book(double price) { super(price); }
}
class Phone extends Product {
public Phone(double price) { super(price); }
}

public class Main {
// 一个“死板”的方法,参数类型被写死为 List<Product>
public static double calculateTotalPrice(List<Product> products) {
// ...
return 0.0; // 仅为演示编译错误
}

public static void main(String[] args) {
List<Book> books = List.of(new Book(45.5), new Book(79.0));
List<Phone> phones = List.of(new Phone(2999.0));

// 尝试调用
// calculateTotalPrice(books); // 编译错误!
// calculateTotalPrice(phones); // 编译错误!
}
}

错误原因:正如我们之前所说,即使BookProduct的子类,List<Book>不是List<Product>的子类。因此,你无法将一个List<Book>类型的变量传递给一个需要List<Product>类型参数的方法。我们的calculateTotalPrice方法因为参数类型写得太死,导致它完全没有复用性。

这时,上界通配符 ? extends Product 就派上了用场。它的含义是:“一个持有ProductProduct的某种未知子类的列表”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.example;

import java.util.List;

// --- 类型层次结构 ---
class Product {
private double price;
public Product(double price) { this.price = price; }
public double getPrice() { return price; }
@Override public String toString() { return "Price: " + price; }
}
class Book extends Product {
public Book(double price) { super(price); }
}
class Phone extends Product {
public Phone(double price) { super(price); }
}

public class Main {
/**
* 一个通用的、可复用的总价计算方法。
* 它的参数 List<? extends Product> 可以接收任何元素是Product子类的List。
* 这正是一个典型的“生产者(Producer)”场景:我们不向这个list里添加任何东西,
* 只是从中读取(get)元素并消费它的数据(价格)。
*/
public static double calculateTotalPrice(List<? extends Product> products) {
double sum = 0.0;
System.out.println("开始计算列表总价: " + products);
for (Product p : products) { // 我们可以安全地将取出的元素视为Product类型
sum += p.getPrice();
}
return sum;
}

public static void main(String[] args) {
// 创建不同具体类型的列表
List<Book> books = List.of(new Book(45.5), new Book(79.0));
List<Phone> phones = List.of(new Phone(2999.0), new Phone(4999.0));
List<Product> mixed = List.of(new Book(100.0), new Phone(1000.0));

// 现在,同一个方法可以处理所有这些列表,代码得以复用!
double booksTotal = calculateTotalPrice(books);
System.out.println("书籍总价: " + booksTotal); // 124.5

System.out.println("---");

double phonesTotal = calculateTotalPrice(phones);
System.out.println("手机总价: " + phonesTotal); // 7998.0

System.out.println("---");

double mixedTotal = calculateTotalPrice(mixed);
System.out.println("混合商品总价: " + mixedTotal); // 1100.0
}
}
2. 下界通配符: ? super T
  • 含义:“一个持有TT的某种未知父类的集合”。

  • PECS原则super关键字在这里意味着集合是一个消费者(Consumer),你只能向其中添加(add) T类型及其子类型的对象。但当你从中**读取(get)**数据时,因为无法确定其具体类型,只能保证取出的东西是Object

下界通配符 ? super T 的应用场景虽然不如 ? extends T 那么频繁,但它在某些特定场景下同样至关重要,尤其是在设计需要“接收”或“消费”数据的灵活API时。

想象一下,我们正在为一个动物收容所系统编写工具方法。其中一个需求是:创建一个通用的方法,能够将一批新来的动物添加到各种不同的“动物名册”中。这些名册可能是专门的狗狗名册(List<Dog>),也可能是更宽泛的动物名册(List<Animal>),甚至是包含一切的生物名册(List<Creature>)

核心需求是:编写一个 addDogsToList 方法,它应该能接收任何**能装得下Dog**的列表。

如果我们不使用通配符,最直观的写法可能是这样的:

1
2
3
4
5
// 一个只能接收“狗狗名册”的“死板”方法
public static void addDogs(List<Dog> dogs) {
dogs.add(new Dog("旺财"));
dogs.add(new Dog("来福"));
}

这个方法本身没有错,但它的适用范围太窄了。当我们想把狗狗添加到更通用的动物名册时,问题就来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example;

import java.util.ArrayList;
import java.util.List;

// --- 类型层次结构 ---
class Animal {}
class Dog extends Animal {
String name;
public Dog(String name) { this.name = name; }
@Override public String toString() { return "Dog(" + name + ")"; }
}

public class Main {
// 一个“死板”的方法,参数类型被写死为 List<Dog>
public static void addDogs(List<Dog> dogs) {
dogs.add(new Dog("旺财"));
}

public static void main(String[] args) {
// 创建不同层级的“名册”
List<Dog> dogList = new ArrayList<>();
List<Animal> animalList = new ArrayList<>();

addDogs(dogList); // OK
// addDogs(animalList); // 编译错误!
}
}

错误原因List<Dog>List<Animal> 是两种完全不同的类型,前者不能赋值给后者。尽管从逻辑上讲,把一只Dog放进一个Animal列表是天经地义的,但Java的泛型机制不允许这种直接的赋值。我们的addDogs方法因为参数类型太具体,失去了通用性。

下界通配符 ? super Dog 在这里就派上了大用场。它的含义是:“一个持有DogDog的某种未知父类的列表”。

通过使用它,我们的方法就能接收所有“能装得下狗”的容器了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.example;

import java.util.ArrayList;
import java.util.List;

// --- 类型层次结构 ---
class Animal {}
class Dog extends Animal {
String name;
public Dog(String name) { this.name = name; }
@Override public String toString() { return "Dog(" + name + ")"; }
}
// 再加一个Dog的子类,用于演示
class Poodle extends Dog {
public Poodle(String name) { super(name); }
@Override public String toString() { return "Poodle(" + name + ")"; }
}

public class Main {
/**
* 一个通用的、可复用的添加狗狗的方法。
* 它的参数 List<? super Dog> 可以接收任何元素是Dog父类的List。
* 这正是一个典型的“消费者(Consumer)”场景:我们不关心list里原来装的是什么,
* 只是向这个list里添加(消费)Dog或其子类对象。
*/
public static void addDogsAndPoodles(List<? super Dog> dogContainer) {
dogContainer.add(new Dog("旺财"));
dogContainer.add(new Poodle("泰迪")); // 添加Dog的子类也是安全的

// Object o = dogContainer.get(0); // 读取是受限的,只能确保是Object
// Dog d = dogContainer.get(0); // 编译错误!无法保证取出的就是Dog
}

public static void main(String[] args) {
// 创建不同层级的“名册”
List<Dog> dogList = new ArrayList<>();
List<Animal> animalList = new ArrayList<>();
List<Object> objectList = new ArrayList<>();

// 现在,同一个方法可以向所有这些列表里添加狗狗!
addDogsAndPoodles(dogList);
System.out.println("狗狗名册: " + dogList);

System.out.println("---");

addDogsAndPoodles(animalList);
System.out.println("动物名册: " + animalList);

System.out.println("---");

addDogsAndPoodles(objectList);
System.out.println("对象名册: " + objectList);
}
}

3.10.4 [底层] 类型擦除

面试题引入

“Java的泛型是真泛型还是伪泛型?谈谈你对类型擦除的理解,它为什么被认为是‘变态’级的面试题?”

核心原理:编译期的“皇帝新衣”

Java的泛型是伪泛型。这意味着泛型提供的类型安全检查只存在于编译期。一旦代码被成功编译为.class字节码文件,其中绝大部分的泛型类型信息都会被“擦除”掉,替换为它们的上界类型。

  • 擦除规则
    1. 无界泛型(如 <T>:会被擦除为 Object 类型。一个List<String>在运行时看来就是一个List<Object>
    2. 有界泛型(如 <T extends Number>:会被擦除为其指定的上界 Number 类型。
[设计哲学] 为什么要擦除?

这是一个历史与工程权衡的决策,主要基于以下两点:

  1. 向后兼容 :这是最主要的原因。Java 5引入泛型时,需要确保海量的、没有使用泛型的老代码(如使用原始List)能够与新的、使用泛型的代码库协同工作,而不会产生兼容性灾难。类型擦除使得这一切成为可能。
  2. 避免“类爆炸”:如果Java采用真泛型(像C++的模板),那么List<String>List<Integer>List<Double>在运行时都会生成各自独立的.class文件。这会导致一个泛型类在被不同类型参数化时,产生大量重复的类文件,极大地增加JVM的内存消耗和类加载负担。类型擦除确保了无论有多少种泛型实例化,运行时永远只有一份List.class字节码。
类型擦除的运行时表现
  • getClass() 的证明
    在运行时,JVM无法区分不同泛型参数的同一个泛型类。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package com.example;

    import java.util.ArrayList;
    import java.util.List;

    public class Main {
    public static void main(String[] args) {
    List<String> stringList = new ArrayList<>();
    List<Integer> integerList = new ArrayList<>();

    // 尽管编译时类型不同,但运行时它们的Class对象是完全相同的
    System.out.println("list1.getClass(): " + stringList.getClass());
    System.out.println("list2.getClass(): " + integerList.getClass());
    System.out.println("两者是否相等: " + (stringList.getClass() == integerList.getClass())); // 输出: true
    }
    }
[进阶] 如何“反擦除”:获取真实的泛型类型

尽管JVM在运行时常规操作中会忽略泛型,但为了支持反射等高级操作,泛型的真实类型信息实际上被以**签名(Signature)**的形式保留在了字节码中。因此,在特定场景下,我们是可以通过反射API“窥探”到这些被擦除的信息的。

  • 场景:框架中获取父类的泛型类型

    问题:在Spring或MyBatis等框架中,我们常写public class UserDao extends BaseDao<User>。框架是如何知道UserDao操作的泛型实体就是User这个类的呢?
    答案:正是通过反射API getGenericSuperclass()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.example;

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;

// 定义一个带泛型的父类
abstract class BaseDao<T> {
public void save() {
// 获取当前运行时子类的真实泛型类型
Type genericSuperclass = this.getClass().getGenericSuperclass();
if (genericSuperclass instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) genericSuperclass;
Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
if (actualTypeArguments != null && actualTypeArguments.length > 0) {
Class<T> entityClass = (Class<T>) actualTypeArguments[0];
System.out.println("框架检测到,正在操作的实体类是: " + entityClass.getSimpleName());
}
}
}
}

// 定义一个实体类
class User {}

// 子类继承父类,并明确指定了泛型为User
class UserDao extends BaseDao<User> {}


public class Main {
public static void main(String[] args) {
UserDao userDao = new UserDao();
userDao.save(); // 框架通过反射,在运行时知道了泛型是User
}
}
类型擦除带来的限制
  1. 不能对泛型使用基本类型List<int>非法,必须使用包装类List<Integer>
  2. 不能在运行时检查泛型类型if (myList instanceof List<String>)非法。
  3. 不能创建泛型数组T[] array = new T[10];非法。
  4. 不能创建泛型实例T instance = new T();非法。