13 类与对象

前置知识:12 方法
本章目标:理解类与对象的本质、构造方法、封装、JavaBean 规范,掌握 record 记录类这一「最像 C struct」的语法,弄清字段默认值与初始化顺序,初步认识 GC 与 package 组织。学完本章你就能用面向对象的方式组织程序了。

概述

C 的 struct 只是一堆字段的内存布局,行为要靠独立的函数;C 程序员常用「struct + 一组以结构体指针为首参的函数」模拟面向对象。Java 把数据和操作数据的方法绑定在一起形成类(class),用类生成对象(object)。OOP 三大特性在本章和后两章展开:

mindmap
  root((OOP 三大特性))
    封装
      private 隐藏字段
      getter/setter 受控访问
      本章
    继承
      extends 复用代码
      下一章
    多态
      父类引用指子类对象
      接口与抽象类配合
      后两章

class 与 new

public class ClassBasic {
    public static void main(String[] args) {
        // 声明变量 + new 创建对象:两步
        Student stu;                    // 此时只是引用,不指向任何对象
        stu = new Student("张三", 20);  // 在堆上分配对象,返回引用
 
        Student stu2 = new Student("李四", 22);   // 常见的一步写法
        System.out.println(stu.name + " " + stu.age);
        stu.introduce();                // 调用对象的方法
 
        // 对象不能像 C struct 一样整体赋值拷贝:
        Student stu3 = stu2;            // 只是复制了引用!两个名字同一个对象
        stu3.age = 99;
        System.out.println(stu2.age);   // 99 —— 被"连带"修改了
    }
}
 
class Student {
    String name;
    int age;
 
    Student(String name, int age) {     // 构造方法,下一节详讲
        this.name = name;
        this.age = age;
    }
 
    void introduce() {
        System.out.println("我是 " + name + ",今年 " + age + " 岁");
    }
}

C 中用函数指针在 struct 里挂方法的写法,Java 直接内置了:

对比项C 手工模拟Java 内置
数据struct Student字段
行为函数指针成员或独立函数方法
「this」显式传 Student* self隐式 this 引用
创建malloc(sizeof(struct Student))new Student(...)
初始化手写 init 函数,忘调即脏数据构造方法强制初始化
释放free(p)GC 自动回收

字段 vs 局部变量默认值

这是高频考点:字段有默认值,局部变量必须先赋值再用

public class DefaultValue {
    static boolean flag;       // 类的字段
    static int count;
    static String text;
 
    public static void main(String[] args) {
        System.out.println(flag);   // false
        System.out.println(count);  // 0
        System.out.println(text);   // null —— 引用类型默认 null,不是空串!
 
        // int x;  System.out.println(x);   // 编译错误!局部变量没有默认值
        // C 中局部变量不初始化是未定义行为(读垃圾值),Java 直接编译报错,
        // 数组和对象的字段例外——它们由 JVM 清零初始化。
    }
}
类型默认值C 局部变量的值
byte/short/int/long0未定义(垃圾值)
float/double0.0未定义
char’\u0000’未定义
booleanfalse无对应类型
引用类型null野指针

构造方法、重载与 this

构造方法与类同名、无返回类型(连 void 都不能写),在 new 时自动调用。

public class ConstructorDemo {
    public static void main(String[] args) {
        Point p1 = new Point();          // 调无参构造 (0, 0)
        Point p2 = new Point(5);         // 调一参构造 (5, 5)
        Point p3 = new Point(3, 4);      // 调两参构造 (3, 4)
        Point p4 = new Point(p3);        // 拷贝构造,模仿 C 的结构体按值复制
        System.out.println(p3.x + "," + p3.y);   // 3,4
        p4.x = 100;
        System.out.println(p3.x);        // 3 —— 拷贝互不影响
    }
}
 
class Point {
    int x;
    int y;
 
    Point() {
        this(0, 0);          // this(...) 调用另一个构造方法,必须是第一行
    }
 
    Point(int x) {
        this(x, x);          // 复用两参构造,减少重复
    }
 
    Point(int x, int y) {
        this.x = x;          // 参数名遮蔽字段名时,必须用 this.x 区分
        this.y = y;
    }
 
    // 拷贝构造:C 结构体赋值是按位拷贝,Java 对象需要自己实现
    Point(Point other) {
        this.x = other.x;
        this.y = other.y;
    }
}

要点:

  • 如果你一个构造方法都不写,编译器送你一个隐式无参构造
  • 但只要你写了任何带参构造,隐式无参构造就消失——再想 new Point() 必须显式补上
  • this(...) 只能在构造方法第一行,且一个构造只能调用一次

封装:private + getter/setter

字段直接暴露(如上面 stu.age = 99)无法做合法性校验。封装就是把字段设为 private,通过公开方法受控访问。

public class EncapsulationDemo {
    public static void main(String[] args) {
        BankAccount acc = new BankAccount();
        acc.deposit(1000);
        acc.withdraw(300);
        System.out.println(acc.getBalance());   // 700
 
        // acc.balance = -9999;     // 编译错误:balance 是 private
        acc.deposit(-50);           // 存入负数?被拦截了,余额不变
        System.out.println(acc.getBalance());   // 仍是 700
    }
}
 
class BankAccount {
    private double balance;     // 外部不可直接访问
 
    public void deposit(double amount) {
        if (amount <= 0) {
            return;             // setter/setter 是加业务规则的关口
        }
        balance += amount;
    }
 
    public boolean withdraw(double amount) {
        if (amount <= 0 || amount > balance) {
            return false;
        }
        balance -= amount;
        return true;
    }
 
    public double getBalance() {
        return balance;         // 只读访问
    }
}

JavaBean 规范

JavaBean 是 Java 生态约定俗成的数据类规范,框架(Spring、MyBatis 等)靠反射按这套命名找属性:

  • 类是 public、有 public 无参构造
  • 字段全部 private
  • 通过 getXxx() / setXxx() 访问(boolean 用 isXxx()
  • 实现 Serializable(见 文件 IO
public class UserBean implements java.io.Serializable {
    @java.io.Serial
    private static final long serialVersionUID = 1L;
 
    private String username;
    private boolean active;
 
    public UserBean() { }                       // 规范要求无参构造
 
    public String getUsername() {               // 读方法 get 前缀
        return username;
    }
 
    public void setUsername(String username) {  // 写方法 set 前缀
        this.username = username;
    }
 
    public boolean isActive() {                 // boolean 用 is 前缀
        return active;
    }
 
    public void setActive(boolean active) {
        this.active = active;
    }
}

record 记录类:最像 C struct 的东西

Java 16 正式引入 record。它就是「纯数据载体」的语法糖——一行声明自动生成构造器、getter、equals、hashCode、toString,字段全部 final(不可变)。这是 Java 里最接近 C struct 按值语义的东西:

// 声明一行搞定:自动有 Point(int x, int y) 构造器和 x() y() 访问器
record PointRecord(int x, int y) { }
 
public class RecordDemo {
    public static void main(String[] args) {
        PointRecord p = new PointRecord(3, 4);
 
        System.out.println(p.x());        // 3 —— 访问器没有 get 前缀
        System.out.println(p);            // PointRecord[x=3, y=4] 自动 toString
 
        // 自动 equals:按所有字段的值比较
        System.out.println(p.equals(new PointRecord(3, 4)));   // true
 
        // p.x = 10;      // 编译错误!record 字段是 final 不可变
        // 要"修改"只能造一个新的 —— 类似 C 中结构体整体赋值出新副本:
        PointRecord moved = new PointRecord(p.x() + 1, p.y());
    }
}
对比项C structJava record
声明成本极低同样极低(一行)
可变性随意改字段全部 final
相等判断手写 memcmp 或逐字段自动生成按值 equals
赋值/传参按值拷贝仍是引用传递,但不可变所以安全

什么时候用 record?坐标、金额、配置项等「不可变数据」。需要大量可变状态的业务实体仍用传统 JavaBean。

static 成员

static 成员属于类本身,被该类所有对象共享,通过类名访问:

public class StaticDemo {
    public static void main(String[] args) {
        Counter.increment();
        Counter.increment();
        Counter.increment();
        System.out.println(Counter.getTotal());       // 3
        System.out.println(Counter.MAX_LIMIT);        // 常量直接类名访问
        new Counter().print();                        // 实例方法内部读 static 也合法
    }
}
 
class Counter {
    static final int MAX_LIMIT = 1000;   // static final = 类级常量
    private static int total;            // 所有实例共享一份
 
    static void increment() {            // 静态计数场景不需要 this
        total++;
    }
 
    static int getTotal() {
        return total;
    }
 
    void print() {
        // 实例方法可以访问 static 成员,反过来不行
        System.out.println("当前总数 " + total);
    }
}

记忆口诀:static 方法不能访问实例成员——因为不经过对象调用,根本没有 this 可用。

初始化顺序

一个类被首次使用时发生的事,顺序固定:

  1. 父类静态字段与静态代码块(仅首次加载类时执行一次)
  2. 子类静态字段与静态代码块
  3. 每次 new 时:父类实例字段与实例代码块、父类构造方法
  4. 子类实例字段与实例代码块、子类构造方法
flowchart TD
    A["new Child()"] --> B["Parent 静态初始化<br/>(整个 JVM 生命周期只执行一次)"]
    B --> C["Child 静态初始化<br/>(同样只执行一次)"]
    C --> D["Parent 实例字段 + 实例代码块"]
    D --> E["Parent 构造方法体"]
    E --> F["Child 实例字段 + 实例代码块"]
    F --> G["Child 构造方法体"]
    H["第二次 new Child()"] -->|"跳过静态部分"| F
public class InitOrder {
    public static void main(String[] args) {
        System.out.println("--- 第一次 new ---");
        new Child();
        System.out.println("--- 第二次 new ---");
        new Child();
    }
}
 
class Parent {
    static { System.out.println("1. Parent 静态块"); }
    { System.out.println("3. Parent 实例块"); }     // 实例代码块,每次 new 都执行
 
    Parent() {
        System.out.println("4. Parent 构造方法");
    }
}
 
class Child extends Parent {
    static int s = init("2. Child 静态字段");
    int i = init("5. Child 实例字段");
 
    static int init(String msg) {
        System.out.println(msg);
        return 0;
    }
 
    Child() {
        System.out.println("6. Child 构造方法");
    }
}

堆上分配与 GC 初识

C 程序员最关心的两个问题:

  • 对象在哪分配? C 中栈还是堆由你选(局部变量 vs malloc);Java 对象一律在堆上分配,变量里只存引用。JIT 的逃逸分析有时会把未逃逸的对象优化成栈上标量替换,但那是透明优化,语法层面无感知。
  • 谁负责释放? C 用 free,忘了就内存泄漏;Java 由垃圾回收器自动回收不可达对象。你唯一要做的是不再持有引用
public class GcFirstLook {
    public static void main(String[] args) {
        makeGarbage();
        System.out.println("makeGarbage 已返回,里面的对象已不可达");
 
        System.gc();   // 只是"建议",GC 时机完全由 JVM 决定
        // 没有 free,没有 delete,也没有智能指针——这就是 Java
    }
 
    static void makeGarbage() {
        for (int i = 0; i < 1000000; i++) {
            new String("临时对象 " + i);   // 创建后立刻失去引用,等待 GC 回收
        }
        // 方法返回后局部引用出栈,这批对象全部变成"垃圾"
    }
}

深挖 GC 分代算法、各种回收器,见 JVM 内存模型

package 与 import

package 是类的命名空间和目录组织方式,解决 C 中「头文件全局命名空间污染」问题:

// 文件必须放在 com/rootstack/demo/ 目录下,包名与目录严格对应
package com.rootstack.demo;
 
// import 引入其他包的类;java.lang.* 默认自动导入,所以 System 不用导
import java.util.ArrayList;
import java.util.List;
// import java.sql.Date;   // 若同时要 util.Date 就得全限定名二选一
 
public class PackageDemo {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();          // import 过的简短写法
        java.util.Date d1 = new java.util.Date();       // 全限定名避免歧义
        System.out.println(list.size() + " " + d1.getTime());
    }
}

包与类的关系图

下图展示了包、类、继承、访问权限之间的完整关系:

图解

  • 包是类的容器com.example.hr 包里有 Employee(父类)和 Salary(子类),以及同包其他类
  • 继承关系Salary extends EmployeeEmployee extends Object(隐式)
  • 访问权限由包+继承共同决定
    • 同包:可以访问 publicprotected、默认(包私有)成员
    • 跨包非子类:只能访问 public 成员
    • 跨包子类:可以访问 publicprotected 成员,但父类 private 成员不能直接访问(只能通过 getter/setter 或 protected 方法间接访问)

访问权限详解

Java 有四种访问修饰符,控制类的成员(字段、方法、构造方法)的可见范围:

修饰符同类同包不同包(子类)不同包(非子类)
public
protected
默认(不写)
private

记忆口诀:从上到下范围递减——public 全开,protected 开给子类,默认开给同包,private 只给自己。

// 演示四种访问权限
package com.example.hr;
 
public class Employee {
    public String name;          // 任何地方都能访问
    protected int age;           // 同包 + 不同包子类能访问
    double salary;               // 默认:只有同包能访问
    private String password;     // 只有本类能访问
 
    public void publicMethod() { }
    protected void protectedMethod() { }
    void defaultMethod() { }     // 默认访问
    private void privateMethod() { }
}
对比项CJava
组织单位文件 + 头文件包(目录层级)
冲突处理static 函数限文件内 / 命名前缀包名隔离 + import 选择
可见性控制无(链接器符号可见性除外)public/private/protected/默认

编译运行带包名的类:

javac -d out com/rootstack/demo/PackageDemo.java   # -d 让 class 文件按包目录落位
java -cp out com.rootstack.demo.PackageDemo        # 运行时用完整类名

jar 打包初步

jar 就是把一堆 class 和资源打成的 zip 包(对应 C 世界静态库 .a 的分发角色):

# 打包:-c create -v 显示过程 -f 输出名 -e 指定入口 main 类
jar --create --verbose --file myapp.jar --main-class com.rootstack.demo.PackageDemo -C out .
 
# 直接运行 jar(manifest 里记录了 Main-Class)
java -jar myapp.jar
 
# 查看内容
jar --list --file myapp.jar

依赖管理与 Maven/Gradle 属于 工程化篇 内容,这里只需知道 jar 的本质即可。


深入:Java 对象模型的底层原理

为什么 Java 用引用语义而非值语义

Java 中 Student stu = new Student(...)stu 存的不是对象本身,而是指向堆上对象的引用(一个 4/8 字节的地址)。为什么 Java 不像 C 那样让对象按值拷贝?

原因一:避免昂贵的大对象拷贝。 C 结构体赋值是按位 memcpy,如果 struct 里嵌了一个 1MB 的 char 数组,赋值就是 1MB 的内存搬运。Java 把对象放堆上,变量只存一个引用(8 字节),赋值 stu3 = stu2 只是复制指针,开销恒定。

原因二:支持运行时多态。 如果 Animal a = new Dog() 里 a 装的是整个 Dog 对象(值拷贝),那么 a 的静态类型就锁死了 Dog 的内存布局,无法在运行时根据实际类型查 vtable。引用让所有对象以统一的”指针”形式存在于堆上,变量的类型只约束你能调哪些方法,不限制对象实际大小——这是多态的物理基础。

原因三:让 GC 可控。 引用模型下,GC 只需遍历可达性图(从 GC Root 沿引用链搜索),就能精确判定哪些对象该回收。如果对象按值存储在栈帧里,栈帧弹出时对象消失,GC 无法介入管理堆外的生命周期。

对象在 JVM 堆上的内存布局

new Student("张三", 20) 在堆上分配的内存布局如下:

┌─────────────────────────────────┐
│        对象头 (Object Header)    │
│  ┌─ Mark Word (64 bit)          │  ← hashCode、GC 分代年龄、锁状态
│  ├─ Klass Pointer (32/64 bit)  │  ← 指向类元数据 Student.class
│  └─ (数组长度, 仅数组对象有)     │
├─────────────────────────────────┤
│        实例数据 (Instance Data)  │
│  String name → 引用(8B)         │  ← 指向堆上另一个 String 对象
│  int age    = 20 (4B)           │
│  (对齐填充至 8 的倍数)           │
└─────────────────────────────────┘

关键字段解读:

  • Mark Word:64 位 JVM 上占 8 字节。无锁时存 hashCode + GC 年龄 + 偏向锁标记;加 synchronized 时存指向 Lock Record 的指针(轻量级锁)或 Monitor 地址(重量级锁)。这就是 Java 对象天然自带 hashCode 的物理原因——不需要额外字段,对象头里就有。
  • Klass Pointer:指向方法区(Metaspace)里的类元数据。a.makeSound() 调用时,JVM 先通过这个指针找到 Student.class,再从类元数据里查虚方法表。这就是多态的底层实现路径。

为什么字段排列有顺序?HotSpot 默认按 long/double → int/short/char → byte/boolean → 引用 的顺序排列,并按 8 字节对齐。这不是随意的——宽类型先放减少对齐空洞,整块内存更紧凑,CPU 缓存行命中率更高。

构造方法链与 super() 的必然性

class Animal {
    String name;
    Animal(String name) { this.name = name; }  // 父类构造
}
 
class Dog extends Animal {
    String breed;
    Dog(String name, String breed) {
        super(name);       // 必须第一行:先把父类部分初始化好
        this.breed = breed;
    }
}

为什么 super() 必须写在构造方法第一行?

安全约束:子类构造方法可能依赖父类字段。 如果父类字段还没初始化,子类在 super() 之前访问 this.name 会拿到 null 或 0。Java 强制你先把父类部分建好,再扩展子类部分——这是分层初始化原则的编码保证。

底层实现:this 指针复用。 JVM 在执行子类构造方法时,this 引用从头到尾指向的是同一个堆对象。子类构造方法的字节码里,前几行是 aload_0; invokespecial <init>(调父类构造),之后才开始设置子类字段。如果允许在 super() 之前访问子类字段,这些字段还未被初始化(父类构造还没设置它们依赖的值),会出现逻辑错误但编译器无法检测——所以 Java 用语法约束来规避。

隐式 super() 的存在。 如果你不写 super(...) 也不写 this(...),编译器会自动插入一个无参 super() 调用。这就是为什么父类必须有无参构造——否则编译报错。反过来,如果父类只定义了带参构造(无参构造消失),子类不写 super(具体值) 就无法编译通过,这其实是在强制你显式初始化父类

hashCode() 与 equals() 契约

import java.util.Objects;
 
class Money {
    private final long amount;
    private final String currency;
 
    Money(long amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money m)) return false;
        return amount == m.amount && Objects.equals(currency, m.currency);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);   // equals 相等则 hashCode 必须相等
    }
}

三条铁律(Contract):

  1. 自反性a.equals(a) 必须 true(== 天然满足)
  2. 对称性a.equals(b) 为 true 则 b.equals(a) 也为 true
  3. 一致性:多次调 equals 结果不变(只要字段没改)
  4. hashCode 契约a.equals(b) 为 true → a.hashCode() == b.hashCode() 必须成立;反过来不要求(哈希碰撞允许 hashCode 相等但 equals 不等)

为什么必须同时重写 hashCode? 因为 HashMap、HashSet 等哈希集合的查找流程是:先算 hashCode 定位桶 → 再用 equals 比较桶内元素。如果两个 equals 相等的对象 hashCode 不同,HashMap 会把它们放进不同的桶——你用 new Money(100, “CNY”) 做 key 存了值,再用另一个 new Money(100, “CNY”) 去 get,由于 hashCode 不同,HashMap 在错误的桶里找不到它,返回 null。

为什么 HashMap 查找是 O(1)? 因为 hashCode 通过散列函数直接定位桶的索引(hash & (n-1)),跳过了逐个比较的 O(n) 遍历。这不是”魔法”,是以空间换时间 + 概率论保证——只要散列均匀,每个桶的元素数量趋近常数。

toString() 的工程意义

@Override
public String toString() {
    return "Money{amount=" + amount + ", currency='" + currency + "'}";
}

不重写 toString 会输出 Money@1b6d3586——这是 类名@十六进制hashCode,对调试毫无帮助。toString 的工程价值:

  • 日志可读性log.info("处理金额: {}", money) 直接打印有意义的内容
  • 调试效率:断点时变量视图直接显示对象状态
  • JSON 序列化:Jackson/Gson 默认行为与 toString 策略相关
  • 断言失败信息:JUnit 的 assertEquals(expected, actual) 失败时调 toString 展示差异

为什么 Java 的对象模型整体设计是这样

Java 对象模型的核心决策可以追溯到三个约束:

  1. 安全优先:引用语义 + 运行时类型信息 + GC 管理 → 消除了 C/C++ 中 90% 的内存安全问题(野指针、double free、缓冲区溢出)
  2. 向后兼容:JVM 需要能加载不同编译器版本产生的 class 文件 → 对象布局必须是固定且可预测的
  3. 平台无关:对象头的设计(Mark Word + Klass Pointer)是跨平台的,32 位 JVM 和 64 位 JVM 只是字段宽度不同,语义一致

这就是为什么 Java 的对象模型看起来”不够灵活”(没有多继承、没有值类型对象、字段布局不可自定义),但它是在安全性、兼容性和性能之间取了工程最优解


本章要点回顾

  • 类 = 数据 + 行为的绑定;new 在堆上创建对象并返回引用
  • 字段有默认值,局部变量没初始化就编译报错——比 C 的垃圾值安全得多
  • 构造方法负责强制初始化,支持重载与 this(...) 链式复用
  • 封装:private 字段 + getter/setter 是加校验规则的关口;JavaBean 规范是框架世界的通用语言
  • record 是不可变纯数据类,一行顶五件套,语义上最接近 C struct
  • static 属于类不属于实例;初始化顺序为静态先于实例、父类先于子类
  • 对象在堆上分配,GC 免去手工 free;package 解决命名冲突,jar 用于打包分发
  • 对象内存布局 = 对象头(Mark Word + Klass Pointer) + 实例数据 + 对齐填充
  • 引用语义是多态、GC、安全性的物理基础,不是”偷懒的设计”


练习

题号题目链接知识点
P1021邮票面值设计https://www.luogu.com.cn/problem/P1021类设计、封装