本文是《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 ]); } } class Account { private final String id; private long balance; public Account (String id, long balance) { this .id = id; this .balance = balance; } 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 里的落地方式:
单一职责(SRP) :一个类只应有一个引起它变化的原因。Order 管状态,OrderRepository 管持久化,OrderValidator 管校验。
开闭原则(OCP) :对扩展开放、对修改关闭。新增支付方式应是新增 Payment 实现类,而不是在 switch 里加 case。
里氏替换(LSP) :子类必须能替换父类出现的地方而不破坏程序。这直接约束重写——不能收紧前置条件,不能放宽后置条件(返回值、异常)。
依赖倒置(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 { private String name; private static int count; { System.out.println("实例初始化块执行" ); name = "默认名称" ; } static { System.out.println("静态初始化块执行" ); count = 0 ; } public ClassMembers () { this .name = "构造器中赋值" ; } public void sayHello () { System.out.println("Hello, " + name); } public static class StaticInner { void show () { System.out.println("count = " + count); } } 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
从字节码可以得到三个硬核结论:第一,分配内存时字段是零值 而非”未定义”,所以不存在 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 (name, 0 ); } public ThisDemo (String name, int age) { this .name = name; this .age = age; } 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()); System.out.println(p.tag()); System.out.println(((Child) p).tag()); } }
上面这段代码是面试高频陷阱:同一个 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); System.out.println(s1.equals(s2)); System.out.println(s3 == s4); System.out.println(s1 == s3); System.out.println(s1.intern() == s3); Integer i1 = 127 , i2 = 127 ; Integer i3 = 128 , i4 = 128 ; System.out.println(i1 == i2); System.out.println(i3 == i4); System.out.println(i3.equals(i4)); } }
结论非常明确:包装类与引用类型的等值判断一律用 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()); u.setAge(-1 ); } }
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 { private final String orderNo; private final List<String> items; public ImmutableOrder (String orderNo, List<String> items) { this .orderNo = orderNo; this .items = new ArrayList <>(items); } public String getOrderNo () { return orderNo; } public List<String> getItems () { 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); ImmutableOrder o2 = o1.withItem("Effective Java" ); System.out.println(o2); } }
注意 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); this .breed = breed; } @Override public void speak () { super .speak(); System.out.println(name + ":汪汪!品种 " + breed); } public void printName () { System.out.println(super .name); } } public class SuperDemo { public static void main (String[] args) { Dog dog = new Dog ("小黑" , "拉布拉多" ); dog.speak(); dog.printName(); } }
4.2 方法重写的四条硬性规则 重写(Override)不是”写一遍同名方法”那么简单,编译器与 JVM 会做严格校验,违反任何一条都会编译失败或运行时抛 IncompatibleClassChangeError。
签名必须相同 :方法名与参数列表(类型、顺序、数量)完全一致。参数类型不相同就是重载,不是重写。
返回值协变 :JDK 5 起,返回值可以是父类方法返回类型的子类型(covariant return type)。
访问权限不能变窄 :父类 public,子类不能改成 protected 或 private;变宽则允许。
异常不能变宽 :子类方法不能抛出比父类更多的受检异常(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 { public Object find (long id) throws IOException { return "entity-" + id; } void internalHook () { System.out.println("父类包私有钩子" ); } } class UserRepository extends Repository { @Override public String find (long id) throws java.io.FileNotFoundException { return "user-" + id; } @Override public void internalHook () { System.out.println("子类增强了钩子,权限变宽" ); } } public class OverrideRuleDemo { public static void main (String[] args) { Repository repo = new UserRepository (); try { String s = (String) repo.find(1L ); 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 ); print(10L ); print("abc" ); print(1 , 2 ); } }
选择顺序大致是:精确匹配 → 基本类型加宽 → 装箱/拆箱 → 可变参数。混用重载与继承时,如果子类新增了一个更具体的重载版本,父类引用调用时仍然只会看到父类的版本,这也是”重载不跨类分派”的表现。
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" ; int value = 2 ; } public class FieldHidingDemo { public static void main (String[] args) { Base b = new Derived (); System.out.println(b.name); System.out.println(b.getName()); Derived d = (Derived) b; System.out.println(d.name); System.out.println(((Base) d).value); System.out.println(d.value); } }
这个例子揭示了一个非常隐蔽的 bug 模式:父类方法访问父类字段,子类方法访问子类字段,同一个逻辑概念(name)在内存里存了两份,值还可能不一致。规避办法很简单——字段一律 private + getter/setter,绝不使用包级或 protected 的可变字段 ,从根源上消灭字段隐藏。
4.5 完整的构造器初始化顺序 这是本系列最重要的考点之一。一次 new Child() 触发的完整顺序如下,每一环都由 JVM 规范强制:
父类静态 :父类静态字段赋值与静态初始化块(按源码顺序),合并进父类 <clinit>
子类静态 :子类静态字段赋值与静态初始化块,合并进子类 <clinit>
回到子类 <init>:第一条指令是 super(...),进入父类 <init>
父类实例 :父类实例字段赋值与实例初始化块(按源码顺序)
父类构造器体 :父类构造器的方法体
子类实例 :子类实例字段赋值与实例初始化块(按源码顺序)
子类构造器体 :子类构造器的方法体
静态部分只在类首次主动使用 (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 { static double totalArea (Shape[] shapes) { double sum = 0 ; for (Shape s : shapes) { sum += s.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 ()); 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) { 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); } } 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" ; 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 () { 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); System.out.println("静态方法 who = " + b.who()); } }
三个陷阱的成因与规避:
字段不参与多态 :getfield 按编译期类型绑定符号引用,永远访问声明类型上的字段。规避:字段私有化,通过方法访问。
静态方法不参与多态 :invokestatic 在解析阶段就固化目标。规避:用类名而非对象引用调用静态方法。
构造器中调用可重写方法 :父类构造器执行时子类字段还是零值,子类重写方法若读取自身字段就拿到 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 { 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); } } 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()); Set<GoodKey> good = new HashSet <>(); good.add(new GoodKey (1 )); good.add(new GoodKey (1 )); System.out.println("GoodKey 集合大小 = " + good.size()); } }
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; public User (String email, String nickname) { this .email = Objects.requireNonNull(email, "email 不能为空" ); this .nickname = nickname; } @Override public boolean equals (Object o) { if (this == o) return true ; if (!(o instanceof User)) return false ; User other = (User) o; return Objects.equals(this .email, other.email) && Objects.equals(this .nickname, other.nickname); } @Override public int hashCode () { return Objects.hash(email, nickname); } @Override public String 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 <>(); } @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; } 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 () ; 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 { 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); } } 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" )); } }
对比一下:面向过程版本的 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) (扩展点)
八、高频面试题
重载和重写的区别? 重载在同一类(或父子类)内、参数列表必须不同、编译期静态绑定;重写在父子类之间、签名必须相同、返回值可协变、权限不能变窄、受检异常不能变宽、运行期动态绑定。构造器可重载不可重写。
new 一个对象的完整初始化顺序? 父类静态 → 子类静态 → 父类实例字段与实例块 → 父类构造器体 → 子类实例字段与实例块 → 子类构造器体。静态部分只在类首次主动使用时执行一次。
为什么重写 equals 必须重写 hashCode? HashMap/HashSet 先用 hashCode 定位桶,再用 equals 比较。只重写 equals 会让逻辑相等的对象落到不同桶,HashSet 存入重复元素、get() 返回 null,违反集合契约。
字段能被重写吗? 不能。子类同名字段只是隐藏 父类字段,两者在内存各占一份,访问哪个由引用的编译期类型决定(getfield 编译期绑定)。只有方法有多态。
静态方法能被重写吗? 不能,只能被隐藏。调用版本由编译期类型决定,invokestatic 在解析阶段就固化目标。用对象引用调用静态方法是坏味道。
== 和 equals 的区别? == 对基本类型比值、对引用类型比地址(是否同一对象);equals 默认实现为 ==,可重写为内容比较。Integer 在 -128~127 有缓存,用 == 比较包装类是典型 bug。
String 为什么不可变? 内部 byte[] value 是 final 且不提供修改方法、类本身 final、所有”修改”都返回新对象。好处:可缓存 hash、可安全共享(常量池)、天然线程安全、可作 HashMap 的 key。
抽象类与接口的区别? 抽象类表达 is-a、可有状态与构造器、单继承;接口表达 can-do、JDK 8 起可有 default/static 方法、JDK 9 起可有 private 方法、多实现、字段默认 public static final。设计上接口定义能力,抽象类提供骨架。
构造器中可以调用重写方法吗? 语法允许但极度危险。父类构造器执行时子类字段尚未初始化,子类重写方法读取自身字段会得到 null/0,引发难定位的 NPE。解决:构造器中只调用 private 或 final 方法。
什么是向上转型和向下转型? 向上转型是子类引用赋给父类变量,自动发生、永远安全,代价是只能调用父类声明过的方法;向下转型把父类引用转回子类,必须先用 instanceof 判断,否则抛 ClassCastException。JDK 16+ 可用模式匹配一步完成。
this 和 super 能否同时出现在构造器中? 不能。两者都必须是构造器第一条语句,互斥。但 this() 调用的构造器内部最终一定去调 super(),父类初始化不会遗漏。
Object 有哪些方法? equals/hashCode(相等与哈希契约)、toString、getClass(运行期类对象)、clone(浅拷贝,需 Cloneable)、wait/notify/notifyAll(线程协作,须在 synchronized 内)、finalize(JDK 9 废弃,用 Cleaner 替代)。
final、finally、finalize 的区别? final 是修饰符(类不可继承、方法不可重写、变量引用不可变);finally 是异常处理的必执行块;finalize 是已废弃的 GC 回调。三者毫无关系,只是拼写相近。
多态在 JVM 层面怎么实现? 靠 invokevirtual 指令加虚方法表(vtable)。符号引用在解析阶段转成 vtable 固定索引,执行时按对象实际类型的 vtable 取入口地址;子类重写即覆盖对应槽位。接口方法用 itable,需按签名查找,略慢于 invokevirtual。
什么是深浅拷贝? 浅拷贝只复制引用,两对象共享子对象(super.clone() 默认行为);深拷贝递归复制所有可变引用字段。推荐用拷贝构造器替代 Cloneable,它能正确处理 final 字段且不依赖脆弱的标记接口。
本篇重点回顾三句话:封装保护不变式,继承建立 is-a 与代码复用,多态用 vtable 索引把分支判断变成一次数组访问。 下一篇将进入抽象类、接口与内部类的进阶话题,并引出函数式接口与 Lambda,为集合框架与 Stream 打下基础。