Java面向对象
面向对象是 Java 的核心编程思想。它通过类和对象组织代码,把数据和行为封装到一起,降低复杂系统的理解成本。
为什么需要面向对象
如果只用过程式代码,数据和操作数据的逻辑容易分散在各个方法里。项目一大,就会出现“谁都能改数据、谁也不知道规则在哪里”的问题。面向对象把状态和行为放到同一个对象里,通过方法保护状态变化,让代码更接近业务概念。
flowchart TD
A["过程式写法"] --> B["数据到处传"]
B --> C["规则分散在多个方法"]
C --> D["状态容易被非法修改"]
E["面向对象写法"] --> F["对象封装状态和行为"]
F --> G["通过方法表达业务规则"]
G --> H["对象始终保持合法状态"]三大特性
| 特性 | 说明 |
|---|---|
| 封装 | 隐藏内部细节,对外暴露稳定接口 |
| 继承 | 子类复用父类字段和方法 |
| 多态 | 同一个父类引用可以指向不同子类对象 |
类和对象
public class User {
private String name;
public User(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public void sayHello() {
System.out.println("hello " + name);
}
}创建对象:
User user = new User("Tom");
user.sayHello();对象创建流程
flowchart TD
A["new 关键字"] --> B["检查类是否已加载"]
B --> C["为对象分配堆内存"]
C --> D["成员变量零值初始化"]
D --> E["执行字段显式赋值和代码块"]
E --> F["执行构造方法"]
F --> G["返回对象引用"]更底层的对象内存内容可以看 HotSpot 虚拟机对象探秘。
对象创建不是“调用构造方法这么简单”。构造方法执行前,对象内存已经分配,并且字段已经有默认零值。
示例:
class Order {
private long id;
private String status = "CREATED";
public Order(long id) {
this.id = id;
}
}创建 new Order(1001L) 时可以理解为:
- 检查
Order类是否已加载。 - 在堆里分配一块对象内存。
id先是0,status先是null。- 执行字段显式赋值,
status = "CREATED"。 - 执行构造方法,
id = 1001L。 - 把对象引用返回给变量。
如果构造方法里把 this 提前传给其他线程或全局变量,对方可能看到还没构造完整的对象。这也是为什么构造期间不要让 this 逃逸。
对象在内存中怎么关联
对象变量保存的是引用,不是对象本身。多个变量可以指向同一个对象。
User a = new User("Tom");
User b = a;
b.setName("Jerry");
System.out.println(a.getName()); // Jerryflowchart TD
A["变量 a"] --> C["堆中的 User 对象"]
B["变量 b"] --> C
C --> D["name = Jerry"]这解释了为什么把对象传到方法里,方法可以修改对象字段。也解释了为什么共享可变对象在多线程或多模块之间很危险:谁拿到引用,谁就可能改状态。
封装
封装不是简单把字段改成 private,而是把对象内部状态保护起来,只允许通过有意义的方法修改。
import java.math.BigDecimal;
public class Account {
private BigDecimal balance;
public void withdraw(BigDecimal amount) {
if (balance == null) {
throw new IllegalStateException("账户余额未初始化");
}
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("金额必须大于0");
}
if (balance.compareTo(amount) < 0) {
throw new IllegalStateException("余额不足");
}
this.balance = this.balance.subtract(amount);
}
}这样可以避免外部代码随意修改余额,保证对象状态合法。
封装的核心是“状态只能通过规则变化”。如果只暴露 setBalance,就等于把规则交给外部调用者:
public class BadAccount {
public BigDecimal balance;
}外部可以这样写:
account.balance = new BigDecimal("-999999");对象立刻进入非法状态。更好的建模方式是把业务动作放进对象:
import java.math.BigDecimal;
public class Account {
private BigDecimal balance;
public Account(BigDecimal initialBalance) {
if (initialBalance == null || initialBalance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("初始余额不能为负");
}
this.balance = initialBalance;
}
public void deposit(BigDecimal amount) {
checkPositive(amount);
this.balance = this.balance.add(amount);
}
public void withdraw(BigDecimal amount) {
checkPositive(amount);
if (this.balance.compareTo(amount) < 0) {
throw new IllegalStateException("余额不足");
}
this.balance = this.balance.subtract(amount);
}
public BigDecimal getBalance() {
return balance;
}
private void checkPositive(BigDecimal amount) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("金额必须大于 0");
}
}
}这段代码比 getter/setter 多,但它把“余额不能为负、提款不能超额、金额必须大于 0”的规则放在对象内部。调用方不需要记住所有细节,只能通过合法方法改变状态。
继承
继承适合表达“is-a”关系。例如 Cat 是 Animal。
public class Animal {
public void eat() {}
}
public class Cat extends Animal {
public void catchMouse() {}
}继承不要滥用。继承层级过深会让代码难理解,也会把父类变化传递给所有子类。
继承真正适合的是稳定的“is-a”关系,而不是为了复用几行代码。
错误示例:为了复用折扣字段,让所有订单都继承 DiscountOrder:
class DiscountOrder {
protected int discountRate;
}
class NormalOrder extends DiscountOrder {
}问题是普通订单未必有折扣,这样会把不属于它的状态强塞进去。更好的方式是组合:
class DiscountPolicy {
private int discountRate;
}
class Order {
private DiscountPolicy discountPolicy;
}组合比继承更灵活,因为对象可以选择是否拥有某种能力,也可以在运行期替换策略。继承一旦建立,父类变化会影响所有子类。
重写、重载和动态绑定
重载发生在编译期,重写发生在运行期多态。
class PayService {
public void pay(String orderNo) {
System.out.println("按订单号支付");
}
public void pay(long orderId) {
System.out.println("按订单 ID 支付");
}
}这是重载:方法名相同,参数列表不同。编译器根据变量的静态类型和参数类型决定调用哪个方法。
重写示例:
class Animal {
public void speak() {
System.out.println("animal");
}
}
class Cat extends Animal {
@Override
public void speak() {
System.out.println("cat");
}
}
public class PolymorphismDemo {
public static void main(String[] args) {
Animal animal = new Cat();
animal.speak(); // cat
}
}执行过程:
flowchart TD
A["编译期看到变量类型 Animal"] --> B["确认 Animal 有 speak 方法"]
B --> C["运行期对象真实类型是 Cat"]
C --> D["动态绑定到 Cat.speak"]字段没有多态,静态方法也不参与运行期动态绑定。多态主要针对实例方法。
多态
Animal animal = new Cat();
animal.eat();编译时看左边类型,运行时执行右边真实对象的方法。多态是很多框架扩展点的基础。
多态的价值是让调用方依赖抽象,而不是依赖具体实现。
interface MessageSender {
void send(String target, String content);
}
class SmsSender implements MessageSender {
public void send(String target, String content) {
System.out.println("发送短信:" + target + " " + content);
}
}
class EmailSender implements MessageSender {
public void send(String target, String content) {
System.out.println("发送邮件:" + target + " " + content);
}
}
class NoticeService {
private final MessageSender sender;
NoticeService(MessageSender sender) {
this.sender = sender;
}
public void notifyUser(String target, String content) {
sender.send(target, content);
}
}NoticeService 不关心是短信还是邮件,只依赖 MessageSender。以后增加站内信、企业微信、钉钉,只要实现同一个接口,不需要修改通知业务主流程。
接口和抽象类
| 对比项 | 接口 | 抽象类 |
|---|---|---|
| 关系 | 表示能力 | 表示抽象父类 |
| 多实现 | 支持 | 不支持,只能单继承 |
| 状态 | 通常不保存对象状态 | 可以保存字段 |
| 场景 | 定义规范、扩展点 | 提取公共逻辑 |
更细一点:
| 问题 | 接口 | 抽象类 |
|---|---|---|
| 表达什么 | 能力、规范、扩展点 | 一类对象的共同父类 |
| 是否可多继承 | 一个类可实现多个接口 | 一个类只能继承一个抽象类 |
| 是否适合保存状态 | 不适合保存实例状态 | 可以保存公共字段 |
| JDK 8 默认方法 | 支持 default 方法 | 普通方法天然支持 |
| 典型例子 | Runnable、Comparator、Repository 接口 | 模板方法基类、公共 Service 基类 |
选择口诀:
- 定义能力和协议,优先接口。
- 需要复用公共状态和流程,可以考虑抽象类。
- 如果只是复用工具方法,优先组合或工具类,不要强行继承。
抽象类模板方法 Demo
抽象类适合固定流程、开放部分步骤。
abstract class AbstractImportTask {
public final void execute(String filePath) {
checkFile(filePath);
Object data = parse(filePath);
validate(data);
save(data);
}
private void checkFile(String filePath) {
if (filePath == null || filePath.trim().isEmpty()) {
throw new IllegalArgumentException("文件路径不能为空");
}
}
protected abstract Object parse(String filePath);
protected abstract void validate(Object data);
protected abstract void save(Object data);
}这里 execute 固定导入流程,子类只实现解析、校验、保存。final 防止子类破坏整体流程。这就是模板方法思想。
贫血模型和充血模型
很多项目里实体类只有字段和 getter/setter,所有规则都写在 Service 里,这叫贫血模型。
贫血模型示例:
class Order {
private String status;
public void setStatus(String status) {
this.status = status;
}
}
class OrderService {
public void pay(Order order) {
if (!"CREATED".equals(order.getStatus())) {
throw new IllegalStateException("只有待支付订单才能支付");
}
order.setStatus("PAID");
}
}问题是任何地方都可以绕过 OrderService 直接 setStatus("PAID")。充血模型会把核心状态变化放到对象里:
enum OrderStatus {
CREATED,
PAID,
CANCELLED
}
class Order {
private OrderStatus status;
public void pay() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("只有待支付订单才能支付");
}
this.status = OrderStatus.PAID;
}
public void cancel() {
if (this.status == OrderStatus.PAID) {
throw new IllegalStateException("已支付订单不能直接取消");
}
this.status = OrderStatus.CANCELLED;
}
}不是所有项目都必须使用复杂领域模型,但核心业务规则不能完全散落在各处。至少要让关键状态变化有统一入口。
商业 Demo:订单状态流转
下面是一个更完整的状态流转 Demo,适合理解封装、多态、枚举和异常。
import java.math.BigDecimal;
enum OrderStatus {
CREATED,
PAID,
DELIVERED,
CANCELLED
}
public class Order {
private final Long id;
private final BigDecimal amount;
private OrderStatus status;
public Order(Long id, BigDecimal amount) {
if (id == null) {
throw new IllegalArgumentException("订单 ID 不能为空");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("订单金额必须大于 0");
}
this.id = id;
this.amount = amount;
this.status = OrderStatus.CREATED;
}
public void pay() {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("只有待支付订单才能支付");
}
this.status = OrderStatus.PAID;
}
public void deliver() {
if (status != OrderStatus.PAID) {
throw new IllegalStateException("只有已支付订单才能发货");
}
this.status = OrderStatus.DELIVERED;
}
public void cancel() {
if (status == OrderStatus.DELIVERED) {
throw new IllegalStateException("已发货订单不能取消");
}
this.status = OrderStatus.CANCELLED;
}
public OrderStatus getStatus() {
return status;
}
}这个类的重点不是字段多少,而是对象自己保护状态:
- 创建订单时必须有合法 ID 和金额。
- 初始状态固定为
CREATED。 - 支付、发货、取消都有状态前置条件。
- 外部不能直接
setStatus绕过规则。
如果这些规则都写在外部 Service 的多个方法里,未来新增“退款、售后、部分发货”时很容易漏改。
线上问题排查
对象状态被非法修改
排查路径:
flowchart TD
A["对象状态异常"] --> B{"字段是否 public 或有随意 setter"}
B -- "是" --> C["收敛修改入口,字段 private"]
B -- "否" --> D{"状态变化是否分散在多个 Service"}
D -- "是" --> E["封装领域方法或状态机"]
D -- "否" --> F{"是否并发修改"}
F -- "是" --> G["加锁、事务、乐观锁或不可变对象"]
F -- "否" --> H["检查反射、序列化、ORM 映射"]继承导致行为异常
常见原因:
- 子类重写父类方法,但父类构造方法中调用了可重写方法。
- 父类新增字段或逻辑,影响所有子类。
- 子类不满足父类语义,违反里氏替换原则。
- 继承只是为了复用代码,导致层级越来越深。
父类构造方法里不要调用可被子类重写的方法,因为此时子类字段可能还没初始化完成。
面试标准回答
面向对象三大特性是什么?
封装、继承、多态。封装把状态和行为放在对象内部,通过方法保护状态变化;继承表达稳定的 is-a 关系,复用父类能力;多态让父类或接口引用指向不同实现,调用时按真实对象执行方法。
封装为什么不只是 private + getter/setter?
真正的封装是让对象状态只能通过有业务含义的方法变化,并在方法里校验规则。如果只是给所有字段生成 setter,外部仍然能随意修改对象,业务规则会分散并且容易被绕过。
重载和重写区别是什么?
重载发生在同一个类中,方法名相同但参数列表不同,编译期决定调用哪个;重写发生在继承关系中,子类覆盖父类方法,运行期根据真实对象类型动态绑定。
接口和抽象类怎么选?
接口更适合定义能力、规范和扩展点,一个类可以实现多个接口;抽象类更适合提取一类对象的公共状态和固定流程。项目中通常优先接口和组合,谨慎使用继承。
为什么优先组合而不是继承?
继承会把子类和父类强绑定,父类变化会影响所有子类,层级深了难理解。组合可以按需持有某个能力对象,更灵活,也更符合“用什么组合什么”的业务建模方式。
设计建议
- 优先组合,谨慎继承。
- 字段尽量 private,通过方法表达业务行为。
- 接口不要过大,避免实现类被迫实现无关方法。
- equals 和 hashCode 要一起重写。
- 业务对象要保持状态合法,不要只做贫血 getter/setter。
面向对象设计和 设计模式 联系很紧密,建议结合阅读。
常见风险
| 风险 | 后果 | 建议 |
|---|---|---|
| 只写 getter/setter | 对象没有行为,业务规则散落在服务层 | 把核心状态变化封装成方法 |
| 继承层级过深 | 父类变化影响大量子类 | 优先组合和接口 |
| public 字段随意改 | 对象状态不受控制 | 字段私有化,通过方法校验 |
| equals/hashCode 只重写一个 | 集合去重、Map 查找异常 | 两者一起重写 |
