覆盖考点:001–007 适合读者:高级 Java 工程师、技术专家、架构师候选人 预计阅读时长:35–40 分钟
很多面试题表面在问语法,实际在观察三件事:你能不能说清 Java 的运行规则,能不能识别工程风险,以及能不能把规则落到设计上。
本文用一个简化的支付系统贯穿七个考点:
业务变化
│
▼
抽象 ──► 接口/抽象类 ──► 多态调用
│ │
│ ▼
└────► 封装对象状态 重载与重写
│ │
▼ ▼
equals/hashCode 参数如何传递
│ │
└──────┬───────┘
▼
不可变值与资源边界
String / final / finally
读完后,你应该能回答的不只是“定义是什么”,还包括:为什么这样设计、什么时候会踩坑、怎样写出更稳的代码。
001|面向对象四大特性:不是四句定义,而是四种控制复杂度的手段
面向对象常被概括为封装、继承、多态、抽象。背定义不难,难点是说明四者如何协作。
1. 封装:把合法状态和修改规则关在一起
封装不等于“字段全部写成 private”。真正的目标是:对象不能轻易进入非法状态。
下面这个账户看似封装了字段,实际上没有保护业务规则:
public class Account {
private BigDecimal balance;
public void setBalance(BigDecimal balance) {
this.balance = balance;
}
}
调用者可以把余额改成负数、null,甚至跳过审计。更合理的做法是只暴露有业务含义的操作:
public final class Account {
private BigDecimal balance;
public Account(BigDecimal openingBalance) {
requireNonNegative(openingBalance);
this.balance = openingBalance;
}
public void debit(BigDecimal amount) {
requirePositive(amount);
if (balance.compareTo(amount) < 0) {
throw new IllegalStateException("余额不足");
}
balance = balance.subtract(amount);
}
public BigDecimal balance() {
return balance;
}
private static void requirePositive(BigDecimal value) {
if (value == null || value.signum() <= 0) {
throw new IllegalArgumentException("金额必须大于 0");
}
}
private static void requireNonNegative(BigDecimal value) {
if (value == null || value.signum() < 0) {
throw new IllegalArgumentException("余额不能为负数");
}
}
}
这里封装了三样东西:余额、余额的合法条件、余额的变化路径。对象边界同时成为校验边界。
2. 抽象:保留决策需要的信息,隐藏无关细节
抽象是在建立模型。例如支付服务关心的是“支付多少钱、用什么渠道、结果是什么”,不应该让上层业务理解每家支付机构的签名字段和 HTTP 报文。
public interface PaymentChannel {
PaymentResult pay(PaymentCommand command);
}
这个接口就是抽象。它没有描述支付宝或银行卡怎样通信,只固定了调用方真正需要的契约。
3. 继承:复用“同一种东西”的稳定骨架
继承表达的是 is-a。例如“银行卡支付处理器是一种支付处理器”。继承可以复用模板流程,但会让父子类形成较强耦合。
public abstract class AbstractPaymentChannel implements PaymentChannel {
@Override
public final PaymentResult pay(PaymentCommand command) {
validate(command);
String requestId = createRequestId();
return doPay(requestId, command);
}
protected void validate(PaymentCommand command) {
// 通用校验
}
protected abstract PaymentResult doPay(
String requestId, PaymentCommand command);
private String createRequestId() {
return UUID.randomUUID().toString();
}
}
父类固定流程,子类只实现变化点。注意 pay 被声明为 final,防止子类绕过通用校验。
如果关系只是“使用某个能力”,优先考虑组合:订单服务“使用”支付渠道,不是“属于”支付渠道。
不推荐:OrderService extends AlipayChannel
推荐: OrderService ──has-a──► PaymentChannel
▲
┌────────────┴────────────┐
AlipayChannel CardChannel
4. 多态:同一调用点,根据实际对象执行不同实现
PaymentChannel channel = route(command.channelCode());
PaymentResult result = channel.pay(command);
编译时,变量类型是 PaymentChannel;运行时,变量可能指向 AlipayChannel 或 CardChannel。调用哪个 pay,由实际对象决定。这让新增渠道时可以扩展实现,而不是不断修改一大段 if-else。
多态的成立通常需要三个条件:存在继承或接口实现、子类重写方法、父类型引用指向子类型对象。
面试追问
问:继承和组合怎么选? 答:先判断关系是不是稳定的 is-a,再判断父类是否能提供稳定契约。仅为了复用几行代码而继承,往往会造成脆弱基类问题。组合耦合更低,也更容易替换和测试,通常优先。
问:多态是否适用于字段和静态方法? 答:通常说的运行时多态针对可重写的实例方法。字段访问按引用的声明类型解析;静态方法属于类,是隐藏而不是重写。
问:封装会不会只是多写一些样板代码? 答:无规则的数据载体可以保持简单;有业务不变量的领域对象必须保护状态。判断标准不是 getter/setter 数量,而是对象是否能自行守住合法性。
002|重载与重写:一个在编译期选签名,一个在运行期选实现
这两个词相似,但它们解决的是两个阶段的问题。
| 维度 | 重载 Overload | 重写 Override |
|---|---|---|
| 发生位置 | 通常在同一个类,也可包含继承来的同名方法 | 父子类或接口实现关系 |
| 方法名 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同 |
| 返回类型 | 不能单独作为重载依据 | 相同或协变返回类型 |
| 选择时机 | 编译期,依据声明类型和参数类型 | 运行期,依据接收者的实际类型 |
| 访问权限 | 无特殊关联 | 不能缩小父方法权限 |
| 异常 | 各自独立 | 不能抛出更宽的受检异常 |
两阶段选择模型
先看一个有意设计得容易混淆的例子:
class Payment {
String describe(Object value) {
return "Payment:Object";
}
String describe(String value) {
return "Payment:String";
}
}
class CardPayment extends Payment {
@Override
String describe(String value) {
return "CardPayment:String";
}
}
Payment payment = new CardPayment();
Object value = "100";
System.out.println(payment.describe(value));
输出是:
Payment:Object
可以把过程画成两步:
第 1 步:编译器看声明类型
payment 是 Payment,value 是 Object
└─► 选定签名 describe(Object)
第 2 步:JVM 看实际接收者
实际对象是 CardPayment
└─► 查找是否重写了 describe(Object)
没有,所以执行 Payment.describe(Object)
如果把变量改为 String value = "100",编译器会先选 describe(String);运行时再发现子类重写了这个签名,于是输出 CardPayment:String。
三个常见陷阱
第一,返回类型不同不能构成重载。因为调用 calculate() 时,编译器不能只靠接收结果的变量判断该选哪个方法。
第二,private 方法不可被子类重写,final 方法禁止重写,static 方法只能被隐藏。建议始终写 @Override,让编译器帮你发现签名拼错。
第三,null 可能让重载产生歧义:
void send(String value) {}
void send(Integer value) {}
// send(null); // 编译错误:String 和 Integer 互不为父子类型
面试追问
问:重写方法为什么可以返回更具体的类型? 答:这是协变返回类型。调用方按父类契约期待一个父类型结果,子类返回其子类型仍然满足契约。
问:构造方法能重写吗?能重载吗? 答:构造方法不参与继承,因此不能重写;同一个类可以提供不同参数列表的构造方法,因此可以重载。
问:重载是不是多态? 答:广义上常称为编译时多态,但工程讨论中的“运行时多态”通常特指重写与动态分派。面试时最好把阶段说清楚,避免只争术语。
003|接口与抽象类:能力契约和家族骨架
一句好记但不绝对的区分是:接口描述“能做什么”,抽象类描述“这一族对象共同是什么、共同怎样做”。
能力契约:接口
public interface Refundable {
RefundResult refund(RefundCommand command);
}
任何类型只要能履行退款契约,就可以实现它。一个类也可以同时实现多个接口,因此接口适合表达正交能力,例如 Refundable、Retryable、Auditable。
Java 8 之后,接口可以有 default 和 static 方法;Java 9 之后还可以有 private 辅助方法。但接口实例字段仍只能是隐式的 public static final 常量,不能保存每个对象自己的可变状态。
家族骨架:抽象类
抽象类可以拥有构造器、实例字段、不同可见性的具体方法和抽象方法,适合承载一组紧密相关实现的共同状态与流程。
public abstract class AbstractRefundChannel implements Refundable {
private final Clock clock;
protected AbstractRefundChannel(Clock clock) {
this.clock = clock;
}
@Override
public final RefundResult refund(RefundCommand command) {
checkDeadline(command, clock.instant());
return doRefund(command);
}
protected abstract RefundResult doRefund(RefundCommand command);
}
选择时看这张表
| 问题 | 更倾向接口 | 更倾向抽象类 |
|---|---|---|
| 多种不相关类型都要具备该能力? | 是 | 否 |
| 一个类需要组合多种能力? | 是,多实现 | 否,Java 只能单继承类 |
| 需要保存共同实例状态? | 否 | 是 |
| 需要统一构造过程? | 否 | 是 |
| 需要给调用方稳定、低耦合的契约? | 是 | 可配合接口使用 |
| 需要模板方法固定主流程? | default 能做少量工作 |
更合适 |
实际项目中两者常常组合,而不是二选一:
调用方 ──依赖──► PaymentChannel(接口,稳定契约)
▲
│ implements
AbstractPaymentChannel(抽象类,共同骨架)
▲
┌───────┴───────┐
Alipay Card
调用方只依赖接口;框架作者用抽象类帮助实现者复用代码。这同时兼顾了解耦和复用。
默认方法冲突规则
两个接口提供相同签名的默认方法时,可以记住三条:
- 类中的具体方法优先于接口默认方法;
- 更具体的子接口优先;
- 两个无继承关系的接口冲突,类必须显式重写,可用
A.super.method()指定调用。
面试追问
问:接口增加方法一定会破坏兼容性吗? 答:增加抽象方法会迫使旧实现补代码;增加 default 方法通常保持二进制兼容,但仍要留意语义冲突以及已有实现中同名方法的行为。
问:为什么不把所有公共逻辑都放进接口默认方法? 答:默认方法没有实例状态,复杂模板流程的可见性和扩展点也容易失控。它更适合接口演进或很薄的通用行为,不应把接口写成隐蔽的工具类。
问:什么时候应该既有接口又有抽象类? 答:对外需要稳定契约、对内又有多个同族实现共享骨架时。例如 JDK 集合既有 List 接口,也有 AbstractList 骨架类。
004|Java 参数传递:永远传值,引用本身也是一个值
“Java 对象是引用传递”是一个很顽固的误解。Java 只有值传递:调用方法时,会把实参变量中保存的值复制给形参。
区别只在于变量里保存的是什么:
- 基本类型变量保存数值,例如
int中保存10; - 引用变量保存“指向对象的引用值”,可以把它理解为一张地址卡,但不必等同于真实内存地址。
一张图看懂复制过程
调用前:
main.account ───────────────► Account{balance=100}
调用 debit(account) 时:复制引用值
main.account ───────┐
├────────► 同一个 Account 对象
debit.parameter ────┘
所以:通过 parameter 修改对象,main 能看到;
但:把 parameter 改指向另一个对象,不会改变 main.account。
用一段代码验证三种情况
static void changeNumber(int value) {
value = 99;
}
static void changeAccount(Account account) {
account.debit(new BigDecimal("10"));
}
static void replaceAccount(Account account) {
account = new Account(new BigDecimal("999"));
}
int number = 1;
Account account = new Account(new BigDecimal("100"));
changeNumber(number); // number 仍是 1
changeAccount(account); // 原对象余额变成 90
replaceAccount(account); // account 仍指向余额为 90 的原对象
形参 account 只是调用方引用变量的副本。changeAccount 顺着副本找到了同一个对象并修改它;replaceAccount 只改了自己的副本。
为什么 Java 写不出通用的 swap?
static void swap(Object left, Object right) {
Object temp = left;
left = right;
right = temp;
}
这段代码交换的只是两个形参副本。调用方的两个引用变量没有变化。要把新值带回去,可以返回结果、修改可变容器,或使用明确的领域操作。
工程上的启示
方法收到可变对象后,可能影响调用方可见的状态。这是一种隐式输出。高层设计中可以优先使用不可变命令和值对象,让输入真正只读;需要变化时返回新对象,副作用更清晰。
record Money(BigDecimal amount, Currency currency) {
Money add(Money other) {
checkSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
}
面试追问
问:方法能改变对象内容,为什么还不是引用传递? 答:因为形参和实参保存了两份相同的引用值,所以能访问同一对象。真正的引用传递会允许被调用方法直接替换调用方变量指向的对象,Java 做不到。
问:数组作为参数是什么规则? 答:数组也是对象。传入的是引用值的副本;可以修改数组元素,但给形参数组重新赋值不会改变调用方的数组引用。
问:**Integer**传进去为什么改不了? 答:仍然是值传递,同时 Integer 本身不可变。形参重新装箱只是指向了另一个 Integer 对象。
005|equals 与 hashCode:先定义“相等”,再让哈希容器相信你
对象相等不是 JVM 能替业务决定的事。对订单来说,订单号相同可能就代表同一个订单;对金额来说,数值和币种都相同才相等。
引用相等与业务身份
对基本类型,== 比较值;对引用类型,== 比较是否指向同一对象。Object.equals 默认也等价于引用身份比较,只有类型重写后才表达内容相等。
一个良好的 equals 要满足:
- 自反:
x.equals(x)为真; - 对称:
x.equals(y)与y.equals(x)结果一致; - 传递:若
x=y且y=z,则x=z; - 一致:参与比较的信息没变,多次调用结果不变;
- 非空:
x.equals(null)为假。
为什么重写 equals 必须同时重写 hashCode?
哈希容器不是把所有元素逐个比较。以 HashMap 查找为例:
key
│
├─► hashCode() ─► 扰动 ─► 定位桶下标
│ │
│ ▼
└──────────────────────► 在桶内用 equals() 确认
契约要求:两个对象只要**equals**相等,**hashCode**就必须相等。 反过来不要求成立,不同对象允许哈希碰撞。
如果只重写 equals,两个业务上相等的对象可能被放进不同桶里,HashMap.get 根本没有机会调用 equals,于是出现“明明相等却找不到”的怪事。
一个合格的值对象
public final class OrderId {
private final String value;
public OrderId(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("订单号不能为空");
}
this.value = value;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof OrderId that)) return false;
return value.equals(that.value);
}
@Override
public int hashCode() {
return value.hashCode();
}
}
Java 16+ 可以用 record 表达简单值对象,编译器会基于组件生成 equals 和 hashCode:
public record OrderId(String value) {
public OrderId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("订单号不能为空");
}
}
}
千万别修改 HashMap 的 Key
Map<UserKey, String> map = new HashMap<>();
UserKey key = new UserKey("alice");
map.put(key, "data");
key.setName("bob");
System.out.println(map.get(key)); // 可能得到 null
对象放入时按 alice 的哈希定位,修改后按 bob 的哈希查找,桶位置可能已经不同。哈希 Key 应优先设计为不可变对象。
继承层次中的 equals 也很危险。父类用 instanceof,子类又加入新字段,容易破坏对称性或传递性。值对象通常适合 final;复杂实体则要明确是按数据库 ID、自然键还是对象身份比较。
面试追问
问:hashCode 相同,equals 一定相等吗? 答:不一定。哈希值范围有限,不同对象允许碰撞。容器定位到桶后仍要用 equals 确认。
问:equals 相等,hashCode 在不同 JVM 进程中也必须相同吗? 答:契约要求同一次执行期间保持一致,没有承诺跨进程或跨版本稳定。因此不要直接把普通 hashCode 当持久化协议或分片算法。
问:**BigDecimal("1.0")**和**BigDecimal("1.00")**相等吗? 答:compareTo 返回 0,但 equals 为假,因为 equals 同时比较数值和 scale。金额领域要明确采用哪种相等语义,不能想当然。
006|String 不可变性:一处设计,换来共享、安全和可预测
所谓不可变,是指一个 String 对象创建后,其字符序列不能再改变。toUpperCase、replace、字符串拼接看起来在“修改”,实际返回的是新对象。
String original = "pay";
String upper = original.toUpperCase();
System.out.println(original); // pay
System.out.println(upper); // PAY
它怎样做到不可变?
从设计层面看:String 类是 final,不能被子类破坏约束;内部数据私有;没有暴露修改内部内容的方法;需要变化的操作返回新字符串。
在现代 OpenJDK 中,字符串通常用 byte[ ] 加编码标记实现 Compact Strings;更早版本常见 char[ ]。这是 JDK 实现细节,不应写业务代码去依赖具体字段布局。
为什么值得付出“创建新对象”的成本?
- 字符串常量池可以安全共享。 多个引用指向同一个字面量,不会被其中一方改坏。
- 天然线程安全。 创建完成后没有内部写操作,多个线程读取不需要锁。
- 哈希值稳定。
String常作为HashMapKey,内容不变就不会发生“换桶失联”。 - 安全边界更可靠。 类名、文件路径、URL、数据库连接信息等传给其他代码后,不会被对方原地篡改。
- 便于缓存。 JDK 可以缓存已经计算过的哈希值。
字符串池与 new String
堆内存
┌──────────────────────────────────────┐
│ 字符串常量池 │
│ "pay" ◄──── a │
│ ▲ │
│ └──── b = new String("pay").intern()
│ │
│ 普通堆对象 │
│ new String("pay") ◄──── b(intern 前)
└──────────────────────────────────────┘
String a = "pay";
String b = new String("pay");
System.out.println(a == b); // false,引用不同
System.out.println(a.equals(b)); // true,内容相同
System.out.println(a == b.intern()); // true,取得池中规范引用
不要把 intern() 当成通用性能开关。它会引入池管理成本,是否收益要由数据规模、重复率和压测决定。
拼接到底创建多少对象?
编译期可确定的常量表达式通常会被折叠:
String a = "pay" + "ment"; // 通常直接成为常量 "payment"
运行期拼接会由编译器使用优化过的拼接机制;在循环中反复 result += item 仍可能产生大量中间结果。明确的可变拼接应使用 StringBuilder:
StringBuilder builder = new StringBuilder();
for (String item : items) {
builder.append(item).append(',');
}
String result = builder.toString();
StringBuilder 不保证线程安全;StringBuffer 的主要方法带同步,但共享字符串构建器往往不是最佳并发设计。
面试追问
问:**String**为什么适合做 HashMap 的 Key? 答:它不可变,equals 和 hashCode 语义明确,放入后哈希不变;同时 JDK 还可缓存哈希值。
问:String 真的是绝对不可修改吗?反射呢? 答:正常 Java API 保证不可变。利用深反射、Unsafe 等突破模块和封装边界属于非正常手段,会破坏 JVM 假设,不应作为业务设计依据。
问:敏感密码应该用 String 保存吗?
答:长期保存密码时更倾向 char[ ] 或 byte[ ],用完可主动清零;String 无法原地擦除,生命周期也不完全可控。但实际系统还要结合框架接口和威胁模型评估。
007|final、finally、finalize 与 Cleaner:名字相近,职责完全不同
这几个词没有一条共同的语义链,最好分开记:
| 名称 | 属于什么 | 解决什么问题 |
|---|---|---|
final |
关键字 | 限制再次赋值、重写或继承 |
finally |
异常处理语句块 | 在控制流离开 try 时执行收尾逻辑 |
finalize() |
Object 的历史方法 |
已废弃并待移除的终结机制,不可依赖 |
Cleaner |
Java 9+ 清理 API | 为未显式释放的资源提供兜底,不保证及时 |
final 的三种常见位置
final int timeout = 1000; // 变量只能赋值一次
final PaymentChannel channel = route(); // 引用不能改指向
// 但对象未必不可变
class Parent {
final void validate() {} // 子类不能重写
}
final class OrderId {} // 类不能被继承
final List<String> list 只表示变量 list 不能指向另一个列表,不代表列表内容不能增删。要表达不可变集合,应使用 List.copyOf 等不可变视图/副本,并确保元素本身的可变性也受控。
构造阶段正确设置的 final 字段还享有 Java 内存模型的特殊初始化安全保证:对象没有在构造期间逸出的前提下,其他线程看到该对象引用时,应能看到其 final 字段的正确值。这也是不可变对象容易安全共享的重要原因之一。
finally:会执行,但不要把它说成“百分之百执行”
try {
process();
} finally {
release();
}
正常返回、抛出异常、break 或 continue 等控制流离开 try 时,finally 通常会执行。但进程被强制终止、JVM 崩溃、机器掉电、Runtime.halt 等情况下没有保证。因此面试中说“finally 一定执行”不严谨。
不要在 finally 中 return:它可能覆盖 try 中的返回值,甚至吞掉正在传播的异常。
资源释放首选 try-with-resources
try (InputStream in = Files.newInputStream(path);
BufferedInputStream buffered = new BufferedInputStream(in)) {
return buffered.readAllBytes();
}
实现 AutoCloseable 的资源会按声明的逆序关闭。如果业务代码和 close() 同时抛异常,业务异常作为主异常,关闭异常进入 getSuppressed(),信息不会被简单覆盖。
申请资源 A ─► 申请资源 B ─► 执行业务
│
┌───────────┴───────────┐
▼ ▼
成功 抛异常
│ │
└────► 关闭 B ─► 关闭 A ┘
(逆序)
为什么 finalize 不可靠?
finalize() 的执行时间由 GC 和终结线程决定:可能很晚,也可能在进程结束前根本没有机会执行。终结队列积压会拖住大量对象;终结方法还可能让对象“复活”,带来复杂的安全与性能问题。JEP 421 已将终结机制标记为待移除。
结论很直接:绝不能用 finalize 释放数据库连接、文件句柄、锁等稀缺资源。
Cleaner 的正确位置:安全网,不是主流程
Cleaner 比 finalize 更可控,但仍依赖 GC 触发,不能保证及时。正确设计是:资源类实现 AutoCloseable,调用方显式 close;Cleaner 只在调用方漏关时做最后兜底。
public final class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private static final class State implements Runnable {
private long address;
State(long address) {
this.address = address;
}
@Override
public void run() {
if (address != 0) {
freeNativeMemory(address);
address = 0;
}
}
}
private final State state;
private final Cleaner.Cleanable cleanable;
NativeBuffer(long address) {
this.state = new State(address);
this.cleanable = CLEANER.register(this, state);
}
@Override
public void close() {
cleanable.clean(); // 显式、幂等地清理
}
}
清理动作 State 不应持有外部 NativeBuffer 的引用,否则对象可能始终可达,Cleaner 也无法触发。
面试追问
问:final 引用指向的对象一定不可变吗? 答:不一定。final 限制的是引用变量重新赋值,对象内部是否可变由对象自身设计决定。
问:try 中 return,finally 会不会执行? 答:通常会先计算并保存返回结果,再执行 finally,最后返回。若 finally 自己 return 或抛异常,会改变原控制流,所以不要在 finally 中返回。
问:Cleaner 能替代 try-with-resources 吗? 答:不能。Cleaner 依赖对象不可达和 GC,没有及时性保证;try-with-resources 在明确的词法作用域结束时释放资源。Cleaner 只适合作为漏关时的兜底。
把七个考点连成一个设计
回到支付系统,可以这样分配职责:
PaymentCommand(不可变命令)
├─ final 字段 / record
├─ String 渠道码不可变
└─ 参数按值传递,服务不会替换调用方变量
│
▼
PaymentChannel(接口:能力契约)
▲
AbstractPaymentChannel(抽象类:校验和审计骨架)
▲
┌────────┴────────┐
AlipayChannel CardChannel ← 重写产生运行时多态
│
└─ SDK 方法可重载,但签名在编译期确定
OrderId / RequestId(值对象)
└─ equals + hashCode 一致,安全用作 Map Key
外部连接 / 文件 / Native 资源
└─ AutoCloseable + try-with-resources,Cleaner 只兜底
这张图也是架构面试中比较理想的回答方式:不孤立背语法,而是说明语言机制怎样支撑可扩展、可维护、可恢复的设计。
终场自测:先回答,再展开答案
1. 重写后,方法调用究竟看左边还是右边?
先由编译器根据左边的声明类型和实参类型确定可调用的方法签名;如果该签名是可重写实例方法,运行时再根据右边实际对象选择具体实现。
2. 为什么“字段 private + 全套 setter”不一定叫良好封装?
因为 setter 可能允许调用方绕过业务规则,把对象改成非法状态。良好封装应该暴露有业务含义的操作,并在对象内部维护不变量。
3. 新增一种支付渠道,为什么接口通常比到处加 if-else 更稳?
接口把稳定调用契约与变化实现分开。路由确定实现后,主流程通过多态调用;新增渠道主要扩展新类,减少修改既有主流程带来的回归风险。
4. 方法能把传入账户余额扣掉,为什么 Java 仍是值传递?
调用时复制了账户引用值。两个变量暂时指向同一账户,因此能观察到同一对象的变化;被调用方法不能直接替换调用方变量中保存的引用。
5. 业务对象重写 equals,却漏掉 hashCode,会出现什么现象?
在普通比较中可能看不出问题,但放进 HashMap/HashSet 后,相等对象可能落到不同桶,导致查找不到或重复存储。
6. String 不可变为什么与线程安全、字符串池、HashMap Key 同时有关?
内容不变意味着可以跨引用和跨线程安全共享,池中对象不会被某个调用方篡改;哈希值也保持稳定,因此适合做 Key。
7. 关闭资源的优先级应该怎样排?
首选 try-with-resources 管理明确生命周期;必要时在 finally 中显式关闭旧式资源;Cleaner 仅作为兜底;不要使用 finalize。
面试表达建议
回答这七类基础题时,可以用固定的四步结构:
- 先给准确结论;
- 解释编译期或运行期机制;
- 给一个能暴露差异的最小例子;
- 补充工程风险和选择依据。
例如回答参数传递,不要停在“Java 是值传递”。继续说明“引用值也会被复制”,再用“能改对象、不能换掉调用方引用”收尾,答案才完整。
基础题真正区分候选人的地方,不在于术语数量,而在于能否把语言规则、运行机制与工程设计连成一条因果链。