面向对象与基础语法
开篇:为什么 Java 是面向对象的?
如果把写程序比作搭积木,面向过程就像是按照说明书一步步把积木拼好——先拿红色块,再拿蓝色块,然后插到底板上。你的脑子里想的全是"下一步该干什么"。
而面向对象更像是先造出一个个"积木工厂":这个工厂专门生产红色块,那个工厂专门生产蓝色块,每个工厂知道自己该怎么运作。你要搭什么东西,只需要跟这些工厂打交道就行了。
Java 从第一天起就选择了面向对象这条路。它把世界上的一切都看成"对象"——你的银行账户是对象、购物车是对象、甚至一条日志也是对象。每个对象有自己的属性(数据)和行为(方法),对象之间通过消息传递来协作。
这种思维方式的好处非常直接:代码更容易理解、更容易复用、更容易扩展。当你的项目从几百行膨胀到几十万行时,面向对象就是你管理复杂度的救命稻草。
接下来,我们从面向对象的三大特性讲起,一路讲到异常体系和高频面试题,力求让你把这些知识真正理解透。
一、面向对象三大特性
1.1 封装:把复杂性藏起来
你开车的时候,只需要踩油门、打方向盘、看仪表盘。你不需要知道发动机怎么点火、变速箱怎么换挡。汽车把复杂的机械细节"封装"了起来,只给你暴露了几个简单的操作接口。
Java 的封装也是这个意思:用 private 把内部细节藏起来,用 public 方法暴露安全的操作入口。
public class BankAccount {
private double balance; // 余额藏起来,外部不能直接改
public void deposit(double amount) {
if (amount > 0) { // 存钱之前做校验
this.balance += amount;
}
}
public double getBalance() {
return this.balance; // 只能通过方法查看余额
}
}小贴士:封装不是简单地加 getter/setter。真正的封装是隐藏决策——外部不需要知道余额怎么存的,只需要知道"能存钱、能查余额"。如果你对每个
private字段都无脑加上 getter 和 setter,那封装就形同虚设了。
Java 中的四种访问控制修饰符,控制力度从大到小:
| 修饰符 | 同一个类 | 同一个包 | 子类 | 任意位置 |
|---|---|---|---|---|
private | 可以 | 不可以 | 不可以 | 不可以 |
| default(不写) | 可以 | 可以 | 不可以 | 不可以 |
protected | 可以 | 可以 | 可以 | 不可以 |
public | 可以 | 可以 | 可以 | 可以 |
1.2 继承:站在巨人的肩膀上
小时候你继承了父母的基因——眼睛像妈妈,鼻子像爸爸。在 Java 里,子类可以继承父类的属性和方法,然后在这个基础上增加自己的特色。
class Animal {
String name;
void eat() {
System.out.println(name + " is eating");
}
}
class Dog extends Animal {
void bark() {
System.out.println(name + ": 汪汪汪!");
}
}Dog 不需要重新写 eat() 方法,直接从 Animal 那里继承过来就能用。继承的核心目的是复用代码。
注意:Java 只支持单继承(一个类只能有一个父类),但支持多实现(一个类可以实现多个接口)。这是为了避免 C++ 中臭名昭著的菱形继承问题——如果 D 同时继承了 B 和 C,而 B、C 又都继承了 A,那调用 A 的方法时到底走 B 的还是 C 的?Java 直接砍掉多继承,釜底抽薪。
Java 8 的新变化: 接口中可以定义 default 方法了,这相当于变相支持了"多继承"。不过 Java 要求:如果你实现的多个接口中有同名 default 方法,你必须在实现类中重写来消除歧义。
interface Pet {
default void eat() { System.out.println("Pet eating"); }
}
interface Mammal {
default void eat() { System.out.println("Mammal eating"); }
}
// 必须重写 eat(),否则编译报错
class Cat implements Pet, Mammal {
@Override
public void eat() { System.out.println("Cat eating"); }
}1.3 多态:同一个方法,不同的表现
让所有动物"叫"一下:狗会"汪汪汪",猫会"喵喵喵",鸭子会"嘎嘎嘎"。同一个 speak() 方法,在不同动物身上有不同的表现,这就是多态。
多态需要满足三个条件:
- 有继承或接口实现
- 子类重写了父类的方法
- 父类引用指向子类对象
class Animal {
void speak() { System.out.println("..."); }
}
class Dog extends Animal {
@Override
void speak() { System.out.println("汪汪汪!"); }
}
class Cat extends Animal {
@Override
void speak() { System.out.println("喵喵喵!"); }
}
// 使用多态
Animal a1 = new Dog(); // 父类引用指向子类对象
Animal a2 = new Cat();
a1.speak(); // 输出:汪汪汪!
a2.speak(); // 输出:喵喵喵!你可能会问:"我自己定义的时候不就已经知道 a1 是 Dog、a2 是 Cat 了吗,多态有啥用?"
好问题。在实际开发中,你用到的对象经常不是你自己创建的。比如 Spring 中的依赖注入:
public class PayService {
@Autowired
PayServiceFactory factory;
public void pay(PayRequest req) {
// 运行时才决定用哪个支付实现,这就是多态的威力
factory.getPayService(req.getChannel()).pay(req);
}
}这里的 payService 到底是支付宝还是微信,你写代码的时候根本不知道,也不需要知道——运行时根据 channel 参数自动绑定到正确的实现。这就是多态的真正威力:让调用方和实现方解耦。
关于"静态多态":有些人认为重载也算多态(编译期多态),这个说法有争议。面试时可以这样说:"我倾向于认为多态是运行期特性,重写是多态的体现;但也有人提出重载是静态多态,这个问题学术界没有定论。"——既展示了你的理解深度,又展示了你的思辨能力。
二、重载与重写
这两个概念名字像,但完全是两回事。一个发生在"同一个类内部",一个发生在"父子类之间"。
| 对比项 | 重载(Overload) | 重写(Override) |
|---|---|---|
| 发生位置 | 同一个类中 | 父子类之间 |
| 方法名 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同 |
| 返回类型 | 无要求 | 相同(或协变返回类型) |
| 访问修饰符 | 无要求 | 不能比父类更严格 |
| 绑定时机 | 编译期(静态绑定) | 运行期(动态绑定) |
| 异常 | 无限制 | 不能抛出比父类更宽泛的受检异常 |
重载的例子——同一个类里,方法名一样但参数不同:
class Calculator {
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; } // 参数类型不同
int add(int a, int b, int c) { return a + b + c; } // 参数个数不同
}重写的例子——子类覆盖父类的方法:
class Parent {
void greet() { System.out.println("I am Parent"); }
}
class Child extends Parent {
@Override // 强烈建议加上这个注解,编译器会帮你检查
void greet() { System.out.println("I am Child"); }
}
// 运行时动态绑定
Parent p = new Child();
p.greet(); // 输出 "I am Child",而不是 "I am Parent"小贴士:重载是编译期决定调用哪个方法(看参数类型),重写是运行期决定(看实际对象类型)。面试时把"编译期"和"运行期"说清楚,面试官会觉得你理解得很到位。
三、抽象类 vs 接口
这是 Java 面试的经典考题。我先打个比方:
- 接口像是一份"合同":你签了合同(implements),就必须按照合同约定的条款(方法)去做事。合同只规定"你要做什么",不管你"怎么做"。
- 抽象类像是一个"半成品模具":模具已经有了基本的形状(已实现的方法),但有些地方留空了(抽象方法),需要你来补上。
| 对比项 | 抽象类 | 接口 |
|---|---|---|
| 关键字 | abstract class | interface |
| 继承/实现 | 单继承 | 多实现 |
| 构造器 | 可以有 | 不能有 |
| 成员变量 | 任意修饰符 | 默认 public static final |
| 方法实现 | 可以有具体方法 | Java 8 前不能有(之后可以有 default 方法) |
| 设计目的 | 代码复用(is-a) | 制定规范(can-do) |
实际开发中怎么选? 一般是这个套路:
- 先定义接口暴露给外部,制定规范
- 如果多个实现类有公共代码,在中间加一层抽象类抽取公共逻辑
- 最后各实现类继承抽象类,完成差异化部分
这就是经典的模板方法模式:
// 第一层:接口定义规范
public interface PayService {
void pay(PayRequest request);
}
// 第二层:抽象类复用公共逻辑
public abstract class AbstractPayService implements PayService {
@Override
public void pay(PayRequest request) {
validate(request); // 公共:参数校验
doPay(request); // 差异:具体支付逻辑,由子类实现
afterPay(request); // 公共:后置处理
}
// 子类必须实现的抽象方法
protected abstract void doPay(PayRequest request);
private void validate(PayRequest request) {
// 公共的参数校验逻辑
}
private void afterPay(PayRequest request) {
// 公共的后置处理:记录日志、发通知等
}
}
// 第三层:具体实现
public class AlipayService extends AbstractPayService {
@Override
protected void doPay(PayRequest request) {
// 调用支付宝 SDK,每个支付渠道只需关心自己的核心逻辑
}
}
public class WechatPayService extends AbstractPayService {
@Override
protected void doPay(PayRequest request) {
// 调用微信支付 SDK
}
}一句话总结:想定义规范用接口,想复用代码用抽象类,两者经常搭配使用。
四、关键字详解
4.1 static:属于类,不属于对象
static 修饰的东西跟着类走,不跟着对象走。你不需要 new 出对象就能用它。
打个比方:一个班级有 50 个学生(对象),每个学生有自己的名字(实例变量)。但是"班主任是谁"这个信息对全班来说只有一份,不需要每个学生都存一份——这就是 static 变量。
能修饰什么?
| 用法 | 说明 | 示例 |
|---|---|---|
| 静态变量 | 所有实例共享同一份 | public static int count = 0; |
| 静态方法 | 不依赖实例,直接 类名.方法() 调用 | Math.sqrt(4) |
| 静态代码块 | 类加载时执行一次,常用于初始化 | static { ... } |
| 静态内部类 | 不依赖外部类实例 | OuterClass.StaticInner inner = new OuterClass.StaticInner(); |
public class Counter {
public static int count = 0; // 所有 Counter 对象共享这一个 count
public Counter() {
count++; // 每创建一个对象,计数 +1
}
public static int getCount() {
// 静态方法里不能用 this,因为没有"当前对象"的概念
return count;
}
}
// 使用
Counter c1 = new Counter();
Counter c2 = new Counter();
System.out.println(Counter.getCount()); // 输出 2小贴士:静态方法不能访问实例变量和实例方法,因为静态方法被调用时,可能还没有任何对象实例存在。工具类的方法(如
Math.sqrt()、Collections.sort())通常都是静态方法。
4.2 final:不可变的承诺
final 就是一个"承诺"——承诺这个东西不会再变了。
| 修饰对象 | 效果 | 为什么这样设计 |
|---|---|---|
| 变量 | 赋值后不能再改(常量) | 防止意外修改,提高代码安全性 |
| 方法 | 不能被子类重写 | 防止子类破坏父类的核心逻辑 |
| 类 | 不能被继承(如 String、Integer) | 保证类的行为不被篡改 |
// final 变量
final int MAX_SIZE = 100;
// MAX_SIZE = 200; // 编译错误!
// final 类
public final class String { }
// class MyString extends String { } // 编译错误!String 不能被继承注意:
final修饰引用类型变量时,引用不能变(不能指向新对象),但对象的内容可以变。就像你被指定住在这个房子里不能搬家(引用不变),但你可以在房子里随便装修(对象内容可变)。
final vs finally vs finalize——名字像但毫无关系,就像周杰和周杰伦:
| 关键字 | 作用 | 本质 |
|---|---|---|
final | 修饰变量/方法/类,使之不可变 | 关键字 |
finally | 异常处理中确保代码块一定执行 | 异常处理机制 |
finalize | 对象被 GC 回收前调用的方法(不推荐用) | Object 的方法 |
4.3 this 与 super
this:指向当前对象自己。常用于区分同名的成员变量和局部变量,或者在构造器中调用本类的另一个构造器。super:指向父类。用于调用父类的构造器或被重写的方法。
class Animal {
String name;
Animal(String name) {
this.name = name; // this 区分成员变量和参数
}
}
class Dog extends Animal {
String breed;
Dog(String name, String breed) {
super(name); // 调用父类构造器,必须是第一行
this.breed = breed;
}
void printInfo() {
System.out.println(super.name + " - " + this.breed);
}
}注意:
super()和this()都必须放在构造器的第一行,所以它们不能同时出现在一个构造器中。如果子类构造器中没有显式调用super(),编译器会自动插入一个无参的super()调用——所以父类最好有无参构造器(后面会讲为什么)。
4.4 instanceof:类型检查
instanceof 用来判断一个对象是否是某个类(或其子类、接口实现类)的实例。
Animal a = new Dog("旺财");
System.out.println(a instanceof Animal); // true
System.out.println(a instanceof Dog); // true
System.out.println(a instanceof Cat); // false小贴士:编译器会检查
instanceof右边的类型是否和左边有继承关系。如果完全不可能为 true(比如 String instanceof Integer),编译器直接报错。
五、Object 类核心方法
Object 是所有 Java 类的祖先。它定义了几个非常重要的方法,每个 Java 程序员都必须理解。
5.1 equals 与 hashCode 的契约
为什么重写 equals 时必须重写 hashCode?
想象一个图书馆的分类系统:hashCode 决定一本书放在哪个书架上,equals 决定这本书是不是你要找的那本。
如果两本书是"同一本"(equals 返回 true),那它们必须被放在同一个书架上(hashCode 相同)。否则你在 A 书架找不到它,就会以为图书馆没有这本书——但它其实在 B 书架上。
这就是 HashMap 和 HashSet 正确工作的基础。
public class User {
private String id;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true; // 同一个引用,直接 true
if (o == null || getClass() != o.getClass()) return false; // 类型不同,false
User user = (User) o;
return Objects.equals(id, user.id); // 按 id 判断是否相等
}
@Override
public int hashCode() {
return Objects.hash(id); // 和 equals 保持一致,用 id 计算哈希
}
}契约规则总结:
equals相等 →hashCode必须相等hashCode相等 →equals不一定相等(哈希冲突是允许的)equals不等 →hashCode最好不等(减少冲突,提高性能)
小贴士:
Object默认的equals就是==(比较引用地址),默认的hashCode是根据内存地址算出来的整数。所以,如果你的业务逻辑需要按内容判断相等(比如按 id),就必须同时重写这两个方法。
5.2 深拷贝与浅拷贝
打个比方:你有一份文件夹,里面有几份文件。
- 浅拷贝:复印了文件夹的封面,但里面的文件只是放了个快捷方式——你改了原文件,快捷方式指向的内容也跟着变。
- 深拷贝:不仅复印文件夹,连里面的每一份文件都复印一份——完全独立,互不影响。
Java 中 Object.clone() 默认是浅拷贝,常用的 BeanUtils.copyProperties 也是浅拷贝。
来看个具体的例子:
class Address {
String city;
}
class User {
String name;
Address address;
}
User user1 = new User();
user1.address = new Address();
user1.address.city = "杭州";
User user2 = new User();
BeanUtils.copyProperties(user1, user2);
// 浅拷贝:user2.address 和 user1.address 指向同一个对象
user2.address.city = "上海";
System.out.println(user1.address.city); // 输出 "上海"!被改了实现深拷贝的两种方式:
方式一:重写 clone 方法——适合简单对象
class User implements Cloneable {
private String name;
private Address address;
@Override
protected Object clone() throws CloneNotSupportedException {
User user = (User) super.clone();
user.address = (Address) address.clone(); // 手动深拷贝引用类型
return user;
}
}方式二:序列化(推荐)——适合复杂对象,省心不出错
// 用 JSON 工具一行搞定
User newUser = JSON.parseObject(JSON.toJSONString(user), User.class);
// 或者用 Apache Commons Lang
User newUser = (User) SerializationUtils.clone(user); // 类需要实现 Serializable六、异常体系
Java 的异常体系是一棵树,根节点是 Throwable:
Error 和 Exception 的区别:
- Error:系统级错误,程序无法处理。比如内存爆了(OOM)、栈溢出了,该崩就崩,别试图 catch。
- Exception:程序级异常,可以也应该被处理。
受检异常 vs 非受检异常
| 类别 | 受检异常(Checked) | 非受检异常(Unchecked) |
|---|---|---|
| 父类 | Exception(非 RuntimeException) | RuntimeException |
| 编译器 | 强制要求处理(try-catch 或 throws) | 不强制 |
| 典型例子 | IOException, SQLException | NullPointerException, ArrayIndexOutOfBoundsException |
| 发生原因 | 外部因素(文件不存在、网络断开) | 程序 bug(空指针、越界) |
| 设计意图 | "我不保证一定成功,你必须做好准备" | "这是你代码写的有问题,自己修" |
异常处理的正确姿势
推荐使用 try-with-resources(Java 7+),自动关闭资源,告别手动 finally 关流:
// 推荐写法
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
String line;
while ((line = br.readLine()) != null) {
process(line);
}
} catch (IOException e) {
log.error("读取文件失败", e); // 记录日志,别吞掉异常
}
// br 会自动关闭,不需要 finally编译器会把 try-with-resources 翻译成带 finally 的代码,还会自动处理 addSuppressed(如果 close 也抛异常的话)。比手写安全得多。
异常处理几条铁律:
- 别用异常控制业务流程——异常的创建和抛出代价很高(要填充栈跟踪),频繁使用会拖性能。比如用 catch
DuplicateKeyException来判断记录是否存在,这是反模式,应该先查再插。 - 别吞异常——
catch住了什么都不做,是最危险的操作。出了问题你连日志都查不到。 - 自定义业务异常——比如
InsufficientBalanceException(余额不足),比通用的RuntimeException更有意义,上层拿到异常就知道发生了什么。 - finally 中别写 return——它会覆盖 try/catch 中的返回值,导致诡异 bug。
- catch 顺序要从子类到父类——先 catch
FileNotFoundException,再 catchIOException,否则编译报错。
finally 的执行规则
// finally 中有 return,最终返回什么?
public static String getValue() {
try {
return "A";
} catch (Exception e) {
return "B";
} finally {
return "C"; // 最终返回 C!finally 的 return 会覆盖前面的
}
}执行流程:try 中遇到 return "A",先把 "A" 暂存起来,然后执行 finally。finally 中又遇到 return "C",覆盖了之前暂存的 "A",最终返回 "C"。
如果 finally 中没有 return,那么暂存的值就会正常返回:
public static int getValue() {
int i = 1;
try {
i++;
return i; // 暂存 i=2
} finally {
i = 100; // 修改了 i,但不影响已暂存的返回值
}
// 最终返回 2,不是 100
}finally 不一定执行的几种情况:
System.exit()被调用、JVM 崩溃、kill -9杀进程、守护线程在 JVM 退出时可能被打断、try 中有死循环。
ClassNotFoundException vs NoClassDefFoundError
| 对比项 | ClassNotFoundException | NoClassDefFoundError |
|---|---|---|
| 类型 | 受检异常 | Error |
| 触发时机 | Class.forName() 找不到类 | 编译时存在,运行时找不到 |
| 典型场景 | 类名拼错、缺少依赖 jar | 编译后删除了 .class 文件 |
| 常见原因 | 配置问题 | jar 包冲突、版本不一致 |
七、Java 是值传递还是引用传递?
这是一个被讨论了无数次的话题,我直接给结论:
Java 只有值传递。 对于对象类型,传递的是引用的副本(地址的拷贝),而不是对象本身。
打个比方:你复制了一把家里的钥匙给朋友。
- 朋友用这把钥匙开了你家门,把你的电视砸了(修改对象属性)——你家确实被改了
- 朋友把他手里的钥匙换成了他自己家的钥匙(重新赋值引用)——对你手里的钥匙没有任何影响
public void pass(User user) {
user.setName("changed"); // 修改属性 → 调用方能看到变化
}
public void pass2(User user) {
user = new User(); // 重新赋值引用
user.setName("new"); // 调用方看不到变化,因为改的是副本
}再来看两段代码的对比:
// 场景1:修改属性
User hollis = new User("Hollis");
pass(hollis);
System.out.println(hollis.getName()); // 输出 "changed"
// 场景2:重新赋值引用
User hollis2 = new User("Hollis");
pass2(hollis2);
System.out.println(hollis2.getName()); // 输出 "Hollis",没变!核心逻辑:传递的是引用值的拷贝,不是引用本身。所以改引用指向的内容有效,改引用本身无效。Java 官方文档也明确说了:"Reference data type parameters, such as objects, are also passed into methods by value."
八、其他重要知识点
8.1 String、StringBuilder、StringBuffer
| 类 | 可变性 | 线程安全 | 性能 | 使用场景 |
|---|---|---|---|---|
String | 不可变(final 类) | 安全 | 频繁拼接时差 | 少量固定字符串 |
StringBuilder | 可变 | 不安全 | 最快 | 单线程拼接 |
StringBuffer | 可变 | 安全(synchronized) | 较快 | 多线程拼接(少用) |
小贴士:Java 8+ 编译器会把
"a" + "b" + "c"优化成StringBuilder.append(),但在循环中拼接字符串仍然建议手动用StringBuilder。
8.2 new String("abc") 创建了几个对象?
- 如果字符串常量池中已有
"abc"→ 1 个对象(堆上的 String 对象) - 如果常量池中没有
"abc"→ 2 个对象(常量池一个 + 堆上一个)
intern() 方法可以手动把字符串放入常量池:如果池中已有,返回池中引用;否则把当前字符串加入池并返回引用。
8.3 四种引用类型
| 引用类型 | GC 时机 | 典型用途 |
|---|---|---|
| 强引用(Strong) | 永远不回收(即使 OOM) | 日常使用的 = 赋值 |
| 软引用(Soft) | 内存不足时回收 | 缓存 |
| 弱引用(Weak) | 下次 GC 时回收 | WeakHashMap、ThreadLocal 的 Entry |
| 虚引用(Phantom) | 随时可能回收 | 跟踪对象被 GC 的状态,回收前做清理 |
8.4 反射
Java 编译后生成 .class 字节码文件,JVM 加载类时会把类的所有信息存入方法区。反射就是在运行时去读取和操作这些信息——可以动态创建对象、调用方法、访问属性。
// 方式1:Class.forName
User user = (User) Class.forName("com.example.User").newInstance();
// 方式2:Constructor(推荐,可以调用有参构造器)
Constructor<User> ctor = User.class.getConstructor(String.class);
User user2 = ctor.newInstance("张三");Java 中创建对象的方式还有:new、clone()、反序列化、方法句柄(MethodHandle)、Unsafe.allocateInstance()。日常开发用 new 就够了,反射和其他方式主要是框架在用。
8.5 枚举
枚举本质上是一个继承了 Enum 的 final 类,编译器帮你生成的。它天生就是线程安全的单例。
public enum Season {
SPRING, SUMMER, AUTUMN, WINTER;
}
// 反编译后其实是:
public final class Season extends Enum {
public static final Season SPRING = new Season("SPRING", 0);
public static final Season SUMMER = new Season("SUMMER", 1);
// ...
}枚举的好处:
- valueOf 自动校验——传入非法字符串会抛
IllegalArgumentException - 可以定义方法和属性——比常量类灵活得多
- 天然单例——序列化、反射都破坏不了
- 适合策略模式——每个枚举值可以有不同的行为实现
8.6 为什么建议自定义无参构造器?
如果你没有定义任何构造器,编译器会自动加一个无参构造器。但是,一旦你定义了有参构造器,自动生成的无参构造器就没了。
没有无参构造器会带来一系列问题:
- 反射和序列化依赖无参构造器创建对象
- Spring、Hibernate、Jackson 等框架需要无参构造器
- 子类构造器默认调用父类的无参构造器,如果没有就编译报错
所以养成习惯:定义了有参构造器,就顺手加一个无参的。
九、面向对象设计原则(SOLID)
| 原则 | 一句话解释 | 大白话 |
|---|---|---|
| S - 单一职责 | 一个类只做一件事 | 别让厨师兼职当保安 |
| O - 开闭原则 | 对扩展开放,对修改关闭 | 加新功能不改旧代码 |
| L - 里氏替换 | 子类必须能替换父类 | 儿子能干爸爸能干的活 |
| I - 接口隔离 | 用多个小接口替代大接口 | 别给只需要打印的客户塞扫描功能 |
| D - 依赖倒置 | 依赖抽象,不依赖具体实现 | 面向接口编程 |
举个依赖倒置的例子:
// 错误:直接依赖具体实现
class OrderService {
private MySQLDao dao = new MySQLDao(); // 如果换成 Redis 就要改代码
}
// 正确:依赖抽象接口
class OrderService {
private DataAccess dao; // 面向接口
OrderService(DataAccess dao) { this.dao = dao; }
}
// 底层存储随时可以换:MySQL、Redis、MongoDB,OrderService 不用改实践经验:多用组合,少用继承。组合是 has-a(狗有尾巴),继承是 is-a(狗是动物)。组合更灵活(运行时可以换组件)、耦合更低(不会因为父类改动牵连子类)。只有在确实需要 is-a 关系,且需要向上转型时,才用继承。
十、常见面试题精选
面试题 1:多态
面试官:如何理解 Java 中的多态?
思路:定义 → 三个条件 → 编译期 vs 运行期 → 实际应用场景
参考答案:多态是指同一操作作用于不同对象,产生不同结果。实现运行时多态需要三个条件:继承/实现、方法重写、父类引用指向子类对象。多态的核心是运行期动态绑定——编译时只知道是父类类型,运行时 JVM 根据实际对象类型决定调用哪个实现。典型应用如 Spring 的依赖注入,通过接口编程实现解耦。
面试题 2:值传递
面试官:Java 是值传递还是引用传递?
思路:先明确概念(值传递 = 传副本) → 基本类型传值 → 对象传引用地址的副本 → 举例说明
参考答案:Java 只有值传递。基本类型传递的是值的副本,对象类型传递的是引用地址的副本。这意味着:通过引用修改对象属性,调用方能看到变化(因为指向同一个对象);但如果在方法中把引用指向新对象,调用方的引用不受影响(因为改的是副本)。本质上是"共享对象传递",属于值传递的特例。
面试题 3:equals 和 hashCode
面试官:为什么重写 equals 时必须重写 hashCode?
思路:HashMap 的工作原理 → hashCode 定位桶 → equals 精确比较 → 违反契约的后果
参考答案:在 HashMap 等哈希集合中,先用 hashCode 快速定位桶位置,再用 equals 精确比较。如果两个 equals 相等的对象 hashCode 不同,它们会被放到不同的桶里,导致 HashMap 认为它们是不同的对象,出现数据丢失或重复存储。所以 Java 规范要求:equals 相等的对象,hashCode 必须相等。
面试题 4:接口和抽象类
面试官:接口和抽象类的区别,如何选择?
思路:核心区别(规范 vs 复用) → Java 8 default 方法的影响 → 实际架构中的分层用法
参考答案:接口的核心目的是定义规范(can-do),抽象类的核心目的是代码复用(is-a)。接口支持多实现,变量默认 public static final;抽象类单继承,可以有构造器和各种修饰符的成员。实际开发中,一般先定义接口,多个实现类有公共逻辑时再加抽象类做中间层(模板方法模式)。Java 8 引入 default 方法后,接口也能有默认实现,但要注意多接口同名方法冲突时必须在实现类中重写。
面试题 5:异常分类
面试官:Java 中异常分哪两类,有什么区别?
思路:Throwable 树 → Error vs Exception → 受检 vs 非受检 → 各自典型例子和处理方式
参考答案:Java 异常分为受检异常(Checked Exception)和非受检异常(Unchecked Exception,即 RuntimeException 及其子类)。受检异常编译器强制要求处理,代表外部不可控因素(如文件不存在、网络中断);非受检异常代表程序 bug(如空指针、越界),不要求强制处理。此外还有 Error(如 OOM、StackOverflow),表示系统级错误,程序不应该捕获。
面试题 6:深拷贝与浅拷贝
面试官:什么是深拷贝和浅拷贝?怎么实现深拷贝?
思路:概念区别 → 内存图 → BeanUtils 是浅拷贝 → 深拷贝的两种实现
参考答案:浅拷贝只复制对象本身和基本类型字段的值,引用类型字段仍指向原来的对象(共享);深拷贝递归复制所有引用类型,完全独立。Object.clone() 默认浅拷贝,BeanUtils.copyProperties 也是浅拷贝。实现深拷贝有两种方式:一是重写 clone() 方法手动深拷贝每个引用字段;二是通过序列化/反序列化(如
JSON.parseObject(JSON.toJSONString(obj), Cls.class)),后者更省心。
面试题 7:finally 的返回值
面试官:try 中 return A,catch 中 return B,finally 中 return C,最终返回什么?
思路:finally 总是在 try/catch 之后执行 → return 值会被暂存 → finally 中的 return 会覆盖
参考答案:最终返回 C。Java 的执行顺序是:try 中遇到 return 时先把返回值暂存,然后执行 finally。如果 finally 中有 return,会覆盖之前暂存的值。所以无论是否有异常,只要 finally 中有 return,结果都是 finally 的返回值。这也是为什么强烈不建议在 finally 中写 return 的原因。
面试题 8:为什么 Java 不支持多继承?
面试官:为什么 Java 不支持多继承?
思路:菱形继承问题 → C++ 的虚继承复杂度 → Java 的解决方案(接口多实现)→ Java 8 default 方法的处理
参考答案:因为多继承会引发菱形继承问题:如果 D 同时继承 B 和 C,而 B、C 又都继承 A,那 D 会继承两份 A 的属性和方法,调用时产生歧义。C++ 为此引入了虚继承,但增加了语言复杂度。Java 选择只允许单继承,但支持多接口实现。Java 8 引入 default 方法后,如果多个接口有同名默认方法,编译器会要求实现类必须重写来消除歧义。
小结
核心要点回顾:
- 封装、继承、多态是面向对象的三大基石——封装管复杂度,继承管复用,多态管扩展性
- 重写是运行期绑定,重载是编译期绑定——这一点说清楚,很多面试题就迎刃而解了
- equals 和 hashCode 必须一起重写,否则 HashMap 会出问题
- Java 只有值传递,对象传递的是引用地址的副本
- 异常别乱用——不要用异常控制业务流程,不要吞异常,推荐用 try-with-resources 管理资源