大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
OOP 进阶
这一课你将能做到
- 一句话区分重载与覆盖,并判断任意一个方法调用发生的是编译期绑定还是运行期绑定
- 用 final 字段、构造器校验、不可变视图写出「外部改不坏」的领域类
- 说清 equals 与 hashCode 的契约,以及抽象类与接口的选型标准
为什么要学
写 CRUD 时感觉不到 OOP,代码要活过三个月时它才开始伤人:Service 里塞满 public 字段、任何人都能把订单状态改成任意值、内部 List 引用被外层 clear() 掉——这些不是「设计美学」问题,是线上事故的源头。
面试角度:重载 vs 覆盖、equals/hashCode 契约、抽象类 vs 接口,是 Java 面试出场率最高的三连问,本课答案按能背的标准写。Spring 的 Bean 体系(第 9 课)也建立在这些概念上:单例 Bean 为什么要求无状态,就是对象生命周期知识的直接应用。
先建立图像
重载 overload:同一个类里,同名不同参,编译期拍板
save(Lesson) ┐
save(Lesson, boolean) ┘ 编译器按参数列表选一个
覆盖 override:子类重新实现父类的同名同参方法,运行期拍板
Animal a = new Dog();
a.speak(); ← 声明类型是 Animal,实际执行的是 Dog.speak()
对象生命周期:
new → 堆上分配 → 被引用持有(局部变量/字段/静态字段/容器)
↓
所有引用断开(出栈、置 null、容器移除)
↓
不可达 → GC 择机回收(时机由 JVM 决定,不可控)
关键直觉:GC 看的是「引用链是否可达」,不是「你觉得它没用了」。静态 Map 只进不出,就是最典型的内存泄漏。
跟着做
步骤 1:完整示例——接口、抽象类、封装、equals/hashCode
新建 OopDemo.java,完整可运行:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public class OopDemo {
/** 接口:定义「能力」,谁都能实现 */
interface Payable {
boolean pay(long amountFen);
default String channelName() { return getClass().getSimpleName(); }
}
/** 抽象类:抽取公共骨架,模板方法固定流程 */
static abstract class PaymentChannel implements Payable {
protected final String merchantId;
protected PaymentChannel(String merchantId) {
this.merchantId = Objects.requireNonNull(merchantId);
}
/** 模板方法:流程固定为 final,差异留给子类 */
public final String payAndLog(long amountFen) {
boolean ok = pay(amountFen);
return channelName() + (ok ? " 支付成功 " : " 支付失败 ") + amountFen + " 分";
}
}
static class WechatPay extends PaymentChannel {
WechatPay(String merchantId) { super(merchantId); }
@Override
public boolean pay(long amountFen) {
return amountFen > 0; // 模拟调用微信下单接口
}
}
/** 封装:对外只读,内部列表不外泄可变引用 */
static class Order {
private final long id;
private final List<String> skuCodes = new ArrayList<>();
Order(long id) { this.id = id; }
void addSku(String sku) {
if (sku == null || sku.isBlank()) throw new IllegalArgumentException("sku 非法");
skuCodes.add(sku);
}
public long id() { return id; }
/** 不可变视图:外部 clear() 会抛 UnsupportedOperationException */
public List<String> skuCodes() { return Collections.unmodifiableList(skuCodes); }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Order other)) return false;
return id == other.id;
}
@Override
public int hashCode() { return Objects.hash(id); }
}
public static void main(String[] args) {
PaymentChannel channel = new WechatPay("m-1001");
System.out.println(channel.payAndLog(9900)); // 运行期绑定到 WechatPay.pay
Order order = new Order(1L);
order.addSku("SKU-1");
System.out.println(order.skuCodes());
try {
order.skuCodes().clear();
} catch (UnsupportedOperationException e) {
System.out.println("封装生效:外部无法清空内部列表");
}
System.out.println(new Order(1L).equals(new Order(1L))); // true:按业务键判等
}
}
运行:java OopDemo.java。
步骤 2:重载在编译期、覆盖在运行期
新建 OverrideDemo.java:
public class OverrideDemo {
static class Repo {
void save(Object entity) { System.out.println("save(Object)"); }
void save(String entity) { System.out.println("save(String)"); } // 重载
}
static class Lesson {
@Override
public String toString() { return "Lesson{...}"; } // 覆盖
}
public static void main(String[] args) {
Repo repo = new Repo();
repo.save("lesson-1"); // save(String):编译期按字面类型选最具体的重载
Object o = "lesson-1";
repo.save(o); // save(Object):编译期只看引用类型,不看实际对象
Object lesson = new Lesson();
System.out.println(lesson); // Lesson{...}:toString 是运行期按实际对象分派
}
}
注意第二条输出:o 的实际对象是 String,但走的是 save(Object)——重载是编译期行为,与多态无关。
步骤 3(选做):JUnit 验证 equals 契约
把 Order 提为独立文件后,同包下写测试:
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class OrderTest {
@Test
void sameIdShouldBeEqual() {
assertEquals(new Order(1L), new Order(1L));
}
@Test
void equalObjectsShouldOccupyOneSetSlot() {
Set<Order> set = new HashSet<>();
set.add(new Order(1L));
set.add(new Order(1L));
assertEquals(1, set.size());
}
}
讲清楚要点
- 重载是编译期行为:调用哪个重载由「编译期引用/字面类型」决定,与运行期实际对象无关;参数列表必须不同,仅返回类型不同不构成重载。
- 覆盖是运行期行为:JVM 按实际对象类型分派(虚方法调用),这是多态的底层机制;覆盖要求方法签名一致,返回类型可以协变(父类返回 Object、子类可返回 String)。
@Override必写:它不是语法要求,而是保险丝——签名拼错时编译器立刻报错,否则就静默变成一个意外的重载。- 封装三件套:private final 字段 + 构造器校验 + 对外返回不可变视图。
Collections.unmodifiableList是视图(原列表变了它跟着变),List.copyOf是真拷贝,二者语义不同,按需选择。 - equals/hashCode 契约:equals 相等 ⇒ hashCode 必须相等(反之不要求);重写 equals 必须重写 hashCode,否则值相等的对象散落在不同哈希桶,HashSet 出现「重复」、HashMap 的 get 拿不到值。
- 抽象类 vs 接口:共享状态、构造逻辑、模板方法 → 抽象类;定义行为契约、希望无关类也能实现、需要「多继承」效果 → 接口。「接口表达 can-do,抽象类表达 is-a」仍是最可靠的判断句式。
- 对象存活只看引用链:局部变量出栈、字段置 null、容器 remove 都会断引用;静态集合长期持有对象是内存泄漏最常见形态,可用 WeakHashMap 或定期清理应对。
你可能会懵的地方
- 单例 Bean 需要自己保证线程安全吗? Spring 默认 singleton,一个实例被所有请求线程共享。只要不在字段里存请求级状态(无状态),就天然线程安全;需要共享计数用 AtomicInteger 或 Redis,不要用普通成员变量。
- 什么时候对象会被 GC?
唯一标准是「从 GC Roots 出发不可达」。GC Roots 包括栈帧局部变量、静态字段、JNI 引用等。
System.gc()只是建议,时机不可控。 - 构造器里能调用可被覆盖的方法吗? 不能。子类构造尚未执行,其字段还是默认值,覆盖方法会读到脏数据;构造器里只调用 private 或 final 方法。
- 为什么仅返回类型不同不构成重载? 方法签名 = 方法名 + 参数列表,返回类型不在签名内。编译器靠签名在编译期定位方法,只有返回类型不同时它无法抉择。
- 领域对象和 Spring Bean 的生命周期有什么区别? Spring Bean 由容器创建、装配、销毁,默认单例;领域对象(Order、Lesson)由你的代码 new 出来、随引用消亡。框架托管的是「组件」,不是「业务数据」。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| getter 直接返回内部 List 引用 | 外部可 clear/add 破坏状态 | unmodifiableList 或 List.copyOf |
| 重写 equals 不重写 hashCode | HashSet 出现重复、HashMap.get 失败 | 成对重写 |
| 把 @Override 标在重载方法上 | 编译报错 | @Override 只用于覆盖 |
| 构造器里调用可被覆盖的方法 | 子类未初始化字段被读到 | 构造器只调 private/final 方法 |
| 用 public 字段暴露状态 | 失去修改控制权与校验点 | private 字段 + 访问器/行为方法 |
卡点急救
- HashSet 里对象 remove 不掉:存入后修改了参与 hashCode 的字段导致哈希值漂移。规则:放入哈希容器的不可变对象不修改;确需修改则先 remove 再改再 add。
- 子类「覆盖」了方法却不生效:签名没有完全一致,多半写成了重载;补上
@Override让编译器把关。 - 构造器参数一多就写乱:超过 3~4 个参数改用 Builder(第 5 课)或 record(第 6 课),不要堆十几个重载构造器。
面试常问
- 重载和覆盖的区别?
可背版:重载发生在同一个类(或继承可见)中,方法名相同、参数列表不同,编译期静态绑定,返回类型可不同;覆盖发生在子类与父类的实例方法之间,签名必须一致,运行期动态绑定,返回类型可协变,访问权限不能收窄,异常不能放大。
@Override用于编译期校验覆盖关系。 - equals 与 hashCode 的契约是什么?为什么必须成对重写? 可背版:① equals 相等 ⇒ hashCode 必须相等;② hashCode 相等 ⇏ equals 相等(哈希冲突合法);③ equals 判等标准涉及的字段必须同时参与 hashCode 计算。HashMap 先比 hashCode 再比 equals,只重写 equals 会导致等值对象落进不同桶——HashSet 出现重复元素、get 返回 null。
- 抽象类和接口怎么选? 需要共享字段、构造逻辑、模板方法(流程固定细节开放)→ 抽象类;只约定能力、实现类彼此无关、需要多实现 → 接口。Java 8 后接口有 default 方法,但「is-a 用抽象类、can-do 用接口」仍是首选判断;一个类可以 implements 多个接口,但只能 extends 一个抽象类。
自测三问
Object o = "abc"; save(o)与save("abc")调用的重载版本分别由什么决定?- 为什么返回 unmodifiableList 还不够,有时要 List.copyOf?两者差异在哪?
- 静态 Map 缓存只进不出会引发什么问题?有哪两种应对手段?
小结一句话
重载看参数在编译期定,覆盖看对象在运行期定;封装决定谁能改你的状态,equals/hashCode 决定你在哈希世界里的身份。