Java 基础体系 · 第 4/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 对象模型:类、接口、继承、组合、不可变性和设计边界
1. 先建立对象模型:类型、对象、引用和身份
Java 中的类型描述一组允许的值以及这些值支持的操作。类、接口、数组、枚举和 record 都属于引用类型;int、boolean 等属于基本类型。
对象是运行时实体,通常具有:
- 身份:它与其他对象是否为同一个实体;
- 状态:对象中保存的数据;
- 行为:通过方法对状态进行读取或改变。
引用不是对象本身,而是指向对象的值。变量可以保存引用,也可以保存 null:
String a = new String("java");
String b = a;
String c = new String("java");
System.out.println(a == b); // true:两个引用指向同一个对象
System.out.println(a == c); // false:通常是两个不同对象
System.out.println(a.equals(c)); // true:String 按字符内容比较
== 对引用比较的是对象身份;equals 比较什么由具体类型的契约决定。Object.equals 默认等价于身份比较,但 String、Integer 等类重写了它。
可以把引用赋值理解为:
变量 a ─────► 对象 O1
变量 b ─────► 对象 O1
变量 c ─────► 对象 O2
因此,修改对象的一个引用可观察到的状态,其他指向同一对象的引用也会观察到变化:
final class Counter {
private int value;
void increment() {
value++;
}
int value() {
return value;
}
}
Counter x = new Counter();
Counter y = x;
y.increment();
System.out.println(x.value()); // 1
这里 final Counter x 只保证 x 这个变量不能改为指向别的对象,不保证 Counter 对象不可变。
1.1 静态类型和运行时类型
Animal animal = new Dog();
animal.speak();
这里:
Animal是animal的静态类型,编译器据此检查能否调用speak;Dog是对象的运行时类型,实例方法通常据此进行动态分派。
如果 Animal 没有声明 run,即使 Dog 有 run,下面代码也无法编译:
animal.run(); // 编译错误:Animal 类型中没有 run
若需要访问 Dog 特有操作,必须进行类型检查和转换:
if (animal instanceof Dog dog) {
dog.run();
}
instanceof 模式匹配同时完成检查和变量绑定。它不能把不相关的类型强行转换为目标类型;类型系统会在编译期拒绝明显不可能的转换。
1.2 null 是引用值,不是对象
String name = null;
name.length(); // NullPointerException
null 没有对象身份,也没有方法。以下操作会触发空指针异常:
- 调用实例方法;
- 访问实例字段;
- 对
null进行自动拆箱; - 对
null调用某些需要对象的操作。
Integer count = null;
int n = count; // 自动拆箱时抛出 NullPointerException
这也是 API 边界需要明确约定的原因:一个参数是否允许为 null,不能仅靠调用方猜测。
2. 类:对象状态和行为的封装单元
类声明了对象的:
- 实例字段;
- 实例方法;
- 构造器;
- 访问控制;
- 继承关系;
- 实现的接口。
一个简单的类如下:
public final class Money {
private final long cents;
private final String currency;
public Money(long cents, String currency) {
if (cents < 0) {
throw new IllegalArgumentException("cents must be non-negative");
}
this.currency = java.util.Objects.requireNonNull(currency);
this.cents = cents;
}
public long cents() {
return cents;
}
public String currency() {
return currency;
}
public Money add(Money other) {
java.util.Objects.requireNonNull(other);
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(cents + other.cents, currency);
}
@Override
public boolean equals(Object obj) {
return obj instanceof Money other
&& cents == other.cents
&& currency.equals(other.currency);
}
@Override
public int hashCode() {
return java.util.Objects.hash(cents, currency);
}
@Override
public String toString() {
return cents + " " + currency;
}
}
这个类表达了一个不变量:
cents >= 0
currency != null
构造器负责首次建立不变量;后续方法不能破坏它。对象模型中的封装不是简单地把字段设为 private,而是让对象负责维护自身状态的合法性。
2.1 构造器不是普通方法
构造器:
- 名称必须与类名相同;
- 没有返回类型;
- 不能被继承;
- 用于创建并初始化对象;
- 若构造失败,对象不会作为一个正常构造完成的实例返回给调用方。
class User {
private final String name;
User(String name) {
this.name = java.util.Objects.requireNonNull(name);
}
}
创建对象时,逻辑上会经历以下过程:
- 为对象分配存储;
- 实例字段获得默认值,例如
0、false、null; - 执行超类构造过程;
- 执行当前类的实例字段初始化器和实例初始化块;
- 执行当前构造器主体。
继承场景下,子类构造器必须直接或间接调用超类构造器:
class Animal {
Animal(String name) {
// ...
}
}
class Dog extends Animal {
Dog() {
super("unknown"); // 必须显式调用,因为 Animal 没有无参构造器
}
}
构造器中调用可被子类重写的实例方法通常是危险的,因为子类字段初始化可能尚未完成:
class Base {
Base() {
print(); // 可能动态分派到 Derived.print()
}
void print() {}
}
class Derived extends Base {
private String value = "ready";
@Override
void print() {
System.out.println(value); // 可能打印 null
}
}
这不是因为 value 的声明失效,而是因为超类构造器运行时,子类字段初始化器还没有执行。构造阶段应尽量只使用已经建立的不变量。
3. 访问控制和封装边界
Java 的主要访问级别如下:
| 修饰符 | 可访问范围 |
|---|---|
private |
当前顶层类或声明它的类内部 |
| 无修饰符 | 同一 package |
protected |
同一 package,以及其他 package 中的子类访问规则 |
public |
只要模块和其他访问条件允许,均可访问 |
访问控制不是安全沙箱。它首先是编译期的 API 边界;反射、模块系统和运行时权限还会影响实际可访问性。
字段通常应设为 private,通过方法暴露受控操作:
public final class Temperature {
private double celsius;
public Temperature(double celsius) {
setCelsius(celsius);
}
public double celsius() {
return celsius;
}
public void setCelsius(double celsius) {
if (celsius < -273.15) {
throw new IllegalArgumentException("below absolute zero");
}
this.celsius = celsius;
}
}
这里 setCelsius 不是单纯的字段赋值,而是状态转移:
旧状态 celsius
│
├─ 新值 < -273.15 ──► 拒绝,旧状态保持
│
└─ 新值合法 ────────► 更新为新状态
如果直接公开字段,调用方可以绕过这个状态转换,封装就失效了。
4. 继承:建立“是一个”关系
继承允许一个类基于另一个类定义。class Child extends Parent 表示 Child 是 Parent 的子类型,通常表达“Child 是一种 Parent”。
class Animal {
void speak() {
System.out.println("some sound");
}
}
class Dog extends Animal {
@Override
void speak() {
System.out.println("woof");
}
}
继承带来三种常见能力:
- 复用超类的实现;
- 向上转型,把子类对象当作超类使用;
- 重写实例方法,实现多态。
Animal animal = new Dog();
animal.speak(); // woof
4.1 重载与重写不是一回事
重载发生在编译期:方法名相同但参数列表不同。
重写发生在继承关系中:子类为超类的可继承实例方法提供新的实现。
class Printer {
void print(Object x) {
System.out.println("Object");
}
void print(String x) {
System.out.println("String");
}
}
Object value = "text";
new Printer().print(value); // Object
这里调用哪个重载版本由变量的静态类型 Object 决定。
class Parent {
void print() {
System.out.println("parent");
}
}
class Child extends Parent {
@Override
void print() {
System.out.println("child");
}
}
Parent p = new Child();
p.print(); // child
这里调用哪个实现由对象运行时类型决定。
简化理解为:
重载选择:编译期,根据静态类型和参数类型
重写分派:运行期,根据接收者对象的运行时类型
静态方法不参与这种实例方法动态分派;静态方法属于类,调用时应通过类名表达:
class A {
static void f() { System.out.println("A"); }
}
class B extends A {
static void f() { System.out.println("B"); }
}
A x = new B();
x.f(); // A,静态方法按声明类型解析
这类代码容易造成误读,应避免用实例引用调用静态方法。
4.2 子类型必须满足可替换性
继承的核心边界不是“代码能否复用”,而是子类型能否在不破坏客户端正确性的情况下替换父类型。
设父类型允许的输入集合为 ,子类型允许的输入集合为 ,父类型产生的结果集合为 ,子类型产生的结果集合为 。一个常用的可替换性条件是:
C 至少接受 P 接受的所有输入:P ⊆ C
C 返回的结果不能超出父类型承诺:R_C ⊆ R_P
直觉是:
- 子类不能无故收紧父类的前置条件;
- 子类不能削弱父类保证的后置条件;
- 父类建立的不变量,子类也必须保持。
反例:
class Bird {
void fly() {
// 合约:所有 Bird 都能飞
}
}
class Penguin extends Bird {
@Override
void fly() {
throw new UnsupportedOperationException();
}
}
如果调用方接收 Bird 后合理地调用 fly(),传入 Penguin 就失败。问题不是企鹅“实现得不完整”,而是 Bird 的抽象边界错误:会走路的动物和会飞的动物不应由同一个强制父类方法表达。
更合理的建模是:
interface Animal {
void eat();
}
interface FlyingAnimal {
void fly();
}
final class Penguin implements Animal {
@Override
public void eat() {}
}
final class Eagle implements Animal, FlyingAnimal {
@Override
public void eat() {}
@Override
public void fly() {}
}
4.3 继承的限制
Java 类是单继承的:
class Child extends ParentA, ParentB { } // 非法
一个类只能有一个直接超类,但可以实现多个接口:
class Service implements Closeable, Runnable {
@Override
public void close() {}
@Override
public void run() {}
}
类不能重写:
final方法;private方法;- 静态方法的“实例版本”。
类也不能继承 final 类:
final class SecurityToken {}
class SpecialToken extends SecurityToken {} // 编译错误
final 类常用于表达“不允许通过继承扩展此实现”。String 就是典型例子,但 final 本身不等于不可变;一个 final 类仍可拥有可变字段。
5. 接口:能力契约,而不是共享对象状态
接口主要描述一组可供客户端依赖的操作契约:
interface PaymentMethod {
PaymentResult pay(Money amount);
}
类可以实现多个接口:
final class CardPayment implements PaymentMethod {
@Override
public PaymentResult pay(Money amount) {
return PaymentResult.success();
}
}
接口变量表达的是“调用方需要什么能力”,而不是“调用方知道具体实现是什么”:
PaymentMethod method = new CardPayment();
PaymentResult result = method.pay(new Money(1000, "CNY"));
接口中的字段隐含为 public static final;接口不能声明普通实例字段来保存每个实现对象的状态。接口可以声明:
- 抽象实例方法;
default实例方法;static方法;private方法;- 常量字段。
default 方法提供可继承的默认行为:
interface Named {
String name();
default String displayName() {
return name().trim();
}
}
如果两个接口提供冲突的默认方法,实现类必须显式解决:
interface A {
default String value() {
return "A";
}
}
interface B {
default String value() {
return "B";
}
}
final class C implements A, B {
@Override
public String value() {
return A.super.value(); // 明确选择 A 的实现
}
}
类自身的实现优先于接口默认方法;两个接口的默认方法冲突时,不能让编译器猜测。
5.1 接口和抽象类的边界
抽象类可以拥有:
- 实例字段;
- 构造器;
- 受保护的共享实现;
- 部分已实现的方法;
- 一个明确的类继承链。
接口更适合表达:
- 跨不同继承体系共享的能力;
- 调用方真正需要的最小契约;
- 可替换的外部实现。
例如,ArrayList 和 HashSet 都可以实现 Collection,但它们的内部状态和算法完全不同。调用方若只需要遍历和查询,就不应该依赖具体集合类。
接口也不是“必须没有实现”。接口默认方法适合稳定的通用行为,但若默认行为依赖大量共享状态,抽象类或组合对象通常更清晰。
6. 组合:通过对象协作构造行为
组合是一个对象持有另一个对象,并把工作委托给它。它表达“有一个”关系,而不是“是一个”。
interface DiscountPolicy {
Money apply(Money original);
}
final class NoDiscount implements DiscountPolicy {
@Override
public Money apply(Money original) {
return original;
}
}
final class Order {
private final DiscountPolicy discountPolicy;
Order(DiscountPolicy discountPolicy) {
this.discountPolicy = java.util.Objects.requireNonNull(discountPolicy);
}
Money finalPrice(Money original) {
return discountPolicy.apply(original);
}
}
数据流是:
调用方
│ original Money
▼
Order.finalPrice
│ 委托
▼
DiscountPolicy
│ 返回
▼
最终 Money
折扣策略可以替换,而 Order 不需要继承多个不同的订单基类:
final class TenPercentDiscount implements DiscountPolicy {
@Override
public Money apply(Money original) {
return new Money(original.cents() * 90 / 100, original.currency());
}
}
组合的关键优势是变化隔离:
- 继承把父类实现和子类绑定在编译结构中;
- 组合把协作对象作为依赖,可以通过构造器替换;
- 组合对象可以在运行时选择不同策略;
- 被组合对象的生命周期和可见性可以独立管理。
但组合也有边界。若一个对象只是把所有方法原样转发给另一个对象,可能形成无意义的包装层;若协作对象过多,组合根本身会承担过多协调职责。组合不是“越多越好”,而是应让每个对象拥有清晰的不变量和责任。
6.1 继承与组合的反例对比
错误地用继承表达“拥有”:
class Stack extends ArrayList<String> {
void push(String value) {
add(value);
}
String pop() {
return remove(size() - 1);
}
}
Stack 继承 ArrayList 后,调用方仍可使用 add(index, value)、remove(Object) 等操作,破坏栈的约束。它并不是一个可以安全替换 ArrayList 的类型。
组合可以隐藏不需要暴露的操作:
final class StringStack {
private final java.util.ArrayList<String> values = new java.util.ArrayList<>();
void push(String value) {
values.add(value);
}
String pop() {
if (values.isEmpty()) {
throw new java.util.NoSuchElementException("empty stack");
}
return values.remove(values.size() - 1);
}
int size() {
return values.size();
}
}
此时 ArrayList 是实现细节,客户端只能使用栈允许的操作。组合并不自动保证正确性,但它减少了暴露非法操作的机会。
7. 不可变性:不是 final 字段的同义词
不可变对象是指:对象构造完成并对外可见后,其逻辑状态不能再改变。
一个不可变类通常需要同时满足:
- 类不能被不受控地继承,常见做法是声明
final; - 表示状态的字段为
private final; - 构造器建立全部不变量;
- 不提供改变状态的方法;
- 不泄露可变内部对象;
- 不把外部可变对象直接保存为内部表示;
- 返回的复合对象也经过保护;
equals、hashCode等基于稳定状态实现。
下面的类看起来使用了 final,但实际上不是不可变的:
final class MutableHolder {
private final java.util.List<String> values;
MutableHolder(java.util.List<String> values) {
this.values = values; // 保存了调用方传入的可变引用
}
java.util.List<String> values() {
return values; // 又把内部引用暴露出去
}
}
调用方可以通过两个方向修改内部状态:
var source = new java.util.ArrayList<>(java.util.List.of("a"));
var holder = new MutableHolder(source);
source.add("b"); // 修改 holder 内部状态
holder.values().clear(); // 同样修改 holder 内部状态
正确做法是防御性复制,并且返回不可修改视图或不可变副本:
public final class ImmutableTags {
private final java.util.List<String> tags;
public ImmutableTags(java.util.Collection<String> tags) {
java.util.Objects.requireNonNull(tags);
this.tags = java.util.List.copyOf(tags);
}
public java.util.List<String> tags() {
return tags;
}
}
List.copyOf 会创建一个不可修改的列表结果,并拒绝 null 元素;它不等价于把任意对象“深度冻结”。如果元素本身可变,元素仍可能变化:
final class MutableName {
String value;
}
var name = new MutableName();
name.value = "before";
var list = java.util.List.copyOf(java.util.List.of(name));
name.value = "after"; // 列表结构没变,但元素对象变了
因此应区分:
浅不可变:容器结构不能改变,元素对象仍可能改变
深不可变:从根对象可达的全部相关状态都不能改变
Java 标准库通常提供的是浅层保护;深不可变需要元素类型本身也满足不可变约束,或者进行递归复制。
7.1 不可变对象的状态转移
不可变对象不修改自身,而是返回新对象:
Money a = new Money(1000, "CNY");
Money b = a.add(new Money(500, "CNY"));
System.out.println(a); // 1000 CNY
System.out.println(b); // 1500 CNY
状态变化从:
a: 1000 CNY
变成了创建另一个对象:
a: 1000 CNY
b: 1500 CNY
这使得共享更容易推理:持有 a 的代码不会因为其他代码“修改了同一个 Money”而观察到意外变化。
7.2 final 与并发可见性
final 字段还有 Java 内存模型层面的构造安全语义:如果构造过程没有把 this 泄露出去,其他线程在获得对象引用后,对正确初始化的 final 字段具有特殊保证。
但这不意味着整个对象天然线程安全:
final class Config {
private final java.util.List<String> values;
Config(java.util.List<String> values) {
this.values = values; // 列表仍可被其他线程修改
}
}
final 只保护引用变量不被重新赋值,不保护被引用对象的内部状态。线程安全还需要考虑:
- 可变状态是否存在;
- 状态访问是否同步;
- 对象是否安全发布;
- 复合操作是否原子;
- 被引用的对象是否也满足并发要求。
不可变对象通常可以安全共享,但前提是它的整个可观察状态确实不可变,且构造期间没有把未完成对象发布出去。
8. equals、hashCode 和对象身份边界
值对象通常按逻辑状态比较,而实体对象通常按身份或业务标识比较。
若重写 equals,必须同时满足 hashCode 契约:
a.equals(b) == true ⇒ a.hashCode() == b.hashCode()
反过来不要求成立。哈希冲突是允许的。
错误示例:
final class Point {
private final int x;
private final int y;
@Override
public boolean equals(Object obj) {
return obj instanceof Point p && x == p.x && y == p.y;
}
// 未重写 hashCode
}
两个逻辑相等的 Point 可能有不同哈希值,放入 HashSet 后会出现违反预期的查找行为。
更危险的是,把可变字段用于哈希后再修改:
final class MutableKey {
int id;
@Override
public boolean equals(Object o) {
return o instanceof MutableKey k && id == k.id;
}
@Override
public int hashCode() {
return Integer.hashCode(id);
}
}
var key = new MutableKey();
key.id = 1;
var set = new java.util.HashSet<MutableKey>();
set.add(key);
key.id = 2;
System.out.println(set.contains(key)); // 可能为 false
对象仍在集合中,但它的哈希桶位置是按旧值确定的。这个失败不是 HashSet 随机,而是键的哈希相关状态在插入后发生了变化。
因此,作为哈希键的对象,其参与 equals 和 hashCode 的状态在作为键期间必须保持稳定。不可变值对象天然适合这一边界。
9. Record:受约束的数据载体
record 是一种特殊的类声明,适合表达主要由固定组件构成的数据。它隐含生成:
private final组件字段;- 规范构造器;
- 与组件同名的访问器;
equals;hashCode;toString。
public record UserId(long value) {
public UserId {
if (value <= 0) {
throw new IllegalArgumentException("value must be positive");
}
}
}
这里的紧凑构造器仍然是在构造阶段验证输入。UserId 的组件 value 是不可变的,因此这个例子是不可变值对象。
但 record 不是自动深度不可变:
record Names(java.util.List<String> values) {}
var source = new java.util.ArrayList<>(java.util.List.of("A"));
var names = new Names(source);
source.add("B"); // names.values() 现在也能看到 "B"
如果 record 组件是集合,应在构造器中复制:
public record SafeNames(java.util.List<String> values) {
public SafeNames {
values = java.util.List.copyOf(values);
}
}
record 不能继承另一个类,隐式继承 java.lang.Record,但可以实现接口。它适合数据语义,不代表所有包含数据的类都应改写为 record;需要可变生命周期、复杂身份语义、受控构造流程或继承层次时,普通类更合适。
10. Sealed 类型:把继承边界显式化
sealed 类型限制允许的直接子类型:
public sealed interface Shape
permits Circle, Rectangle {
}
public record Circle(double radius) implements Shape {
}
public record Rectangle(double width, double height) implements Shape {
}
每个直接子类型必须声明为:
final:不再允许继续继承;sealed:继续限制子类型;non-sealed:重新开放继承。
public sealed interface Node
permits Leaf, Branch {
}
public final class Leaf implements Node {
}
public non-sealed class Branch implements Node {
}
sealed 的价值是把领域层次的封闭性写入类型系统。编译器知道直接子类型集合后,结合模式匹配可以进行穷尽性检查:
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}
该 switch 没有 default,仍可通过编译,是因为 Shape 的许可子类型已经覆盖了情况。若后来增加新的许可子类型,相关 switch 可能需要重新处理。
sealed 不是运行时权限系统,也不是对所有间接实现都完全禁止;它约束的是直接继承或实现关系,并受模块、package 和可访问性规则约束。它表达的是“这个类型层次由哪些参与者组成”,而不是“对象状态不可变”。
11. 泛型、接口和数组协变的边界
对象模型经常通过泛型表达容器关系。Java 泛型通常是不变的:
List<String> strings = new ArrayList<>();
List<Object> objects = strings; // 编译错误
如果这段赋值合法,就可以执行:
objects.add(42);
从而把整数放入只允许字符串的列表。因此,List<String> 不是 List<Object> 的子类型。
需要只读生产能力时使用上界通配符:
static double sum(java.util.List<? extends Number> values) {
double total = 0;
for (Number value : values) {
total += value.doubleValue();
}
return total;
}
? extends Number 表示“某个未知的 Number 子类型”。可以安全读取为 Number,但不能向其中写入任意 Number,因为实际类型可能是 Integer:
static void addIntegers(java.util.List<? extends Number> values) {
// values.add(1); // 编译错误
}
需要写入某种类型时使用下界:
static void addDefaults(java.util.List<? super Integer> values) {
values.add(1);
values.add(2);
}
? super Integer 可能是 List<Integer>、List<Number> 或 List<Object>,写入 Integer 都安全;读取时只能可靠地当作 Object。
这就是 PECS:
Producer Extends:生产 T 的来源使用 ? extends T
Consumer Super:消费 T 的目标使用 ? super T
数组则不同:
String[] strings = new String[1];
Object[] objects = strings; // 合法:数组协变
objects[0] = 42; // ArrayStoreException
数组协变把一部分类型检查推迟到了运行期;泛型不变性则更多在编译期阻止这类错误。API 设计时不能把“数组可协变”误推导成“泛型容器也可协变”。
12. 继承、组合和接口的选择推导
可以按以下问题逐步确定边界。
12.1 问题一:是否满足真正的“是一个”
若 Child 作为 Parent 使用时,所有合法的 Parent 操作对 Child 都成立,继承或接口子类型关系才有候选价值。
客户端只知道 Parent
客户端调用 Parent 合约允许的操作
Child 能完成这些操作,并保持 Parent 不变量
只要其中一个关键操作必须抛出“不支持”,或者子类需要削弱父类保证,继承关系就值得怀疑。
12.2 问题二:变化是否来自算法或策略
若变化只是某个算法、规则、存储方式或外部服务实现,组合通常更适合:
Order ──持有──► DiscountPolicy
而不是:
DiscountedOrder extends Order
前者允许同一个订单对象在构造时注入不同策略,后者会把策略种类变成继承层次。
12.3 问题三:调用方真正依赖多少操作
如果调用方只需要:
interface Clock {
java.time.Instant now();
}
就不要让它依赖一个同时负责配置、网络连接、日志和缓存的大型具体类。较小的接口能减少替换成本,也能使违反契约的实现更容易被发现。
不过,接口拆分也有成本:过度拆分会产生大量只包含一个方法的类型,使领域概念变得碎片化。接口边界应围绕稳定的客户端需求,而不是机械地按方法数量拆分。
13. 继承和组合中的异常边界
异常也是对象契约的一部分。父类型若承诺某操作在输入合法时成功,子类型不应在相同输入上无故引入新的失败路径。
例如:
interface Cache {
String get(String key);
}
如果调用方理解为“未命中返回 null”,一个实现却在未命中时抛出 NoSuchElementException,即使方法签名完全一致,也改变了契约。
组合中同样要处理委托失败:
final class PaymentService {
private final PaymentMethod method;
PaymentService(PaymentMethod method) {
this.method = java.util.Objects.requireNonNull(method);
}
PaymentResult pay(Money amount) {
try {
return method.pay(amount);
} catch (java.net.ConnectException e) {
return PaymentResult.retryableFailure("payment provider unavailable");
}
}
}
这里必须明确:
- 哪些异常表示可重试;
- 哪些异常表示业务拒绝;
- 底层异常是否应该直接泄露;
- 重试是否可能造成重复扣款;
- 委托对象的状态是否已经改变。
如果支付请求已经被服务端接受,但响应丢失,简单重试可能产生重复扣款。对象边界不能只看 Java 方法调用是否返回,还要看外部系统状态是否已经改变。
14. 一个完整的小例子:接口、组合、不可变对象和 sealed 结果
下面的代码把几种边界放在一起:
import java.util.Objects;
public class ObjectModelDemo {
public static void main(String[] args) {
PaymentGateway gateway = new FakeGateway();
PaymentService service = new PaymentService(gateway);
PaymentRequest request = new PaymentRequest(
new Money(1_500, "CNY"),
"order-1001"
);
PaymentResult result = service.pay(request);
String message = switch (result) {
case PaymentResult.Success s ->
"success: " + s.transactionId();
case PaymentResult.Rejected r ->
"rejected: " + r.reason();
};
System.out.println(message);
}
public record Money(long cents, String currency) {
public Money {
if (cents <= 0) {
throw new IllegalArgumentException("cents must be positive");
}
currency = Objects.requireNonNull(currency);
}
}
public record PaymentRequest(Money amount, String orderId) {
public PaymentRequest {
amount = Objects.requireNonNull(amount);
orderId = Objects.requireNonNull(orderId);
}
}
public sealed interface PaymentResult
permits PaymentResult.Success, PaymentResult.Rejected {
record Success(String transactionId)
implements PaymentResult {
public Success {
transactionId = Objects.requireNonNull(transactionId);
}
}
record Rejected(String reason)
implements PaymentResult {
public Rejected {
reason = Objects.requireNonNull(reason);
}
}
static Success success(String id) {
return new Success(id);
}
static Rejected rejected(String reason) {
return new Rejected(reason);
}
}
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
}
public static final class PaymentService {
private final PaymentGateway gateway;
public PaymentService(PaymentGateway gateway) {
this.gateway = Objects.requireNonNull(gateway);
}
public PaymentResult pay(PaymentRequest request) {
Objects.requireNonNull(request);
return gateway.charge(request);
}
}
public static final class FakeGateway
implements PaymentGateway {
@Override
public PaymentResult charge(PaymentRequest request) {
return PaymentResult.success("tx-" + request.orderId());
}
}
}
编译运行:
javac ObjectModelDemo.java
java ObjectModelDemo
预期输出:
success: tx-order-1001
关键数据流是:
PaymentRequest
│
▼
PaymentService
│ 组合并委托
▼
PaymentGateway
│
▼
sealed PaymentResult
│
▼
switch 模式匹配
各部分职责不同:
Money和PaymentRequest是经过校验的不可变数据;PaymentGateway是可替换的接口边界;PaymentService通过组合持有网关;PaymentResult用 sealed 层次限制结果类型;switch根据结果类型进行穷尽处理;FakeGateway可以替换真实外部支付实现。
这个例子没有让 PaymentService extends FakeGateway,因为服务并不是一种网关;它只是使用网关完成更高层的业务操作。
15. 设计边界中的常见失败表现
15.1 把实现继承当作复用工具
症状:
- 子类必须了解父类大量受保护状态;
- 父类修改后多个子类同时出现回归;
- 子类继承了不需要的公开方法;
- 子类需要覆盖方法以抛出“不支持”。
诊断方式是先写出父类对调用方的完整契约,再验证每个子类是否能无条件满足,而不是只检查“代码是否能编译”。
15.2 把 final 误认为深度不可变
症状:
private final List<Item> items;
但外部修改传入列表,或者通过 getter 修改返回列表。诊断时沿着对象引用图检查:
根对象
└─ 字段引用
└─ 集合
└─ 元素对象
└─ 可变字段
每一层都要判断是否存在可达的修改路径。只有根字段 final,无法推出整张引用图不可变。
15.3 用可变对象作为哈希键
症状包括:
HashMap.get(key)返回null,但遍历 map 又能看到该键;HashSet.contains(key)在插入后突然返回false;- 问题只在修改字段后出现。
检查 equals 和 hashCode 所依赖的字段是否在放入集合后发生变化。恢复通常需要移除旧键、恢复键状态,或重建集合;仅重新调用 contains 不会修复错误的桶位置。
15.4 接口契约过宽
症状:
- 实现类大量方法只抛
UnsupportedOperationException; - 测试必须覆盖许多与当前客户端无关的方法;
- 替换实现困难。
这说明接口可能混合了多个客户端角色。应从调用方实际使用的操作出发拆分能力,但要保持领域概念可理解,避免把每个方法都变成独立接口。
15.5 暴露内部可变状态
症状:
return internalMap;
调用方可能绕过当前类的校验、并发控制和生命周期管理。修复方式取决于契约:
- 返回不可修改视图;
- 返回防御性副本;
- 暴露只读接口;
- 提供具体查询和命令方法,而不是整个集合;
- 若需要快照,明确返回某一时刻的副本。
视图和副本不是同一件事:视图通常仍反映源集合后续变化,副本则拥有独立结构。
16. 规范保证、常见实现和工程判断
需要区分三类结论。
Java 语言和标准库规范保证包括:
- 类的单继承;
- 接口可多实现;
- 引用类型的静态检查与运行时类型检查;
- 实例方法的动态分派规则;
equals和hashCode的契约;- record、sealed 类型的语言语义;
- 泛型类型检查和类型擦除相关规则。
常见 JVM 实现行为包括:
- 对象和字段通常按堆上的某种布局存储;
- 可能使用对象头、指针压缩、逃逸分析和标量替换;
- 可能以内联优化动态分派;
- 可能不为某些实际逃逸的对象分配独立堆内存。
这些布局和优化不是 Java 源码层面可以依赖的对象模型。不要根据某个 JVM 版本、垃圾收集器或处理器上的字段排列推导业务正确性。
工程判断则包括:
- 是否使用继承;
- 是否使用组合;
- 是否将类型设计为
final; - 是否用 record 表达值;
- 是否将层次设计为 sealed;
- 是否允许
null; - 是否选择可变还是不可变状态。
这些判断不能由单一规则自动决定,必须回到对象的身份、生命周期、不变量、协作关系和变化来源。
17. 最终边界:让类型结构匹配领域事实
一个可靠的 Java 对象模型至少要能回答以下问题:
- 这个对象是按身份识别,还是按值比较?
- 哪些状态必须始终成立?
- 谁负责创建和验证状态?
- 哪些操作是对象真正允许的公开行为?
- 子类型是否能满足父类型的全部契约?
- 这个关系是“是一个”还是“有一个”?
- 变化来自类型本身,还是来自可替换的策略和依赖?
- 返回对象后,调用方能否绕过不变量修改内部状态?
- 对象作为并发共享数据时,状态是否稳定且安全发布?
- 该类型层次应该开放扩展、限制扩展,还是完全禁止继承?
类提供状态和行为的封装,接口提供能力契约,继承提供受约束的子类型关系,组合提供对象协作和变化隔离,不可变性提供稳定的值语义与更容易推理的共享方式,record 和 sealed 则把常见的数据边界与层次边界直接写入类型系统。真正的设计质量不在于使用了多少面向对象术语,而在于这些机制是否准确表达了对象的状态、责任和替换条件。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 25 类型与控制流:基本类型、引用、Record、sealed 和模式匹配
- 下一篇:Java 集合框架:List、Set、Map、Queue、迭代器和复杂度
- 延伸:Java 泛型完整基础:类型擦除、通配符、PECS、边界和反射
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论