Java 从入门到精通(三):面向对象基础——类、对象、封装、继承与多态

本文是《Java 从入门到精通》系列第三篇,建议先掌握第二篇的变量、运算符与流程控制。全文围绕”JVM 到底怎么实现面向对象”展开,不只是罗列语法。文中所有代码均基于 JDK 17 语法,涉及 JDK 16+ 特性的地方会单独标注。示例代码可直接复制到任意 main 方法中运行验证。

一、从面向过程到面向对象

1.1 两种编程思维的根本差异

面向过程(Procedure Oriented)关注的是步骤:把事情拆成一、二、三几个动作,用函数把动作串起来,数据以参数形式在函数间流动。C 语言是典型代表。业务膨胀后有两个致命问题:数据与操作数据的逻辑分散各处,改一次数据结构所有函数都要改;函数靠参数耦合,调用链变长后排查问题如同走迷宫。

面向对象(Object Oriented)关注的是职责:先找出业务中有哪些”事物”,每个事物持有哪些数据、能提供哪些行为,再把数据与行为打包成整体。程序运行时,本质上就是一堆对象在互相发消息。最大收益是把变化关进笼子里——数据结构的修改被限制在类内部,对外契约(方法签名)不变,调用方无需感知。

用一个转账场景感受一下差别。面向过程写法里,账户数据是一组游离的变量,转账是一个函数;面向对象写法里,账户自己知道怎么加钱减钱,转账是账户之间的协作。

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
// ===== 面向过程:数据与行为分离 =====
class ProcedureStyle {
// 账户数据:只是一堆松散的变量,谁都能改
static String[] ids = {"A001", "A002"};
static long[] balances = {1000_00L, 500_00L}; // 单位:分

// 转账逻辑写在外部函数里,靠下标操作数据
static void transfer(int from, int to, long amount) {
if (balances[from] < amount) {
throw new IllegalStateException("余额不足");
}
balances[from] -= amount;
balances[to] += amount;
}

public static void main(String[] args) {
transfer(0, 1, 200_00L);
System.out.println(balances[0]); // 80000
}
}

// ===== 面向对象:数据与行为绑定 =====
class Account {
private final String id; // 数据私有,外部拿不到
private long balance; // 单位:分,用 long 避免浮点误差

public Account(String id, long balance) {
this.id = id;
this.balance = balance;
}

// 行为归属于 Account 自己:它才知道什么叫做"合法扣款"
public void transferTo(Account target, long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("金额必须为正");
}
if (this.balance < amount) {
throw new IllegalStateException("余额不足");
}
this.balance -= amount;
target.deposit(amount); // 对象之间发消息
}

private void deposit(long amount) {
this.balance += amount;
}

public long getBalance() {
return balance;
}
}

注意面向对象版本里的几个细节:金额用 long 存”分”而不是 double 存”元”,规避了浮点精度问题;balance 私有,外部只能通过 transferTo 修改,所有校验规则只有一处;deposit 是私有的,因为它只是内部协作的一部分,不应该成为对外契约。这就是封装带来的不变式(invariant)保护:只要走 Account 的方法,balance 永远不可能变成负数。

1.2 三大特性与四大原则

三大特性是语言机制层面的能力,四大原则是设计层面的经验总结,两者不在同一个维度。

特性 要解决的核心问题 语言机制
封装(Encapsulation) 隐藏实现细节,保护对象不变式 private/protected/public、getter/setter
继承(Inheritance) 复用已有实现,建立”是一种”关系 extends、方法重写、super
多态(Polymorphism) 同一消息、不同行为,消除分支判断 动态绑定、abstract、接口

四大原则(SOLID 中的前四条)在 Java 里的落地方式:

  1. 单一职责(SRP):一个类只应有一个引起它变化的原因。Order 管状态,OrderRepository 管持久化,OrderValidator 管校验。
  2. 开闭原则(OCP):对扩展开放、对修改关闭。新增支付方式应是新增 Payment 实现类,而不是在 switch 里加 case。
  3. 里氏替换(LSP):子类必须能替换父类出现的地方而不破坏程序。这直接约束重写——不能收紧前置条件,不能放宽后置条件(返回值、异常)。
  4. 依赖倒置(DIP):高层依赖抽象而非细节。OrderService 依赖 Payment 接口,而不是 AliPay 具体类。

1.3 用订单系统把概念串起来

假设业务是”下单 → 选择支付方式 → 支付 → 生成订单”。面向过程的写法是一个巨大的 createOrder 方法,里面用 if (payType == 1) 判支付宝、if (payType == 2) 判微信。面向对象写法则是:Order 持有商品明细与金额,Payment 是抽象,AliPay/WeChatPay 各自实现,结算时只调用 payment.pay(order),走哪条渠道由运行期对象决定。新增银联支付时 OrderService 一行都不用改——这就是开闭原则与多态的实际价值,第七节会完整实现。

不要为了面向对象而面向对象。 一个只有三个字段、没有任何行为的 DTO,写成 record 或 JavaBean 就够了,硬要给它加一堆”行为”反而制造噪音。判断标准很简单:这个类有没有需要保护的不变式?有,就封装;没有,就当数据结构用。

二、类与对象

2.1 类的五个组成部分

一个 Java 类由成员字段、方法、构造器、初始化块、内部类(以及嵌套接口、枚举、记录)构成。它们不是平铺的,JVM 在编译期会把它们归类到不同的结构中:字段和方法进 Class 文件的字段表与方法表,构造器被编译成名为 <init> 的实例初始化方法,静态初始化块被合并进名为 <clinit> 的类初始化方法。理解这两个特殊方法名,后面讲初始化顺序时会非常关键。

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
public class ClassMembers {
// 1. 实例字段:每个对象一份,随对象分配在堆上
private String name;
// 2. 静态字段:全类一份,JDK 8 起存放在堆中的 Class 对象末尾(即元空间之外的堆)
private static int count;

// 3. 实例初始化块:编译后被拷贝进每个 <init> 的开头(super() 之后)
{
System.out.println("实例初始化块执行");
name = "默认名称";
}

// 4. 静态初始化块:编译后合并进 <clinit>,类加载的初始化阶段执行且仅执行一次
static {
System.out.println("静态初始化块执行");
count = 0;
}

// 5. 构造器:编译成 <init> 方法
public ClassMembers() {
this.name = "构造器中赋值";
}

// 6. 方法:编译进方法表,非 private 实例方法参与虚方法分派
public void sayHello() {
System.out.println("Hello, " + name);
}

// 7. 静态成员内部类:不持有外部类引用
public static class StaticInner {
void show() {
// 可以访问外部类的静态成员
System.out.println("count = " + count);
}
}

// 8. 非静态内部类:编译后隐式持有外部类 this 引用(this$0),可能导致内存泄漏
public class Inner {
void show() {
System.out.println("name = " + name); // 直接访问外部实例字段
}
}
}

关于内部类有个易被忽视的内存点:非静态内部类编译后会生成 final ClassMembers this$0 字段,构造时必须传入外部类实例。在长生命周期容器里持有它,会让外部类对象无法回收。不需要访问外部实例时,一律声明为 static 内部类。

2.2 new 一个对象到底发生了什么

new 关键字在字节码层面并不是一条指令,而是一组指令的协作。下面这段源码与它对应的字节码,能清楚看到对象诞生的四个阶段。

1
2
3
4
5
6
7
8
9
10
11
12
public class ObjectCreation {
private int value = 10;

public ObjectCreation(int value) {
this.value = value;
}

public static void main(String[] args) {
ObjectCreation obj = new ObjectCreation(20);
System.out.println(obj.value);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// javap -c -p ObjectCreation.class 的关键输出(已加注释说明)
// main 方法:
// 0: new #7 // class ObjectCreation
// ① 在堆中分配内存,字段全部置零值(value = 0),返回引用压栈
// 3: dup // 复制栈顶引用,一份给 invokespecial 消费,一份留给后续使用
// 4: ldc #9 // 把 int 常量 20 压栈
// 6: invokespecial #11 // Method "<init>":(I)V
// ② 调用构造器完成初始化:先调父类 <init>,再执行实例块与字段赋值,再执行构造器体
// 9: astore_1 // ③ 把引用存入局部变量表槽位 1(obj)
// 10: getstatic #13 // ④ 后续通过引用访问字段

// <init> 方法:
// 0: aload_0
// 1: invokespecial #1 // Method java/lang/Object."<init>":()V —— 父类构造器必须先执行
// 4: aload_0
// 5: bipush 10 // 字段的显式初始值在此处赋值
// 7: putfield #7 // Field value:I
// 10: aload_0
// 11: iload_1 // 构造器参数
// 12: putfield #7 // Field value:I —— 构造器体覆盖初始值
// 15: return

从字节码可以得到三个硬核结论:第一,分配内存时字段是零值而非”未定义”,所以不存在 C++ 那种读取未初始化内存的风险,int 一定是 0、引用一定是 null;第二,父类构造器一定在子类字段赋值之前执行,这是 <init> 第一条指令决定的;第三,字段的显式初始化与实例初始化块被编译器按源码出现顺序插入 <init>,位置在父类构造器之后、构造器体之前。

2.3 对象在内存中的布局

HotSpot VM 中一个普通对象(非数组)在堆里的布局分为三段,可以用 JOL(Java Object Layout)工具实测。

1
2
3
4
5
6
7
8
9
10
11
|--------------------------------------------------------------------|
| 对象头 Object Header(64 位 VM,开启指针压缩时共 12 字节) |
| ├── Mark Word(8 字节):哈希码、GC 分代年龄、锁状态标志 |
| └── Klass Pointer(4 字节):指向方法区中的类元数据 InstanceKlass |
|--------------------------------------------------------------------|
| 实例数据 Instance Data:父类字段在前,子类字段在后 |
| 相同宽度的字段被分配在一起,long/double → int/float → short/char |
| → byte/boolean → 引用类型,以满足对齐要求 |
|--------------------------------------------------------------------|
| 对齐填充 Padding:补齐到 8 字节的整数倍 |
|--------------------------------------------------------------------|

注意两点:父类字段排在子类字段之前,保证用父类引用还是子类引用访问继承来的字段,偏移量都一致;静态字段不在这里,它属于类。另外,锁升级时 Mark Word 会被替换成指向栈帧锁记录或重量级锁的指针,这也是调用过 hashCode() 的对象无法进入偏向锁的原因——位置被哈希码占用了。

2.4 this 关键字与 this() 调用

this 本质上是 <init> 和每个实例方法的第 0 号局部变量(aload_0),指向当前对象引用。它有两个不可替代的用途:一是区分同名的局部变量与字段,二是在构造器中调用本类的其他构造器。

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
public class ThisDemo {
private final String name;
private int age;
private String remark;

public ThisDemo(String name) {
// this() 必须是构造器体的第一条语句,否则编译错误
this(name, 0); // 复用下面的全参构造器
}

public ThisDemo(String name, int age) {
this.name = name; // 左侧 this.name 指字段,右侧 name 指参数
this.age = age;
}

// 链式 setter:返回 this 让调用方可以连续调用
public ThisDemo remark(String remark) {
this.remark = remark;
return this;
}

// 把自身作为实参传递(事件回调注册场景常见)
public void register() {
EventBus.register(this);
}

@Override
public String toString() {
return "ThisDemo{name='" + name + "', age=" + age + ", remark='" + remark + "'}";
}

public static void main(String[] args) {
ThisDemo d = new ThisDemo("张三").remark("VIP 客户");
System.out.println(d);
}
}

// 仅为让示例代码可编译而存在的桩类
class EventBus {
static void register(Object listener) {
System.out.println("注册监听器:" + listener.getClass().getSimpleName());
}
}

this() 的限制值得记住:只能是构造器第一条语句;一个构造器里只能调用一次;不能与 super() 同时出现(因为两者都要求占据第一条语句的位置,而 this() 调用的目标构造器最终必然会去调 super());不能在普通方法中使用 this()。

2.5 static 成员与实例成员

对比维度 实例成员 静态成员(static)
归属 每个对象一份 每个类一份
内存位置 堆中对象内部 堆中 Class 对象末尾(JDK 8+,元空间存的是类元信息与静态变量的引用描述)
生命周期 随对象 GC 而消亡 随类卸载而消亡,通常等同于进程生命周期
调用方式 必须通过对象引用 推荐 类名.成员
能否访问另一方 实例方法可访问静态成员 静态方法不能访问实例成员,也没有 this
绑定方式 invokevirtual 动态分派 invokestatic,编译期确定,不参与多态

静态方法之所以不参与多态,是因为 invokestatic 指令的参数在编译期就被解析为直接引用,运行期不会查方法表。反过来说,用对象引用调用静态方法是一种代码坏味道,编译器会给出警告,而且实际调用的仍是编译时类型上的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class StaticVsInstance {
static class Parent {
static String tag() { return "Parent"; } // 静态方法:隐藏,不是重写
String name() { return "Parent"; } // 实例方法:重写
}

static class Child extends Parent {
static String tag() { return "Child"; }
@Override
String name() { return "Child"; }
}

public static void main(String[] args) {
Parent p = new Child(); // 向上转型
System.out.println(p.name()); // Child —— 实例方法走虚分派
System.out.println(p.tag()); // Parent —— 静态方法看编译期类型!
System.out.println(((Child) p).tag()); // Child
}
}

上面这段代码是面试高频陷阱:同一个 p 引用,实例方法输出子类结果,静态方法输出父类结果。原因就在指令不同——p.name() 编译成 invokevirtual,运行期按对象实际类型查表;p.tag() 编译成 invokestatic,符号引用在类加载的解析阶段就已固化。

2.6 引用与对象:== 与 equals

Account a = new Account(...) 这条语句产生两个东西:引用变量 a(在栈帧的局部变量表中,64 位指针压缩后占 4 字节)和堆中的对象本体。变量之间赋值传递的是引用值,不是对象拷贝。因此 == 比较的是两个引用是否指向同一块内存,而 equals 的语义由类自己定义(默认实现就是 ==)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ReferenceCompare {
public static void main(String[] args) {
String s1 = new String("java"); // 强制在堆里新建对象
String s2 = new String("java"); // 另一个堆对象
String s3 = "java"; // 指向字符串常量池中的同一对象
String s4 = "java";

System.out.println(s1 == s2); // false:两个不同的堆对象
System.out.println(s1.equals(s2)); // true:String 重写了 equals,逐字符比较内容
System.out.println(s3 == s4); // true:常量池复用
System.out.println(s1 == s3); // false:堆对象 vs 常量池对象
System.out.println(s1.intern() == s3);// true:intern() 返回池中对象

Integer i1 = 127, i2 = 127; // 落在 IntegerCache [-128,127] 内
Integer i3 = 128, i4 = 128; // 超出缓存范围,各自 new
System.out.println(i1 == i2); // true:缓存命中,同一对象
System.out.println(i3 == i4); // false:不同对象
System.out.println(i3.equals(i4)); // true:包装类重写了 equals 比较数值
}
}

结论非常明确:包装类与引用类型的等值判断一律用 equals,== 只用于基本类型比较或判断是否为 null / 是否同一引用。Integer 缓存的存在让 == 在小数值上”看起来对”,这是最危险的假象——它在 127 通过、在 128 失败。

三、封装

3.1 访问修饰符的可见范围

修饰符 同类 同包 不同包子类 不同包非子类 典型用途
private ✅ ❌ ❌ ❌ 字段、内部实现方法
默认(包私有) ✅ ✅ ❌ ❌ 包内协作组件
protected ✅ ✅ ✅ ❌ 留给子类扩展的钩子方法
public ✅ ✅ ✅ ✅ 对外 API、常量

关于 protected 有一个极易踩的坑:不同包的子类只能通过子类自身的引用访问父类的 protected 成员,不能通过父类类型的引用访问。也就是说 protected 的”子类可见”是有限定的,它保护的是”子类继承来的那一份”,而不是”任意对象的成员”。这一点在字节码层面也有体现:访问 protected 成员时使用 invokevirtual 会触发 checkcast 到当前类,确保接收者类型是当前类或其子类。

3.2 getter/setter 的真实意义

很多人把 getter/setter 理解成”多写两行代码的形式主义”,其实它有三点价值:一是在赋值入口插入校验,让非法状态无法被构造;二是把字段访问变成方法调用,将来字段改名、改成计算属性、加缓存,调用方无需改动;三是给框架提供元数据,Spring 的属性绑定、MyBatis 的结果映射、Jackson 的序列化都依赖这套命名约定。

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
public class EncapsulationDemo {
private String phone;
private int age;

public String getPhone() {
// 对外只暴露脱敏后的手机号,原始值不外泄
return phone == null ? null : phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}

public void setPhone(String phone) {
// 校验放在唯一入口,任何调用方都无法绕过
if (phone != null && !phone.matches("^1[3-9]\\d{9}$")) {
throw new IllegalArgumentException("手机号格式非法:" + phone);
}
this.phone = phone;
}

public int getAge() {
return age;
}

public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("年龄超出合理范围:" + age);
}
this.age = age;
}

public static void main(String[] args) {
EncapsulationDemo u = new EncapsulationDemo();
u.setPhone("13812345678");
u.setAge(28);
System.out.println(u.getPhone()); // 138****5678
u.setAge(-1); // 抛 IllegalArgumentException
}
}

3.3 不可变对象与防御性拷贝

不可变对象(Immutable Object)是并发编程里最省心的设计:创建后状态永不改变,天然线程安全,可安全共享、安全作 HashMap 的 key。设计不可变类有五条:类声明 final(或所有方法 final)、字段全部 private final、不提供 setter、构造器做防御性拷贝、getter 返回可变字段时也做拷贝。String、包装类、LocalDateTime、record 都是典型不可变类型。

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
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class ImmutableOrder { // ① 类 final,禁止继承破坏
private final String orderNo; // ② 全部 private final
private final List<String> items; // 可变字段是最大风险点

public ImmutableOrder(String orderNo, List<String> items) {
this.orderNo = orderNo;
// ③ 构造器防御性拷贝:外部后续修改 items 不影响本对象
this.items = new ArrayList<>(items);
}

public String getOrderNo() {
return orderNo;
}

public List<String> getItems() {
// ④ getter 也做拷贝,或者返回不可修改视图(推荐后者,零拷贝)
return Collections.unmodifiableList(items);
}

// ⑤ "修改"操作返回新对象,而非修改自身(函数式风格)
public ImmutableOrder withItem(String item) {
List<String> copy = new ArrayList<>(items);
copy.add(item);
return new ImmutableOrder(orderNo, copy);
}

@Override
public String toString() {
return "ImmutableOrder{orderNo='" + orderNo + "', items=" + items + "}";
}

public static void main(String[] args) {
List<String> src = new ArrayList<>();
src.add("Java 编程思想");
ImmutableOrder o1 = new ImmutableOrder("NO001", src);
src.add("深入理解 JVM"); // 外部修改原集合
System.out.println(o1); // items 仍只有一项,防御成功
ImmutableOrder o2 = o1.withItem("Effective Java");
System.out.println(o2); // 新对象,o1 不受影响
}
}

注意 final 字段的语义边界:final 只保证引用不变,不保证引用指向的对象内容不变。上面 items 是 final 的,但如果不做防御性拷贝,外部拿到同一个 ArrayList 仍然可以 add。这就是为什么”不可变”必须靠拷贝来实现,光靠 final 不够。另外,JDK 9 之后推荐用 List.copyOf() 与 List.of() 创建真正的不可变集合。

3.4 JavaBeans 规范与包访问权限

JavaBeans 规范是一套约定:公共无参构造器、私有字段、getXxx/isXxx/setXxx 命名、实现 Serializable。它没有语言级强制力,但整个 Java 生态(Spring、MyBatis、Jackson)都基于它做反射,遵守约定就能免费获得框架支持。

包(package)是命名空间与访问控制单元,也是封装在”模块”层面的延伸。默认(不写修饰符)的可见范围就是包级私有。实践中常把实现细节放在同一包下声明为包私有,只 public 出接口,对外 API 面就很干净。包名须与目录结构严格一致,采用域名反写(如 com.example.order.service),否则会报”找不到符号”或运行时 NoClassDefFoundError。

四、继承

4.1 extends 的语义与 super 的三种用法

extends 建立的是 is-a 关系,子类拥有父类的非私有成员,并可以改写行为。判断该不该用继承的黄金法则是里氏替换:如果”子类是一个父类”这句话在业务上说不通(比如”正方形是一个长方形”在可变长宽的场景下就说不通),就不要用继承,改用组合。

super 有三个完全不同的用法,容易混淆:

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
class Animal {
protected String name;

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

public void speak() {
System.out.println(name + " 发出声音");
}
}

class Dog extends Animal {
private String breed;

public Dog(String name, String breed) {
super(name); // ① super(...):调用父类构造器,必须是第一条语句
this.breed = breed;
}

@Override
public void speak() {
super.speak(); // ② super.method():调用被重写的父类方法(编译为 invokespecial)
System.out.println(name + ":汪汪!品种 " + breed);
}

public void printName() {
System.out.println(super.name); // ③ super.field:访问被隐藏的父类字段(很少用)
}
}

public class SuperDemo {
public static void main(String[] args) {
Dog dog = new Dog("小黑", "拉布拉多");
dog.speak(); // 先输出父类版本,再输出子类版本
dog.printName(); // 小黑
}
}

4.2 方法重写的四条硬性规则

重写(Override)不是”写一遍同名方法”那么简单,编译器与 JVM 会做严格校验,违反任何一条都会编译失败或运行时抛 IncompatibleClassChangeError。

  1. 签名必须相同:方法名与参数列表(类型、顺序、数量)完全一致。参数类型不相同就是重载,不是重写。
  2. 返回值协变:JDK 5 起,返回值可以是父类方法返回类型的子类型(covariant return type)。
  3. 访问权限不能变窄:父类 public,子类不能改成 protected 或 private;变宽则允许。
  4. 异常不能变宽:子类方法不能抛出比父类更多的受检异常(checked exception),可以不抛、可以抛子类异常,但不能抛新的、更宽的受检异常。非受检异常不受此约束。
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
import java.io.IOException;
import java.sql.SQLException;

class Repository {
// 返回值是 Object,抛出 IOException
public Object find(long id) throws IOException {
return "entity-" + id;
}

void internalHook() {
System.out.println("父类包私有钩子");
}
}

class UserRepository extends Repository {
// ① 返回值协变:Object → String(String 是 Object 的子类),合法
// ② 异常收窄:IOException → SQLException(SQLException 是 Exception 子类,
// 但此处父类抛的是 IOException,SQLException 并非其子类,故改为抛 IOException 子类演示)
@Override
public String find(long id) throws java.io.FileNotFoundException {
// FileNotFoundException 是 IOException 的子类:异常变窄,合法
return "user-" + id;
}

// ③ 访问权限变宽:包私有 → public,合法
@Override
public void internalHook() {
System.out.println("子类增强了钩子,权限变宽");
}

// 下面两种写法如果取消注释会编译报错:
// @Override protected Object find2() throws SQLException { } // 异常变宽(若父类为 IOException)
// @Override private void internalHook2() { } // 权限变窄
}

public class OverrideRuleDemo {
public static void main(String[] args) {
Repository repo = new UserRepository();
try {
String s = (String) repo.find(1L); // 编译类型是 Object,需强转
System.out.println(s);
} catch (Exception e) {
e.printStackTrace();
}
}
}

强烈建议永远加上 @Override 注解。它不是语法要求,但编译器会据此校验四条规则,一旦签名写错(比如把 equals(Object) 写成 equals(MyClass))会立刻报错,避免”以为重写了其实在重载”的经典 bug。

4.3 重载与重写的区别

对比项 重载(Overload) 重写(Override)
发生范围 同一个类内(或父子类之间) 父子类之间
方法签名 必须不同(参数类型/个数/顺序) 必须相同
返回值 可以不同,仅靠返回值不同不构成重载 必须相同或协变
访问权限 无关 不能变窄
异常 无关 受检异常不能变宽
绑定时机 编译期静态绑定(编译期根据实参类型挑版本) 运行期动态绑定(按对象实际类型分派)
典型指令 invokevirtual(但符号引用已固定) invokevirtual(查方法表)

重载的”坑”在于它是编译期按静态类型挑选的,会自动寻找”最具体”的匹配,必要时做装箱、向上转型、可变参数展开。看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class OverloadTrap {
static void print(Integer i) { System.out.println("Integer"); }
static void print(long l) { System.out.println("long"); }
static void print(Object o) { System.out.println("Object"); }
static void print(int... arr){ System.out.println("varargs"); }

public static void main(String[] args) {
print(10); // 输出 long:不装箱优先,int 可以加宽转换为 long
// print(Integer) 需要装箱,print(long) 只需加宽,加宽优先级高于装箱
print(10L); // long:精确匹配
print("abc"); // Object:字符串只能匹配 Object
print(1, 2); // varargs:没有其它候选
}
}

选择顺序大致是:精确匹配 → 基本类型加宽 → 装箱/拆箱 → 可变参数。混用重载与继承时,如果子类新增了一个更具体的重载版本,父类引用调用时仍然只会看到父类的版本,这也是”重载不跨类分派”的表现。

4.4 字段隐藏(Field Hiding)的坑

方法会被重写,但字段不会——子类声明一个与父类同名的字段,只是在子类对象里多了一份存储,父类字段依然存在,只是被”遮蔽”了。访问哪个字段由引用的编译期类型决定,因为字段访问指令 getfield/putfield 的符号引用在编译期就绑定到了具体的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Base {
String name = "Base";
int value = 1;

String getName() { return name; } // 方法走多态
}

class Derived extends Base {
String name = "Derived"; // 字段隐藏:Base.name 依然存在
int value = 2;
}

public class FieldHidingDemo {
public static void main(String[] args) {
Base b = new Derived();
System.out.println(b.name); // Base —— 字段看编译期类型
System.out.println(b.getName()); // Base —— 方法在 Derived 中没重写,执行的仍是 Base.getName(),
// 而 Base.getName() 里的 name 解析到 Base.name
Derived d = (Derived) b;
System.out.println(d.name); // Derived
System.out.println(((Base) d).value); // 1
System.out.println(d.value); // 2
}
}

这个例子揭示了一个非常隐蔽的 bug 模式:父类方法访问父类字段,子类方法访问子类字段,同一个逻辑概念(name)在内存里存了两份,值还可能不一致。规避办法很简单——字段一律 private + getter/setter,绝不使用包级或 protected 的可变字段,从根源上消灭字段隐藏。

4.5 完整的构造器初始化顺序

这是本系列最重要的考点之一。一次 new Child() 触发的完整顺序如下,每一环都由 JVM 规范强制:

  1. 父类静态:父类静态字段赋值与静态初始化块(按源码顺序),合并进父类 <clinit>
  2. 子类静态:子类静态字段赋值与静态初始化块,合并进子类 <clinit>
  3. 回到子类 <init>:第一条指令是 super(...),进入父类 <init>
  4. 父类实例:父类实例字段赋值与实例初始化块(按源码顺序)
  5. 父类构造器体:父类构造器的方法体
  6. 子类实例:子类实例字段赋值与实例初始化块(按源码顺序)
  7. 子类构造器体:子类构造器的方法体

静态部分只在类首次主动使用(new、访问静态成员、反射、Class.forName、启动类、子类初始化触发父类初始化等)时执行一次,且父类静态一定早于子类静态。

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
class InitBase {
static { System.out.println("1. 父类静态块"); }
{ System.out.println("4. 父类实例块"); }
private String f = fieldInit("5. 父类字段赋值");

static String fieldInit(String msg) {
System.out.println(msg);
return msg;
}

public InitBase() {
System.out.println("6. 父类构造器体");
}
}

class InitChild extends InitBase {
static { System.out.println("2. 子类静态块"); }
{ System.out.println("7. 子类实例块"); }
private String f2 = InitBase.fieldInit("8. 子类字段赋值");

public InitChild() {
System.out.println("9. 子类构造器体");
}
}

public class InitOrderDemo {
public static void main(String[] args) {
System.out.println("--- 第一次 new ---");
new InitChild();
System.out.println("--- 第二次 new ---");
new InitChild(); // 静态部分不再执行
}
}

运行结果严格按 1→2→4→5→6→7→8→9 打印,第二次 new 只打印 4→5→6→7→8→9。编号特意跳过 3:<clinit> 与 <init> 是两个独立方法,静态与实例之间不存在”第 3 步”。另外,若父类构造器调用了被子类重写的方法,第 6 步就会执行子类代码,而此时子类字段还是零值——这就是 5.5 节的陷阱。

五、多态

5.1 编译期类型与运行期类型

每个引用都有两个类型:编译期类型(静态类型)由声明决定,Base b 的编译期类型就是 Base;运行期类型(实际类型)由指向的对象决定,new Derived() 的运行期类型是 Derived。编译器只按编译期类型检查”能否调用这个方法”,JVM 执行时按运行期类型决定”执行哪个版本”。多态的全部魔力与陷阱,都源于这两个类型不一致。

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
class Shape {
public double area() { return 0; }
}

class Circle extends Shape {
private final double r;
Circle(double r) { this.r = r; }
@Override public double area() { return Math.PI * r * r; }
}

class Rect extends Shape {
private final double w, h;
Rect(double w, double h) { this.w = w; this.h = h; }
@Override public double area() { return w * h; }
}

public class PolymorphismDemo {
// 这个方法只依赖 Shape 抽象,未来新增 Triangle 无需修改此处(开闭原则)
static double totalArea(Shape[] shapes) {
double sum = 0;
for (Shape s : shapes) {
sum += s.area(); // 编译期只知道是 Shape.area,运行期才确定具体版本
}
return sum;
}

public static void main(String[] args) {
Shape[] arr = { new Circle(1), new Rect(2, 3), new Circle(2) };
System.out.printf("总面积 = %.4f%n", totalArea(arr));
}
}

5.2 JVM 如何实现动态绑定

多态不是语法糖,而是 JVM 指令集与运行时数据结构共同支撑的机制。相关指令如下:

指令 用途 分派方式
invokestatic 静态方法 编译期静态绑定
invokespecial 私有方法、构造器 <init>、父类方法 super.xxx()、final 实例方法(历史约定) 编译期静态绑定
invokevirtual 非私有实例方法 运行期按实际类型分派
invokeinterface 接口方法 运行期查接口方法表
invokedynamic Lambda、方法句柄、字符串拼接 运行期由引导方法决定

关键点在 invokevirtual 的执行过程:指令参数是常量池中的一个符号引用(CONSTANT_Methodref,形如 com.xxx.Shape.area:()D)。类加载的解析阶段会把它解析为直接引用,但这个直接引用不是目标方法地址,而是方法在虚方法表(vtable)中的索引。因为父子类中同名同签名的方法在各自 vtable 里的索引相同,执行时只需三步:

1
2
3
4
5
invokevirtual 的执行(简化版):
1. 从操作数栈顶弹出 objectref,做 null 检查(null 则抛 NullPointerException)
2. 通过 objectref 的对象头 Klass Pointer 找到方法区中的 InstanceKlass
3. 在 InstanceKlass 的方法表(vtable)中,按编译期确定的固定索引 vtable_index 取出方法入口地址
4. 执行该方法

这个”固定索引”设计就是动态绑定的精髓:它把”查哪个方法”从字符串匹配变成一次数组下标访问,多态调用开销极低。子类重写方法时,JVM 把 vtable 对应槽位替换成子类版本——继承的本质就是复制父类 vtable 再覆盖槽位。接口方法则用 itable,因为类可实现多个接口、无法固定索引,需按接口加签名查找,这也是 invokeinterface 略慢于 invokevirtual 的原因。

5.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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.util.ArrayList;
import java.util.List;

// 形式一:继承 + 方法重写
abstract class Bird {
abstract void fly();
}

class Sparrow extends Bird {
@Override void fly() { System.out.println("麻雀低空飞行"); }
}

// 形式二:接口实现
interface Flyable {
void fly();
}

class Eagle implements Flyable {
@Override public void fly() { System.out.println("鹰在高空盘旋"); }
}

// 形式三:抽象类(既有抽象方法约束,又有可复用实现)
abstract class AbstractBird implements Flyable {
protected String name;
AbstractBird(String name) { this.name = name; }
// 模板方法:固定流程,把变化点留给子类
public final void dailyRoutine() {
System.out.print(name + " 的日常:");
fly();
}
}

class Swallow extends AbstractBird {
Swallow() { super("燕子"); }
@Override public void fly() { System.out.println("燕子迁徙飞行"); }
}

public class PolymorphismForms {
public static void main(String[] args) {
List<Flyable> list = new ArrayList<>();
list.add(new Eagle());
list.add(new Swallow());
// 同一条 fly() 消息,不同对象有不同行为
for (Flyable f : list) {
f.fly();
}
new Swallow().dailyRoutine();
}
}

三者的选择原则:需要复用代码且是 is-a 关系用继承(优先抽象父类);只定义能力契约用接口(Java 8 后接口可以有 default 方法,也能带少量复用);既要约束子类又要提供骨架流程用抽象类 + 模板方法模式。

5.4 向下转型与 instanceof 模式匹配

向上转型(子类引用赋给父类变量)永远安全、自动发生。向下转型(父类引用转回子类)有风险,必须先用 instanceof 判断。JDK 16 起支持模式匹配,把判断与转型合并成一步,既简洁又避免重复写转型。

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
public class CastDemo {
static void handle(Object obj) {
// JDK 16+ 模式匹配:判断成功后 obj 在分支内自动视为 String 类型
if (obj instanceof String s && s.length() > 3) {
System.out.println("长度大于 3 的字符串:" + s.toUpperCase());
} else if (obj instanceof Integer i) {
System.out.println("整数:" + (i * 2));
} else {
System.out.println("其它类型:" + obj);
}
}

// JDK 21+ 还支持 switch 模式匹配(作为扩展了解)
static String describe(Object o) {
return switch (o) {
case Integer i when i > 100 -> "大整数 " + i;
case Integer i -> "小整数 " + i;
case String s -> "字符串 " + s;
case null -> "空值";
default -> "未知类型";
};
}

public static void main(String[] args) {
handle("hello");
handle(42);
Object obj = "abc";
// 旧写法(JDK 8 兼容)
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
}
}

注意 instanceof 的一个边界行为:null instanceof X 永远返回 false,不需要先判空。另外,向下转型前不做判断会导致 ClassCastException,这是运行期异常,编译器拦不住。

5.5 多态的三个经典陷阱

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
class TrapBase {
String tag = "base"; // 陷阱一:字段不参与多态
static String who() { return "static-base"; } // 陷阱二:静态方法不参与多态

public TrapBase() {
System.out.println("构造器中调用可重写方法 →");
showTag(); // 陷阱三:构造器中调用可重写方法
}

void showTag() {
System.out.println(" Base.showTag, tag=" + tag);
}
}

class TrapChild extends TrapBase {
String tag = "child";
private final String state = initState(); // 实例字段初始化

private String initState() {
System.out.println(" 子类字段初始化执行");
return "ready";
}

static String who() { return "static-child"; }

@Override
void showTag() {
// 父类构造期间执行到这里,子类字段 state 尚未赋值 → null
System.out.println(" Child.showTag, tag=" + tag + ", state=" + state);
}
}

public class PolymorphismTrap {
public static void main(String[] args) {
TrapBase b = new TrapChild();
System.out.println("字段访问 tag = " + b.tag); // base:字段看编译期类型
System.out.println("静态方法 who = " + b.who()); // static-base:静态看编译期类型
}
}

三个陷阱的成因与规避:

  1. 字段不参与多态:getfield 按编译期类型绑定符号引用,永远访问声明类型上的字段。规避:字段私有化,通过方法访问。
  2. 静态方法不参与多态:invokestatic 在解析阶段就固化目标。规避:用类名而非对象引用调用静态方法。
  3. 构造器中调用可重写方法:父类构造器执行时子类字段还是零值,子类重写方法若读取自身字段就拿到 null/0,产生难定位的 NPE。这不是理论问题——ArrayList 早期版本与 Spring 的 InitializingBean 实现都踩过。规避:构造器中只调用 private 或 final 方法,需要回调就用 init() 显式调用。

Effective Java 建议:要么为继承而设计并写好文档,要么禁止继承。允许继承的类,构造器绝不能调用可被重写的方法;同时应当让所有 public/protected 方法遵守自己在文档里声明的行为契约,否则子类重写后就会违反里氏替换。

六、Object 类的契约

6.1 equals 与 hashCode 的约定

Object 是所有类的根,它为所有对象定义了契约。equals 必须满足自反性、对称性、传递性、一致性,且 x.equals(null) 返回 false。hashCode 的契约只有一句话却影响巨大:两个对象 equals 相等,则 hashCode 必须相等。反过来不成立(哈希冲突允许)。

为什么必须一起重写?看 HashMap 的查找流程就明白了:

1
2
3
4
5
HashMap.get(key) 的简化流程:
1. 计算 key.hashCode(),经扰动函数与 (n-1) & hash 得到桶下标
2. 定位到桶,遍历桶内节点(链表或红黑树)
3. 对每个节点先比较 hash 值,再调用 equals 判断是否为同一个 key
—— 如果 hashCode 不同,第 1 步就落到另一个桶里,equals 永远不会被调用

也就是说:hashCode 决定了对象”落在哪个抽屉”,equals 决定了”抽屉里哪个是它”。只重写 equals 不重写 hashCode,两个逻辑相等的对象会落到不同抽屉,HashSet 能同时存进两个”相等”的元素,HashMap.get() 会返回 null——这是最常见的集合类 bug 之一。

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
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

class BadKey { // 只重写 equals,没重写 hashCode
private final int id;
BadKey(int id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof BadKey && ((BadKey) o).id == this.id;
}
}

class GoodKey {
private final int id;
GoodKey(int id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof GoodKey && ((GoodKey) o).id == this.id;
}
@Override public int hashCode() {
return Objects.hash(id); // 用与 equals 相同的字段计算
}
}

public class HashCodeDemo {
public static void main(String[] args) {
Set<BadKey> bad = new HashSet<>();
bad.add(new BadKey(1));
bad.add(new BadKey(1));
System.out.println("BadKey 集合大小 = " + bad.size()); // 2!逻辑相等却都存进去了

Set<GoodKey> good = new HashSet<>();
good.add(new GoodKey(1));
good.add(new GoodKey(1));
System.out.println("GoodKey 集合大小 = " + good.size()); // 1,符合预期
}
}

6.2 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
import java.util.Objects;

public final class User {
private final String email; // 参与相等判断的字段
private final String nickname;// 参与相等判断
private int loginCount; // 不参与:可变字段不应进入 equals/hashCode

public User(String email, String nickname) {
this.email = Objects.requireNonNull(email, "email 不能为空");
this.nickname = nickname;
}

@Override
public boolean equals(Object o) {
// ① 自反性优化:同一引用直接返回 true
if (this == o) return true;
// ② 类型检查用 instanceof(final 类)或 getClass()(需要严格类型相等时)
if (!(o instanceof User)) return false; // 同时覆盖了 null 判断
User other = (User) o;
// ③ 逐一比较关键字段,引用类型用 Objects.equals 防 NPE,浮点用 compare,数组用 Arrays.equals
return Objects.equals(this.email, other.email)
&& Objects.equals(this.nickname, other.nickname);
}

@Override
public int hashCode() {
// ④ 使用与 equals 完全相同的字段,推荐 Objects.hash(内部等价于 Arrays.hashCode)
return Objects.hash(email, nickname);
}

@Override
public String toString() {
// ⑤ toString 应包含所有关键字段,便于日志排查
return "User{email='" + email + "', nickname='" + nickname + "', loginCount=" + loginCount + "}";
}

public void incrementLogin() { loginCount++; }
}

6.3 toString、getClass 与 instanceof

对比 getClass() instanceof
判断依据 对象的运行期实际类型 是否为某类型或其子类
是否考虑继承 不考虑,Child 与 Parent 不相等 考虑,Child instanceof Parent 为 true
null 处理 需要非 null 引用,否则 NPE 永远返回 false
典型用途 严格类型相等(如 equals 中防止子类混入) 多态分支、向下转型前的守卫
相关方法 getClass() 可配合 isInstance() Class.isInstance(obj) 是它的反射版本

关于 getClass() 的一个硬核细节:obj.getClass() 返回运行期类对象,而 Child.class 是编译期常量。用 getClass() != o.getClass() 写 equals 能严格保证对称性——Parent.equals(Child) 与 Child.equals(Parent) 结果永远一致;而用 instanceof 时,若子类新增字段并参与比较,就可能出现 p.equals(c) 为 true 但 c.equals(p) 为 false 的对称性破坏。所以非 final 类在 equals 中用 instanceof 要谨慎,或把 equals 声明为 final。

6.4 clone 的浅拷贝与深拷贝

Object.clone() 是 protected 的 native 方法,逐字段复制内存,属于浅拷贝:基本类型字段复制值,引用类型字段复制引用(拷贝后两个对象共享同一个子对象)。要使用它必须实现 Cloneable 标记接口,否则抛 CloneNotSupportedException。由于这套机制设计得相当糟糕(跳过构造器、与 final 字段冲突、Cloneable 里竟然没有 clone 方法),实践中更推荐用拷贝构造器或拷贝工厂。

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
import java.util.ArrayList;
import java.util.List;

class Address implements Cloneable {
String city;
Address(String city) { this.city = city; }
@Override protected Address clone() throws CloneNotSupportedException {
return (Address) super.clone();
}
}

class Person implements Cloneable {
String name;
Address address; // 引用类型字段
List<String> tags;

Person(String name, Address address) {
this.name = name;
this.address = address;
this.tags = new ArrayList<>();
}

// 浅拷贝:address 与 tags 仍与原对象共享
@Override
protected Person shallowClone() throws CloneNotSupportedException {
return (Person) super.clone();
}

// 深拷贝:递归拷贝所有可变引用字段
public Person deepClone() throws CloneNotSupportedException {
Person copy = (Person) super.clone();
copy.address = this.address.clone(); // 引用字段独立拷贝
copy.tags = new ArrayList<>(this.tags); // 集合独立拷贝
return copy;
}

// 更推荐的替代方案:拷贝构造器,不依赖 Cloneable,可以正确初始化 final 字段
public Person(Person other) {
this.name = other.name;
this.address = new Address(other.address.city);
this.tags = new ArrayList<>(other.tags);
}
}

public class CloneDemo {
public static void main(String[] args) throws Exception {
Person p = new Person("李四", new Address("杭州"));
p.tags.add("VIP");

Person shallow = p.shallowClone();
shallow.address.city = "北京";
System.out.println("浅拷贝后原对象城市 = " + p.address.city); // 北京:被影响了!

Person deep = p.deepClone();
deep.address.city = "上海";
System.out.println("深拷贝后原对象城市 = " + p.address.city); // 北京:不受影响
System.out.println("深拷贝对象城市 = " + deep.address.city); // 上海
}
}

6.5 finalize 的废弃与其它方法

finalize() 在 JDK 9 被标记为 @Deprecated,JDK 18 起默认禁用。废弃原因是它完全不可靠:调用时机不确定(可能永不调用)、至少延长对象一轮 GC 生命周期、可能复活对象、吞掉未捕获异常,还带来性能与死锁风险。资源清理请用 try-with-resources(实现 AutoCloseable),需要感知回收请用 java.lang.ref.Cleaner(JDK 9+)或 PhantomReference。

Object 剩余方法中,wait/notify/notifyAll 是线程协作的基础,必须在 synchronized 块内调用(否则抛 IllegalMonitorStateException),属于并发系列的内容,此处不展开。

记忆口诀:equals 用 instanceof 或 getClass 二选一,但同一继承体系内必须保持一致;hashCode 用的字段必须是 equals 用过的字段的子集;toString 要包含所有关键字段;clone 能不用就不用;finalize 一律不用。

七、实战:用多态消除 if-else

7.1 案例一:动物园的叫声

需求:动物园里有猫、狗、鸭子、老虎,饲养员巡查时要让每个动物”叫一声”, newborn 动物种类会不断增加。面向过程写法是一个巨型 switch,每加一种动物就要改一次饲养员的代码。用多态改写后,饲养员的代码永远不变。

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
import java.util.ArrayList;
import java.util.List;

abstract class Animal {
protected final String name;
protected Animal(String name) { this.name = name; }

// 抽象方法:子类必须给出实现,即"契约"
public abstract void makeSound();

// 模板方法:固定流程 + 可变点,用 final 防止子类破坏骨架
public final void perform() {
System.out.print("【表演开始】");
makeSound(); // 多态发生地
System.out.println("【表演结束】");
}
}

class Cat extends Animal {
Cat() { super("猫"); }
@Override public void makeSound() { System.out.printf("%s:喵喵喵%n", name); }
}

class Dog2 extends Animal {
Dog2() { super("狗"); }
@Override public void makeSound() { System.out.printf("%s:汪汪汪%n", name); }
}

class Duck extends Animal {
Duck() { super("鸭子"); }
@Override public void makeSound() { System.out.printf("%s:嘎嘎嘎%n", name); }
}

class Keeper {
// 只依赖 Animal 抽象:未来新增 Tiger、Lion,这里一行都不用改(开闭原则)
public void inspect(List<Animal> animals) {
for (Animal a : animals) {
a.perform();
}
}
}

public class ZooDemo {
public static void main(String[] args) {
List<Animal> animals = new ArrayList<>();
animals.add(new Cat());
animals.add(new Dog2());
animals.add(new Duck());
new Keeper().inspect(animals);
}
}

7.2 案例二:订单支付系统

需求:订单结算支持支付宝、微信、银行卡三种渠道,每种渠道的校验规则与调用方式不同;未来要接入数字人民币。用策略模式 + 多态把分支彻底消灭。

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
67
68
69
70
71
72
73
74
75
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;

// 抽象:支付能力契约
interface Payment {
String channel(); // 渠道标识
boolean supports(BigDecimal amount); // 前置校验:单笔限额
void pay(String orderNo, BigDecimal amount); // 支付行为
}

class AliPay implements Payment {
@Override public String channel() { return "ALIPAY"; }
@Override public boolean supports(BigDecimal amount) {
return amount.compareTo(new BigDecimal("50000")) <= 0;
}
@Override public void pay(String orderNo, BigDecimal amount) {
System.out.printf("[支付宝] 订单 %s 扣款 %s 元%n", orderNo, amount);
}
}

class WeChatPay implements Payment {
@Override public String channel() { return "WECHAT"; }
@Override public boolean supports(BigDecimal amount) {
return amount.compareTo(new BigDecimal("20000")) <= 0;
}
@Override public void pay(String orderNo, BigDecimal amount) {
System.out.printf("[微信] 订单 %s 扣款 %s 元%n", orderNo, amount);
}
}

class BankCardPay implements Payment {
@Override public String channel() { return "BANK"; }
@Override public boolean supports(BigDecimal amount) {
return amount.compareTo(new BigDecimal("1000000")) <= 0;
}
@Override public void pay(String orderNo, BigDecimal amount) {
System.out.printf("[银行卡] 订单 %s 扣款 %s 元%n", orderNo, amount);
}
}

// 高层模块依赖抽象(依赖倒置):它不知道具体有几种支付方式
class OrderService {
private final Map<String, Payment> channels = new HashMap<>();

public void register(Payment p) { // 扩展点:新增渠道只需注册
channels.put(p.channel(), p);
}

public void settle(String orderNo, String channelCode, BigDecimal amount) {
Payment p = channels.get(channelCode);
if (p == null) {
throw new IllegalArgumentException("不支持的支付渠道:" + channelCode);
}
if (!p.supports(amount)) { // 多态:各渠道自己的限额规则
throw new IllegalStateException("超出渠道限额:" + channelCode);
}
p.pay(orderNo, amount); // 多态:无 if-else
}
}

public class OrderDemo {
public static void main(String[] args) {
OrderService service = new OrderService();
service.register(new AliPay());
service.register(new WeChatPay());
service.register(new BankCardPay());

service.settle("NO20260001", "ALIPAY", new BigDecimal("199.00"));
service.settle("NO20260002", "WECHAT", new BigDecimal("88.50"));
service.settle("NO20260003", "BANK", new BigDecimal("500000"));
// 未来接入数字人民币:新增 DigitalRmbPay implements Payment 并 register,
// OrderService 与 Payment 接口均无需修改 —— 这就是开闭原则的落地
}
}

对比一下:面向过程版本的 settle 会有三个 else if 分支,每接入一种渠道都要改这个方法,回归测试范围是整个结算链路;多态版本里 settle 只有几行,新增渠道是新增文件而非修改文件,风险被隔离在新类内部。这就是”对扩展开放、对修改关闭”的具体含义。

7.3 案例类图(文本描述)

不使用图形,用缩进与符号描述类之间的关系,方便直接复制到任何 Markdown 渲染器中查看:

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
【动物园案例】
Animal (abstract)
├── - name : String (final,构造注入)
├── + makeSound() : void (abstract,子类实现)
└── + perform() : void (final 模板方法,内部调用 makeSound())
▲ 继承(is-a,实线三角箭头)
│
├── Cat : makeSound() → "喵喵喵"
├── Dog : makeSound() → "汪汪汪"
├── Duck : makeSound() → "嘎嘎嘎"
└── Tiger : makeSound() → "嗷呜"(未来新增,零修改)

Keeper ──依赖──> List<Animal> (Keeper 只依赖抽象 Animal)

【订单案例】
Payment (interface)
├── + channel() : String
├── + supports(BigDecimal) : boolean
└── + pay(String, BigDecimal) : void
△ 实现(implements,虚线三角箭头)
│
├── AliPay : 限额 5 万
├── WeChatPay : 限额 2 万
├── BankCardPay : 限额 100 万
└── DigitalRmbPay: 未来新增

OrderService ──聚合──> Map<String, Payment> (依赖倒置:依赖接口而非实现)
OrderService ..> Payment : register(Payment) (扩展点)

八、高频面试题

  1. 重载和重写的区别? 重载在同一类(或父子类)内、参数列表必须不同、编译期静态绑定;重写在父子类之间、签名必须相同、返回值可协变、权限不能变窄、受检异常不能变宽、运行期动态绑定。构造器可重载不可重写。
  2. new 一个对象的完整初始化顺序? 父类静态 → 子类静态 → 父类实例字段与实例块 → 父类构造器体 → 子类实例字段与实例块 → 子类构造器体。静态部分只在类首次主动使用时执行一次。
  3. 为什么重写 equals 必须重写 hashCode? HashMap/HashSet 先用 hashCode 定位桶,再用 equals 比较。只重写 equals 会让逻辑相等的对象落到不同桶,HashSet 存入重复元素、get() 返回 null,违反集合契约。
  4. 字段能被重写吗? 不能。子类同名字段只是隐藏父类字段,两者在内存各占一份,访问哪个由引用的编译期类型决定(getfield 编译期绑定)。只有方法有多态。
  5. 静态方法能被重写吗? 不能,只能被隐藏。调用版本由编译期类型决定,invokestatic 在解析阶段就固化目标。用对象引用调用静态方法是坏味道。
  6. == 和 equals 的区别? == 对基本类型比值、对引用类型比地址(是否同一对象);equals 默认实现为 ==,可重写为内容比较。Integer 在 -128~127 有缓存,用 == 比较包装类是典型 bug。
  7. String 为什么不可变? 内部 byte[] value 是 final 且不提供修改方法、类本身 final、所有”修改”都返回新对象。好处:可缓存 hash、可安全共享(常量池)、天然线程安全、可作 HashMap 的 key。
  8. 抽象类与接口的区别? 抽象类表达 is-a、可有状态与构造器、单继承;接口表达 can-do、JDK 8 起可有 default/static 方法、JDK 9 起可有 private 方法、多实现、字段默认 public static final。设计上接口定义能力,抽象类提供骨架。
  9. 构造器中可以调用重写方法吗? 语法允许但极度危险。父类构造器执行时子类字段尚未初始化,子类重写方法读取自身字段会得到 null/0,引发难定位的 NPE。解决:构造器中只调用 private 或 final 方法。
  10. 什么是向上转型和向下转型? 向上转型是子类引用赋给父类变量,自动发生、永远安全,代价是只能调用父类声明过的方法;向下转型把父类引用转回子类,必须先用 instanceof 判断,否则抛 ClassCastException。JDK 16+ 可用模式匹配一步完成。
  11. this 和 super 能否同时出现在构造器中? 不能。两者都必须是构造器第一条语句,互斥。但 this() 调用的构造器内部最终一定去调 super(),父类初始化不会遗漏。
  12. Object 有哪些方法? equals/hashCode(相等与哈希契约)、toString、getClass(运行期类对象)、clone(浅拷贝,需 Cloneable)、wait/notify/notifyAll(线程协作,须在 synchronized 内)、finalize(JDK 9 废弃,用 Cleaner 替代)。
  13. final、finally、finalize 的区别? final 是修饰符(类不可继承、方法不可重写、变量引用不可变);finally 是异常处理的必执行块;finalize 是已废弃的 GC 回调。三者毫无关系,只是拼写相近。
  14. 多态在 JVM 层面怎么实现? 靠 invokevirtual 指令加虚方法表(vtable)。符号引用在解析阶段转成 vtable 固定索引,执行时按对象实际类型的 vtable 取入口地址;子类重写即覆盖对应槽位。接口方法用 itable,需按签名查找,略慢于 invokevirtual。
  15. 什么是深浅拷贝? 浅拷贝只复制引用,两对象共享子对象(super.clone() 默认行为);深拷贝递归复制所有可变引用字段。推荐用拷贝构造器替代 Cloneable,它能正确处理 final 字段且不依赖脆弱的标记接口。

本篇重点回顾三句话:封装保护不变式,继承建立 is-a 与代码复用,多态用 vtable 索引把分支判断变成一次数组访问。 下一篇将进入抽象类、接口与内部类的进阶话题,并引出函数式接口与 Lambda,为集合框架与 Stream 打下基础。