Java 后端

面向对象与泛型

类与对象、引用与值传递、构造顺序、封装可见性、继承多态、接口与标记接口、内部类、组合与密封类、内聚耦合、泛型边界与类型擦除。

类把状态和行为放在一起,泛型把类型也变成参数。先判断两个类型之间是“是一个”还是“有一个”再决定继承还是组合,然后把允许的类型范围用泛型收口,错误挡在编译期。

一、类与对象:蓝图、状态与行为

类描述对象拥有哪些字段、能做哪些动作;对象是按这个蓝图造出来的具体实例。状态是这一刻的数据,行为是能执行的操作,两者放回同一个对象,相关逻辑才不会散落到各处。

java16 行
final class Fan {
    private final String make;
    private final double radius;
    private boolean on;
    private int speed;

    Fan(String make, double radius) {
        this.make = make;
        this.radius = radius;
    }

    void switchOn() { on = true; }
    void switchOff() { on = false; speed = 0; } // 规则留在对象内部
    void setSpeed(int speed) { this.speed = speed; }
    int speed() { return speed; }
}

switchOff() 顺手把速度归零,这条规则如果交给调用者,就得写 fan.switchOff(); fan.setSpeed(0);——两步之间存在一个“已关但速度还没清”的中间状态。把不变量封装进行为,比暴露 setter 更可靠。

new 两次得到两个不同对象,即使字段完全相同。对象身份与逻辑相等是两个问题,后者由 equals 定义。

java12 行
final class RGB {
    private int r, g, b;

    RGB(int r, int g, int b) {
        if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
            throw new IllegalArgumentException("component must be 0..255");
        }
        this.r = r; this.g = g; this.b = b;
    }

    void invert() { r = 255 - r; g = 255 - g; b = 255 - b; }
}

提示:RGB 这类取值受限的对象应该在构造时就校验。对象从诞生起就合法,后续逻辑才不必反复防御。

二、引用、内存与按值传递

初学时的内存模型可以这样用:方法调用形成栈帧,基本类型局部变量把值直接存在栈帧里,new 出来的对象通常在堆上,引用变量保存的是“能找到这个对象”的引用值。

text7 行
栈帧
int x ──────────────→ 5

堆
RGB 对象
 ↑
引用变量 rgb ────────→ RGB 对象

这是便于理解的模型,JIT 与逃逸分析会让实际存放更复杂。语言层面稳定成立的结论只有两条:基本类型变量保存值,引用变量保存引用值。

由此推出最重要的一条:Java 只有值传递,没有引用传递。 传对象时复制的是那个引用值。

java6 行
static void rename(Person p) { p.name = "Bob"; }   // 改的是双方共同指向的对象
static void replace(Person p) { p = new Person("New"); } // 只重绑方法内的引用副本

Person person = new Person("Alice");
rename(person);   // person.name == "Bob"
replace(person);  // person 仍指向原对象

赋值同样是复制引用,于是出现别名现象:

java4 行
Person a = new Person("Alice");
Person b = a;
b.name = "Bob";
// a.name 也变成 Bob

数组和集合同样如此。两种办法切断别名:传递时复制一份(List.copyOf、数组 copyOf),或者把元素本身设计成不可变。不可变对象能从根上消除这类意外,因为没有可改的内部状态。

警告:== 比较引用(是不是同一个对象),equals 比较内容。别把“能通过引用改到对方”当成“拥有它”。

三、构造器与初始化顺序

构造器负责在对象诞生时建立合法初始状态:没有返回类型,名称与类名完全相同,可以重载。创建时已经知道的必要信息应当通过构造器传入,而不是 new 出来再连调一串 setter。

默认构造器的陷阱:类里没写任何构造器时,编译器会补一个无参构造器;一旦自己写了任意一个构造器,这个无参版本就不再自动提供。

java5 行
class Fan {
    Fan(String make) { }  // 写了带参构造器
}
new Fan();        // 编译错误
new Fan("IBM");   // 正确

确实需要两种创建方式时,得自己把无参构造器补出来。

初始化的执行顺序是固定的,父类永远先于子类:

  1. 分配对象空间,此时字段是语言默认值(数值 0、引用 null);
  2. 执行父类构造链:父类字段初始化器 → 父类实例初始化块 → 父类构造体;
  3. 回到子类,按源码文本顺序执行字段初始化器与实例初始化块;
  4. 执行子类构造体。
java10 行
class Child extends Parent {
    int value = log("子类字段");
    { log("子类实例块"); }

    Child() {
        super();                 // 必须是第一条显式构造调用
        log("子类构造体");
    }
}
// 输出:父类构造体 → 子类字段 → 子类实例块 → 子类构造体

字段初始化器与实例块交错出现时严格按文本顺序执行。构造体里再次赋值会覆盖字段初始化器的值。

this(...) 委托同类的另一个构造器,super(...) 调直接父类构造器。两者都必须是构造器中的第一条显式构造调用,且不能同时出现——对象不能在自身构造完成前执行初始化逻辑,父类状态也必须先建立。

java2 行
Child()            { this(); }  // 委托给无参构造器
Child(int value)   { this(); this.value = value; }

警告:不要在字段初始化器或构造器里调用可重写的实例方法。父类构造期间子类的字段可能还没初始化完成,此刻调用子类重写方法会读到未就绪的状态。

除了默认构造器,另两种值得认识:拷贝构造器接受同类对象用于复制语义,私有构造器用于禁止外部实例化(工具类、单例式写法)。

四、静态成员

static 表示成员属于类而不是某个实例:一份类数据,所有实例共享;静态方法没有隐含的 this,因此不能直接访问实例字段或调用实例方法。

java12 行
final class Player {
    private static int createdCount;
    public static final int MAX_NAME_LENGTH = 40;

    static { createdCount = 0; }  // 类初始化时执行一次

    private final String name;

    Player(String name) { this.name = name; createdCount++; }

    static int createdCount() { return createdCount; }
}

静态块在类初始化时运行,不是每建一个对象就运行一次;类初始化通常发生在第一次主动使用该类时。工具类只有静态方法,不需要实例,应当把构造器设为私有以阻止无意义实例化。

适合静态方法的场景:纯工具方法(Math.sqrt)、工厂方法、操作真正属于类级别的状态、程序入口 main。

警告:静态方法之间是隐藏而非重写,子类的同签名静态方法按编译时类型选择,不参与运行时多态。static final 也不等于深度不可变——static final List<String> ITEMS = new ArrayList<>() 的引用固定了,列表内容照样能改。静态可变状态在多线程下还会互相污染。

五、封装与可见性

封装有三层:把数据与操作组织成职责单元、限制外部直接改内部状态、把校验与状态转换集中到一处。银行账户不该允许 setBalance(-1000) 这种调用,更该提供的是行为:

java19 行
final class BankAccount {
    private long balance;

    BankAccount(long openingBalance) {
        if (openingBalance < 0) {
            throw new IllegalArgumentException("balance cannot be negative");
        }
        this.balance = openingBalance;
    }

    void withdraw(long amount) {
        if (amount <= 0 || amount > balance) {
            throw new IllegalArgumentException("invalid withdrawal: " + amount);
        }
        balance -= amount;
    }

    long balance() { return balance; }
}

访问修饰符从宽到严:

修饰符 可见范围
public 任意可访问该类型的代码
protected 同包代码,以及子类
不写(包访问) 仅同一个包,子包不算
private 仅声明它的类型内部

提示:字段默认 private,公共方法只暴露真正需要的能力,子类扩展点才用 protected。另外,别把 getItems() 直接返回内部可变集合——外部拿到就能破坏内部状态,返回只读视图或防御性副本更安全。访问修饰符防的是合法代码误用,不是安全沙箱。

六、继承

继承表达“是一个”且可以替换的关系:凡是能当 Animal 用的地方,Dog 都要能当用。

java12 行
class Animal {
    protected final String name;
    Animal(String name) { this.name = name; }
    void sound() { System.out.println("……"); }
}

final class Dog extends Animal {
    Dog(String name) { super(name); }

    @Override
    void sound() { System.out.println("汪"); }
}

Java 中类只能直接继承一个类,但可以实现多个接口。父类的 private 成员仍是父类状态的一部分,只是子类不能直接访问。构造子类对象时父类必须先完成初始化,所以子类构造器的第一条是 super(...)。

继承用来复用结构与表达类型关系;仅仅想复用几行代码就引入继承,往往得不偿失:

  • 父类的所有状态与行为都会暴露给子类,包括本不该被继承的;
  • 父类一改,所有子类跟着受影响,这叫脆弱基类;
  • 继承树越深,调用链与初始化顺序越难追踪。

七、多态

多态就是“同一段调用代码,按实际对象执行不同实现”:

java2 行
Animal animal = new Dog();  // 变量的静态类型是 Animal
animal.sound();             // 执行 Dog.sound()

变量类型只是编译期看到的声明,真正决定执行哪个方法的是运行时的实际对象,这叫动态方法分派。因此不要按声明类型猜输出。

java5 行
static void run(GamingConsole game) {  // 声明是接口
    game.up(); game.down(); game.right();
}
run(new MarioGame());  // jump / go into pipe / move forward
run(new ChessGame());  // move piece up / down / right

super.method() 可以在重写里复用父类实现:

java5 行
@Override
void send() {
    super.send();                 // 先做通用逻辑
    System.out.println("已通过邮件发送");
}

类型判断用模式匹配(instanceof 正式定稿于 Java 16),判断与转换一步完成:

java3 行
if (p instanceof Student s) {
    s.setCollege("MyCollege");
}

getClass() != other.getClass() 只承认完全相同的运行时类,other instanceof Person 则也接受子类实例。选哪个取决于领域规则:子类新增字段也应参与相等判断就用 getClass(),子类只是同一类型的扩展就可以用 instanceof。父子两处判定方式不一致会破坏对称性。

八、接口、抽象类与标记接口

接口描述能力或角色,实现由实现类提供;接口中的普通抽象方法隐式 public abstract,字段隐式 public static final(所以接口不能保存实例状态)。

java7 行
interface Flyable {
    void fly();

    default String description() { return "flying object"; }

    static Flyable named(String name) { return () -> System.out.println(name + " flies"); }
}

default 方法的重要价值是向后兼容:往已有接口加抽象方法会迫使所有实现类跟着改,加一个有合理默认行为的 default 方法则不会。static 方法只属于接口本身,不被实现类继承。

抽象类适合“确实是一族类型、且要共享实现或状态”的场景,典型是固定流程的模板:

java6 行
abstract class Recipe {
    final void execute() { prepare(); cook(); cleanUp(); } // 流程固定,步骤可变
    abstract void prepare();
    abstract void cook();
    abstract void cleanUp();
}

标记接口不定义任何方法,只给类型打一个运行时可判别的标签:

java1 行
interface SecureResource { }   // 用 instanceof 决定是否执行额外安全检查

Serializable、RandomAccess 都是标记接口。如果只是编译期元数据而不需要 instanceof,注解通常比标记接口更合适。

需求 选择
描述跨类型能力,一个类可以有多个角色 接口
一族类型共享状态、构造过程或具体算法 抽象类
只是打个类型标签 标记接口

提示:多个接口提供同签名 default 方法会冲突,通常由类中的实现或最具体的接口胜出,无法自动解决时必须显式重写。另外接口从 Java 9 起可以有 private 辅助方法。

九、内部类

定义在另一个类里的类统称嵌套类:不带 static 的叫内部类(成员形式),带 static 的叫静态嵌套类。

java14 行
class Report {
    private int currentRow;

    final class Cursor {                       // 成员内部类
        int current() { return currentRow; }   // 直接访问外部实例状态
    }

    static final class Formatter {             // 静态嵌套类
        static String header(String title) { return "[" + title + "]"; }
    }
}
Report report = new Report();
Report.Cursor cursor = report.new Cursor();   // 内部类需要外部实例
Report.Formatter.header("标题");              // 静态嵌套类直接创建

内部类持有外部实例的隐式引用,因此不能在外部对象之外独立创建;它里面的 this 指内部类自身,外部实例要写 Report.this。静态嵌套类不捕获外部实例,适合作为成组组织但相对独立的辅助类型。

警告:内部类会延长外部对象的生命周期。异步任务、监听器或缓存如果意外保存了内部类实例,可能阻止外部对象被回收。

方法内定义的类叫局部类;没有显式名字、只用一次的可以写成匿名类:

java3 行
Runnable task = new Runnable() {
    @Override public void run() { System.out.println("一次性实现"); }
};

实现单个函数式接口时,Lambda 或方法引用通常更简洁。

十、组合、内聚与耦合

组合表示“有一个”,并把工作委托过去:

java7 行
interface Engine { void start(); }

final class Car {
    private final Engine engine;
    Car(Engine engine) { this.engine = Objects.requireNonNull(engine); }
    void drive() { engine.start(); }
}

Car has an Engine 不能因为想复用 Engine.start() 就写成 Car extends Engine——那会错误地表达“车是一台发动机”,并把发动机的全部行为一并继承过来。

两个衡量维度的分工很清晰:内聚看单个类(职责是否彼此相关),耦合看类之间(依赖对方实现细节的程度)。

低内聚的反例是一个类同时做下载、解析、写库;改进的做法是拆成 InternetDownloader、DataParser、DatabaseStorer,每个类只有一个清晰的变化理由。紧耦合的标志是类内部直接 new 具体实现:

java4 行
final class OrderProcessor {
    private final PaymentMethod payment;
    OrderProcessor(PaymentMethod payment) { this.payment = payment; }
}

依赖通过构造器从外部传入后,换一个实现不必改处理器。接口只有在配合依赖注入时,才真正降低实现耦合。

提示:抽象与封装不是同义词。抽象是“向使用者展示必须知道什么、隐藏怎么做到”,封装是“控制内部数据如何保存与修改”。List.add 隐藏了扩容与容量检查,两者兼有但侧重点不同。

十一、密封类

需要精确控制继承体系、又不想禁止扩展时,用密封类(Java 17):

java3 行
public sealed interface Shape permits Circle, Rectangle, Square {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}

permits 名单之外的类无法实现,每个直接实现类必须显式选择 final(到此终止)、sealed(继续限制下一层)或 non-sealed(重新开放)。permits 名单要一次想全,名单外的继承会在编译期被拦下。

十二、泛型:为什么需要

没有泛型时容易走向两个极端。要么过度僵化:StringList 只能装 String,换成 Integer 就得复制一个类;要么过度灵活:ObjectList 任何类型都能加,但取出时静态类型只是 Object,强转失败要等到运行时才抛 ClassCastException。

泛型同时给出复用、类型安全与更少的强转:

java7 行
static class MyCustomList<T> {
    private final List<T> list = new ArrayList<>();
    public void addElement(T element) { list.add(element); }
    public T get(int index) { return list.get(index); }
}
MyCustomList<String> names = new MyCustomList<>();
// names.addElement(42);  编译期就报错

泛型也可以只作用于单个方法,写法是返回类型之前声明类型参数:

java1 行
static <T> T identity(T value) { return value; }

只有一组方法共享同一类型关系时用泛型类;只有一个操作需要泛型时用泛型方法。

提示:泛型是不变的——List<String> 不能赋给 List<Object>,否则调用方可能写入 Double 破坏原约束。原始类型(List list = new ArrayList())会丢掉类型安全并产生警告,不要用它绕开编译器。

十三、上界与泛型方法

只收数字时写上界 T extends Number,字符串在编译期就被拦下,同时可以直接调用 doubleValue:

java9 行
static class NumberList<T extends Number> {
    private final List<T> list = new ArrayList<>();
    void add(T value) { list.add(value); }
    double sum() {
        double total = 0.0;
        for (T value : list) total += value.doubleValue();  // 因上界而可用
        return total;
    }
}

上界也可以交叉,用来同时要求“是某类型”和“具备某行为”:

java1 行
static <T extends Document & Printable> void printAll(List<T> items) { ... }

界限应当服务于真正要调用的方法,而不只是形式上的限制。

十四、通配符:读用 extends,写用 super

List<Integer> 传不到 List<Number> 位置,正是为了防止调用方写入 Double 破坏约束。通配符把读写方向分开管理,记忆法是 PECS——Producer Extends, Consumer Super:

java9 行
static double sum(List<? extends Number> values) {   // 生产者:只安全地读
    double total = 0.0;
    for (Number n : values) total += n.doubleValue();
    return total;
}
static void addNumbers(List<? super Integer> values) { // 消费者:只安全地写
    values.add(1);
    values.add(2);
}
写法 读出类型 能否写入
List<? extends Number> Number 不能写入具体元素(可删除或写 null)
List<? super Integer> 只能当 Object 可写 Integer
List<Integer> Integer 可读可写

? extends 列表内部仍可删除元素或置 null——禁止的只是添加新的元素。两种方向组合起来就是标准的复制写法:

java3 行
static <T> void copy(List<? super T> destination, List<? extends T> source) {
    for (T value : source) destination.add(value);
}

不要为了“反正不读”就到处加下界通配符,多数普通方法直接用 List<T> 更清晰。

十五、类型擦除的代价

泛型主要由编译器实现,编译后类型参数大部分被抹掉:无界 T 擦除为 Object,有界 T extends Number 擦除为它的上界 Number。所以 Box<String> 与 Box<Integer> 在运行时是同一个 Box 类。

java7 行
System.out.println(new ArrayList<String>().getClass()
        == new ArrayList<Integer>().getClass()); // true

new T();                    // 非法:不能实例化类型参数
new T[10];                  // 非法:不能创建泛型数组
static T data;              // 非法:静态环境不能引用类的类型参数
obj instanceof List<String> // 非法:运行时没有这个类型信息

因此运行时只能写 obj instanceof List<?>。编译器会插入必要的类型转换和桥接方法,保证重写后的多态在擦除前提下依然正确。用 javac -Xlint:unchecked 查堆污染,javap -c -v 可以看到桥接方法与 Signature 属性。

十六、易错点总表

现象 原因 处理
new Fan() 编译失败 写了带参构造器,默认版本不再生成 需要无参时自己补
方法内改字段外部看不到 重新给形参赋值,只换了引用副本 改对象内容,或返回值
别名意外互相影响 复制的是引用 传参时复制一份,或用不可变对象
父类构造期子类字段为空 构造器里调用了可重写方法 避免,交给工厂或显式初始化
工具类被实例化 构造器是 public 私有构造器
子类“改不到”父类行为 同名静态方法是隐藏不是重写 按类型调用,别指望多态
equals 相等但集合找不到 只重写了 equals 未重写 hashCode 两个一起重写
HashMap 行为异常 可变对象做键导致哈希位置改变 键用不可变对象
子类访问 protected 失败 包访问不含子包 调整包划分或提供公开方法
父类一改子类全崩 为复用少量代码而继承 改用组合委托
List<String> 赋给 List<Object> 泛型不变 用通配符或拷贝方法
instanceof List<String> 报错 类型擦除 只能 instanceof List<?>
permits 名单外无法实现 密封类受限 名单一次想全或用 non-sealed

面向对象管的是状态与行为的组织方式——先判“是一个”还是“有一个”,再决定继承、组合还是接口;泛型管的是类型安全的复用——先定读写方向,再套上界或通配符。集合体系在这两套机制之上搭建数据处理的流水线。

← 返回文章