跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

前面已经见过 HealthController、HealthService 和 HealthStatus。它们负责的事情不同,但都建立在 Java 的类与对象之上。这一章先不讨论 Spring 如何管理对象,集中讲清楚普通 class 的组成、对象的创建和使用方式。

为了避免改动当前心跳接口,下面使用一个独立的 HealthReport 类练习。它表示一次心跳检测结果,保存服务状态和服务名称。

1. 类

类(class)是一种自定义类型,用来描述对象可以保存哪些状态、能够执行哪些操作。对象则是根据这个类型创建出来的具体实例。

一个普通 Java 源文件可以包含包声明、导入声明和类型声明:

src/main/java/cn/usehook/spring/basics/HealthReport.java
1
package cn.usehook.spring.basics;
2
3
import java.util.Objects;
4
5
public class HealthReport {
6
// 字段、构造方法和普通方法写在类体中
7
}

类声明可以拆成下面几个部分:

class-syntax.txt
1
访问修饰符 其他修饰符 class 类名 {
2
类成员
3
}
代码含义
public其他包中的代码也可以访问这个类
class声明一个类
HealthReport类名,通常使用大驼峰命名
{ ... }类体,保存这个类的成员

一个类中常见的成员包括:

成员作用
字段保存对象状态或类级共享状态
构造方法初始化新对象
实例方法操作某一个对象的状态
静态方法提供属于类本身的操作
嵌套类型在类内部声明其他类、接口、枚举或 record

本章只展开最常用的字段、构造方法和方法。嵌套类型需要先理解更多类型关系,可以留到后面再学习。

HealthReport 是 public 顶级类,因此源文件名必须是 HealthReport.java。一个源文件最多只能声明一个 public 顶级类型,还可以包含其他包级可见的顶级类型,但实际项目通常让一个源文件只承担一个主要类型。

这里只完成了类的定义,程序还没有创建任何 HealthReport 对象。类是类型说明,对象才是运行期间真正保存数据的实例。

2. 字段与封装

字段是声明在类体中的变量。每个 HealthReport 对象都需要保存自己的 status 和 service:

src/main/java/cn/usehook/spring/basics/HealthReport.java
01
package cn.usehook.spring.basics;
02
03
public class HealthReport {
04
05
private final String status;
06
private final String service;
07
08
public HealthReport(String status, String service) {
09
this.status = status;
10
this.service = service;
11
}
12
13
public String getStatus() {
14
return status;
15
}
16
17
public String getService() {
18
return service;
19
}
20
}

以 private final String status 为例:

部分含义
private只能在 HealthReport 类内部直接访问
final每个对象中的这个字段只能完成一次赋值
String字段的数据类型
status字段名称,使用小驼峰命名

字段通常保持 private,外部代码通过方法读取或修改它们。这种把内部状态和具体实现隐藏在类中、只开放必要操作的方式称为封装。

getStatus() 和 getService() 是读取字段的方法,通常称为 getter。这个类没有提供 setter,构造完成后也不能重新给 final 字段赋值。因为 String 本身不可变,HealthReport 的两个状态不会在创建后发生变化。

需要特别区分字段和局部变量:

对比项实例字段局部变量
声明位置类体中、方法外方法或代码块内部
所属范围每个对象拥有一份只属于本次方法调用
默认值引用为 null、整数为 0、布尔值为 false没有可直接读取的默认值,使用前必须赋值
生命周期通常随对象存在执行离开代码块后结束

空白 final 字段必须在字段声明处或每个构造方法中完成赋值。只声明两个 final 字段而不提供初始化代码,类无法通过编译。

封装并不意味着每个字段都必须机械地生成 getter 和 setter。只读对象可以只有 getter;需要修改状态时,也可以提供表达业务含义的方法,并在方法中检查新值,而不是无条件开放所有字段。

3. 构造方法

构造方法在 new 创建对象时执行,负责让对象从一开始就处于有效状态。它的名称必须与类名相同,并且不声明返回类型,连 void 也不能写。

下面为 HealthReport 提供两个构造方法:

src/main/java/cn/usehook/spring/basics/HealthReport.java
01
package cn.usehook.spring.basics;
02
03
public class HealthReport {
04
05
private final String status;
06
private final String service;
07
08
public HealthReport(String service) {
09
this("UP", service);
10
}
11
12
public HealthReport(String status, String service) {
13
if (status == null || status.isBlank()) {
14
throw new IllegalArgumentException("status must not be blank");
15
}
16
if (service == null || service.isBlank()) {
17
throw new IllegalArgumentException("service must not be blank");
18
}
19
20
this.status = status;
21
this.service = service;
22
}
23
}

同一个类可以声明多个参数列表不同的构造方法,这称为构造方法重载:

src/main/java/cn/usehook/spring/basics/ClassDemo.java
1
HealthReport spring = new HealthReport("spring");
2
HealthReport database = new HealthReport("DOWN", "postgresql");

第一个构造方法只有 service 参数,通过 this("UP", service) 调用当前类中的第二个构造方法,统一完成检查与字段赋值。构造方法中的 this(...) 必须是第一条语句,避免一个对象被重复或不完整地初始化。

在第二个构造方法中,参数和字段名称相同:

HealthReport.java
1
this.status = status;

左侧的 this.status 表示当前对象的字段,右侧的 status 表示构造方法参数。这里的 this 指向当前正在初始化的对象。

如果一个类没有声明任何构造方法,编译器会提供一个无参数构造方法。一旦自行声明了任意构造方法,编译器就不再自动提供无参数版本。因此,上面的类不能使用 new HealthReport(),除非再显式声明一个无参数构造方法。

无参数构造方法并不是所有 Java 类的硬性要求。对象必须具备哪些数据,应由类自己的有效状态决定。这里的心跳结果必须具有服务名称,所以强制调用方传入 service 比提供一个内容不完整的无参数对象更合理。

4. 方法与静态成员

实例方法描述具体对象能够执行的操作。下面的方法会读取当前对象的字段:

src/main/java/cn/usehook/spring/basics/HealthReport.java
01
public boolean isHealthy() {
02
return "UP".equals(status);
03
}
04
05
public String describe() {
06
return describe("health");
07
}
08
09
public String describe(String prefix) {
10
return prefix + ": status=" + status + ", service=" + service;
11
}

以 isHealthy() 为例,一个方法由访问修饰符、返回类型、方法名、参数列表和方法体组成:

代码含义
public允许其他类调用
boolean返回值类型
isHealthy方法名
()参数列表,这里没有参数
{ ... }方法体

return 返回的值必须符合方法声明的返回类型。没有返回值的方法使用 void。参数是调用方交给方法的输入,方法内部还可以声明只在本次调用中使用的局部变量。

两个 describe 方法名称相同,但参数列表不同,这称为方法重载。调用 describe() 时使用无参数版本,调用 describe("database") 时使用带 String 参数的版本。仅修改返回类型不能形成合法重载,因为调用表达式无法只根据返回类型判断应该执行哪个方法。

没有 static 修饰的方法属于对象,需要通过对象引用调用:

src/main/java/cn/usehook/spring/basics/ClassDemo.java
1
HealthReport report = new HealthReport("spring");
2
3
boolean healthy = report.isHealthy();
4
String description = report.describe();

静态成员属于类本身,不会在每个对象中各保存一份。下面添加一个类常量和一个静态工厂方法:

src/main/java/cn/usehook/spring/basics/HealthReport.java
1
public static final String STATUS_UP = "UP";
2
3
public static HealthReport up(String service) {
4
return new HealthReport(STATUS_UP, service);
5
}

调用静态成员时使用类名:

src/main/java/cn/usehook/spring/basics/ClassDemo.java
1
HealthReport report = HealthReport.up("spring");
2
System.out.println(HealthReport.STATUS_UP);

static final 字段常用来声明类级常量,名称通常全部大写并使用下划线分隔。静态方法执行时没有当前对象,也就没有 this,所以不能直接访问 status、service 等实例字段;它只能使用自己的参数、局部变量和其他静态成员,或者先获得一个对象引用。

静态工厂方法并不保证复用同一个对象。上面的 up() 每调用一次都会执行一次 new HealthReport(...),所以每次仍然会创建新的对象。

5. 对象与引用

定义类之后,使用 new 创建对象:

src/main/java/cn/usehook/spring/basics/ClassDemo.java
01
package cn.usehook.spring.basics;
02
03
public class ClassDemo {
04
05
public static void main(String[] args) {
06
HealthReport spring = HealthReport.up("spring");
07
HealthReport database = new HealthReport("DOWN", "postgresql");
08
09
System.out.println(spring.describe());
10
System.out.println(database.describe("database"));
11
}
12
}

下面这行代码包含变量声明和对象创建两个动作:

ClassDemo.java
1
HealthReport spring = new HealthReport("UP", "spring");
  • 左侧声明一个 HealthReport 类型的引用变量 spring。
  • 右侧创建一个 HealthReport 对象,并把对象引用赋给 spring。

变量与对象不是同一个概念。多个变量可以引用同一个对象:

ClassDemo.java
1
HealthReport first = HealthReport.up("spring");
2
HealthReport second = first;
3
4
System.out.println(first == second); // true

这里只创建了一个对象,然后把同一个引用值赋给两个变量。通过 first 和 second 访问到的是同一个对象。

Java 的参数传递始终是值传递。把对象变量传给方法时,复制的是引用值,而不是复制整个对象:

src/main/java/cn/usehook/spring/basics/ClassDemo.java
01
private static void replace(HealthReport report) {
02
report = HealthReport.up("postgresql");
03
}
04
05
public static void main(String[] args) {
06
HealthReport report = HealthReport.up("spring");
07
replace(report);
08
09
System.out.println(report.getService()); // spring
10
}

replace() 只能让自己的参数变量改为引用另一个对象,不能替换调用方变量 report 中保存的引用。如果对象本身允许修改,方法仍然可以通过复制得到的引用修改同一个对象的内部状态。

引用变量还可能保存 null,表示当前没有引用任何对象。对 null 调用实例方法会抛出 NullPointerException。如果一个对象不再能通过任何引用访问,它会成为垃圾回收器可以回收的对象,但具体回收时间由 JVM 决定,程序不能假设它会立即被清理。

引用变量本身也可以声明为 final:

ClassDemo.java
1
final HealthReport report = HealthReport.up("spring");

这里的 final 表示变量 report 不能再指向另一个对象。它和类中 final 字段的含义相似,限制的是再次赋值;它并不会自动让所引用的对象变成不可变对象。

6. 相等性与 Object

所有普通 Java 对象都可以使用 Object 定义的方法,其中最常见的是 equals()、hashCode() 和 toString()。

先创建两个字段值相同但彼此独立的对象:

src/main/java/cn/usehook/spring/basics/ClassDemo.java
1
HealthReport first = HealthReport.up("spring");
2
HealthReport second = HealthReport.up("spring");
3
4
System.out.println(first == second); // false
5
System.out.println(first.equals(second)); // 取决于类是否重写 equals

对于引用类型,== 比较两个变量是否引用同一个对象。两个 new 表达式创建了两个对象,所以 first == second 为 false,即使它们保存相同的数据。

如果类没有重写 equals(),从 Object 继承的默认实现同样按照对象身份比较。要让两个状态和服务都相同的报告被视为相等,需要根据字段重写 equals():

src/main/java/cn/usehook/spring/basics/HealthReport.java
01
@Override
02
public boolean equals(Object other) {
03
if (this == other) {
04
return true;
05
}
06
if (!(other instanceof HealthReport report)) {
07
return false;
08
}
09
return status.equals(report.status) && service.equals(report.service);
10
}

this == other 先处理两个引用指向同一对象的情况。instanceof 检查另一个对象是否为 HealthReport,并把它赋给局部变量 report,然后逐个比较参与对象身份判断的字段。

重写 equals() 时必须同时重写 hashCode():

src/main/java/cn/usehook/spring/basics/HealthReport.java
1
@Override
2
public int hashCode() {
3
return Objects.hash(status, service);
4
}

相等对象必须产生相同的哈希值,否则把对象放入 HashMap、HashSet 等基于哈希的数据结构后会出现不一致的查找结果。反过来,哈希值相同不代表两个对象一定相等。

toString() 用来提供便于阅读的对象文本,打印对象、记录日志或在调试器中查看变量时会被调用:

src/main/java/cn/usehook/spring/basics/HealthReport.java
1
@Override
2
public String toString() {
3
return "HealthReport{status='%s', service='%s'}".formatted(status, service);
4
}

toString() 适合调试和日志,不应该把包含密码、令牌等敏感数据的字段直接拼入返回值。

7. 完整类与项目对应

把前面的字段、构造方法、实例方法、静态成员和 Object 方法合在一起,可以得到一个完整的不可变类:

src/main/java/cn/usehook/spring/basics/HealthReport.java
01
package cn.usehook.spring.basics;
02
03
import java.util.Objects;
04
05
public final class HealthReport {
06
07
public static final String STATUS_UP = "UP";
08
09
private final String status;
10
private final String service;
11
12
public HealthReport(String service) {
13
this(STATUS_UP, service);
14
}
15
16
public HealthReport(String status, String service) {
17
this.status = requireText(status, "status");
18
this.service = requireText(service, "service");
19
}
20
21
public static HealthReport up(String service) {
22
return new HealthReport(STATUS_UP, service);
23
}
24
25
public String getStatus() {
26
return status;
27
}
28
29
public String getService() {
30
return service;
31
}
32
33
public boolean isHealthy() {
34
return STATUS_UP.equals(status);
35
}
36
37
public String describe() {
38
return describe("health");
39
}
40
41
public String describe(String prefix) {
42
return prefix + ": status=" + status + ", service=" + service;
43
}
44
45
private static String requireText(String value, String fieldName) {
46
if (value == null || value.isBlank()) {
47
throw new IllegalArgumentException(fieldName + " must not be blank");
48
}
49
return value;
50
}
51
52
@Override
53
public boolean equals(Object other) {
54
if (this == other) {
55
return true;
56
}
57
if (!(other instanceof HealthReport report)) {
58
return false;
59
}
60
return status.equals(report.status) && service.equals(report.service);
61
}
62
63
@Override
64
public int hashCode() {
65
return Objects.hash(status, service);
66
}
67
68
@Override
69
public String toString() {
70
return "HealthReport{status='%s', service='%s'}".formatted(status, service);
71
}
72
}

类上的 final 表示不能再声明它的子类。这里把它与私有 final 字段、构造参数检查以及不提供 setter 结合起来,使 HealthReport 创建后保持不变。不可变对象更容易理解和共享,但不是所有类都必须设计成不可变类型;需要累积状态的对象可以提供受控的修改方法。

运行下面的代码可以观察创建、方法调用和相等性:

src/main/java/cn/usehook/spring/basics/ClassDemo.java
01
package cn.usehook.spring.basics;
02
03
public class ClassDemo {
04
05
public static void main(String[] args) {
06
HealthReport first = HealthReport.up("spring");
07
HealthReport second = new HealthReport("UP", "spring");
08
HealthReport database = new HealthReport("DOWN", "postgresql");
09
10
System.out.println(first.describe());
11
System.out.println(database.describe("database"));
12
System.out.println(first == second);
13
System.out.println(first.equals(second));
14
System.out.println(first);
15
}
16
}
terminal.txt
1
health: status=UP, service=spring
2
database: status=DOWN, service=postgresql
3
false
4
true
5
HealthReport{status='UP', service='spring'}

HealthReport 也属于 POJO(Plain Old Java Object)。POJO 不是新的 Java 语法,只表示这个类不需要继承框架父类或实现框架规定的接口。

几个容易混淆的名称可以这样区分:

名称核心含义
POJO不依赖特定框架约束的普通 Java 对象
JavaBean通常遵循无参数构造方法、属性 getter 和 setter 等约定的类
Spring Bean由 Spring IoC 容器创建和管理的对象
recordJava 提供的紧凑数据载体类型,自动生成常用成员

一个 POJO 可以被 Spring 注册为 Bean,但 POJO 和 Spring Bean 不是同义词。使用构造器注入时,Spring Bean 也不要求必须具有无参数构造方法或为所有字段提供 setter。

当前项目中的 HealthStatus 使用 record:

src/main/java/cn/usehook/spring/health/HealthStatus.java
1
package cn.usehook.spring.health;
2
3
public record HealthStatus(String status, String service) {}

record 会根据组件列表生成构造方法、访问方法、equals()、hashCode() 和 toString(),适合只承载一组固定数据的对象。它的访问方法是 status() 和 service(),不是普通 JavaBean 风格的 getStatus() 和 getService()。

当一个值只能从有限选项中选择时,可以使用枚举类型,而不是让任意字符串进入业务代码。下面的枚举需要放在独立的 HealthState.java 文件中:

src/main/java/cn/usehook/spring/health/HealthState.java
1
package cn.usehook.spring.health;
2
3
public enum HealthState {
4
UP,
5
DOWN
6
}

enum 声明的每个枚举常量都是 HealthState 类型中预先创建的固定实例。方法参数如果声明为 HealthState,调用方只能传入 HealthState.UP 或 HealthState.DOWN,不能误传 "UNKNOWN" 这样的任意字符串:

src/main/java/cn/usehook/spring/basics/EnumDemo.java
01
package cn.usehook.spring.basics;
02
03
import cn.usehook.spring.health.HealthState;
04
05
public class EnumDemo {
06
07
public static void main(String[] args) {
08
HealthState state = HealthState.UP;
09
10
System.out.println(state.name());
11
System.out.println(state == HealthState.UP);
12
13
for (HealthState value : HealthState.values()) {
14
System.out.println(value);
15
}
16
}
17
}

同一个枚举常量使用固定实例表示,因此枚举通常可以使用 == 比较。name() 返回声明时的名称,values() 返回全部选项,valueOf("UP") 可以按名称查找枚举;无法匹配时会抛出 IllegalArgumentException,所以不能直接信任未经校验的外部字符串。

枚举不只是常量列表,它也可以声明字段、构造方法和普通方法。下面让每个状态自己保存是否可用以及对外显示文本:

src/main/java/cn/usehook/spring/health/HealthState.java
01
package cn.usehook.spring.health;
02
03
public enum HealthState {
04
UP(true, "available"),
05
DOWN(false, "unavailable");
06
07
private final boolean available;
08
private final String label;
09
10
HealthState(boolean available, String label) {
11
this.available = available;
12
this.label = label;
13
}
14
15
public boolean isAvailable() {
16
return available;
17
}
18
19
public String getLabel() {
20
return label;
21
}
22
}

枚举构造方法不能由业务代码通过 new 调用,它只在声明枚举常量时使用。与 public static final String STATUS_UP = "UP" 相比,枚举同时限制了类型和值域,更适合表达稳定的业务状态。当前心跳项目仍然保留字符串状态,避免在基础章节中同时改动 DTO、数据库映射和测试;后续重构时可以把内部业务模型改为 HealthState,再由 DTO 和持久化边界决定怎样转换成字符串。

HealthService 则更适合使用普通类,因为它表达的是可以执行 check() 的服务行为。下一篇先学习类所在的包、源码目录、访问控制与 import;随后再通过接口与多态理解多个类怎样共享一组行为契约。