Java语言的七块地基:从面向对象到可靠的资源释放(001–007)

Java语言基础与面向对象约 15 分钟阅读8,307 字符
知识库文档 8 / 251

覆盖考点:001–007 适合读者:高级 Java 工程师、技术专家、架构师候选人 预计阅读时长:35–40 分钟

很多面试题表面在问语法,实际在观察三件事:你能不能说清 Java 的运行规则,能不能识别工程风险,以及能不能把规则落到设计上。

本文用一个简化的支付系统贯穿七个考点:

text
业务变化
   │
   ▼
抽象 ──► 接口/抽象类 ──► 多态调用
   │                           │
   │                           ▼
   └────► 封装对象状态     重载与重写
                │              │
                ▼              ▼
          equals/hashCode   参数如何传递
                │              │
                └──────┬───────┘
                       ▼
              不可变值与资源边界
                String / final / finally

读完后,你应该能回答的不只是“定义是什么”,还包括:为什么这样设计、什么时候会踩坑、怎样写出更稳的代码。


001|面向对象四大特性:不是四句定义,而是四种控制复杂度的手段

面向对象常被概括为封装、继承、多态、抽象。背定义不难,难点是说明四者如何协作。

1. 封装:把合法状态和修改规则关在一起

封装不等于“字段全部写成 private”。真正的目标是:对象不能轻易进入非法状态。

下面这个账户看似封装了字段,实际上没有保护业务规则:

java
public class Account {
    private BigDecimal balance;

    public void setBalance(BigDecimal balance) {
        this.balance = balance;
    }
}

调用者可以把余额改成负数、null,甚至跳过审计。更合理的做法是只暴露有业务含义的操作:

java
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 报文。

java
public interface PaymentChannel {
    PaymentResult pay(PaymentCommand command);
}

这个接口就是抽象。它没有描述支付宝或银行卡怎样通信,只固定了调用方真正需要的契约。

3. 继承:复用“同一种东西”的稳定骨架

继承表达的是 is-a。例如“银行卡支付处理器是一种支付处理器”。继承可以复用模板流程,但会让父子类形成较强耦合。

java
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,防止子类绕过通用校验。

如果关系只是“使用某个能力”,优先考虑组合:订单服务“使用”支付渠道,不是“属于”支付渠道。

text
不推荐:OrderService extends AlipayChannel

推荐:  OrderService ──has-a──► PaymentChannel
                                  ▲
                     ┌────────────┴────────────┐
                  AlipayChannel           CardChannel

4. 多态:同一调用点,根据实际对象执行不同实现

java
PaymentChannel channel = route(command.channelCode());
PaymentResult result = channel.pay(command);

编译时,变量类型是 PaymentChannel;运行时,变量可能指向 AlipayChannel 或 CardChannel。调用哪个 pay,由实际对象决定。这让新增渠道时可以扩展实现,而不是不断修改一大段 if-else。

多态的成立通常需要三个条件:存在继承或接口实现、子类重写方法、父类型引用指向子类型对象。

面试追问

问:继承和组合怎么选? 答:先判断关系是不是稳定的 is-a,再判断父类是否能提供稳定契约。仅为了复用几行代码而继承,往往会造成脆弱基类问题。组合耦合更低,也更容易替换和测试,通常优先。

问:多态是否适用于字段和静态方法? 答:通常说的运行时多态针对可重写的实例方法。字段访问按引用的声明类型解析;静态方法属于类,是隐藏而不是重写。

问:封装会不会只是多写一些样板代码? 答:无规则的数据载体可以保持简单;有业务不变量的领域对象必须保护状态。判断标准不是 getter/setter 数量,而是对象是否能自行守住合法性。


002|重载与重写:一个在编译期选签名,一个在运行期选实现

这两个词相似,但它们解决的是两个阶段的问题。

维度 重载 Overload 重写 Override
发生位置 通常在同一个类,也可包含继承来的同名方法 父子类或接口实现关系
方法名 相同 相同
参数列表 必须不同 必须相同
返回类型 不能单独作为重载依据 相同或协变返回类型
选择时机 编译期,依据声明类型和参数类型 运行期,依据接收者的实际类型
访问权限 无特殊关联 不能缩小父方法权限
异常 各自独立 不能抛出更宽的受检异常

两阶段选择模型

先看一个有意设计得容易混淆的例子:

java
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));

输出是:

text
Payment:Object

可以把过程画成两步:

text
第 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 可能让重载产生歧义:

java
void send(String value) {}
void send(Integer value) {}

// send(null); // 编译错误:String 和 Integer 互不为父子类型

面试追问

问:重写方法为什么可以返回更具体的类型? 答:这是协变返回类型。调用方按父类契约期待一个父类型结果,子类返回其子类型仍然满足契约。

问:构造方法能重写吗?能重载吗? 答:构造方法不参与继承,因此不能重写;同一个类可以提供不同参数列表的构造方法,因此可以重载。

问:重载是不是多态? 答:广义上常称为编译时多态,但工程讨论中的“运行时多态”通常特指重写与动态分派。面试时最好把阶段说清楚,避免只争术语。


003|接口与抽象类:能力契约和家族骨架

一句好记但不绝对的区分是:接口描述“能做什么”,抽象类描述“这一族对象共同是什么、共同怎样做”。

能力契约:接口

java
public interface Refundable {
    RefundResult refund(RefundCommand command);
}

任何类型只要能履行退款契约,就可以实现它。一个类也可以同时实现多个接口,因此接口适合表达正交能力,例如 Refundable、Retryable、Auditable。

Java 8 之后,接口可以有 default 和 static 方法;Java 9 之后还可以有 private 辅助方法。但接口实例字段仍只能是隐式的 public static final 常量,不能保存每个对象自己的可变状态。

家族骨架:抽象类

抽象类可以拥有构造器、实例字段、不同可见性的具体方法和抽象方法,适合承载一组紧密相关实现的共同状态与流程。

java
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 能做少量工作 更合适

实际项目中两者常常组合,而不是二选一:

text
调用方 ──依赖──► PaymentChannel(接口,稳定契约)
                         ▲
                         │ implements
              AbstractPaymentChannel(抽象类,共同骨架)
                         ▲
                 ┌───────┴───────┐
              Alipay           Card

调用方只依赖接口;框架作者用抽象类帮助实现者复用代码。这同时兼顾了解耦和复用。

默认方法冲突规则

两个接口提供相同签名的默认方法时,可以记住三条:

  1. 类中的具体方法优先于接口默认方法;
  2. 更具体的子接口优先;
  3. 两个无继承关系的接口冲突,类必须显式重写,可用 A.super.method() 指定调用。

面试追问

问:接口增加方法一定会破坏兼容性吗? 答:增加抽象方法会迫使旧实现补代码;增加 default 方法通常保持二进制兼容,但仍要留意语义冲突以及已有实现中同名方法的行为。

问:为什么不把所有公共逻辑都放进接口默认方法? 答:默认方法没有实例状态,复杂模板流程的可见性和扩展点也容易失控。它更适合接口演进或很薄的通用行为,不应把接口写成隐蔽的工具类。

问:什么时候应该既有接口又有抽象类? 答:对外需要稳定契约、对内又有多个同族实现共享骨架时。例如 JDK 集合既有 List 接口,也有 AbstractList 骨架类。


004|Java 参数传递:永远传值,引用本身也是一个值

“Java 对象是引用传递”是一个很顽固的误解。Java 只有值传递:调用方法时,会把实参变量中保存的值复制给形参。

区别只在于变量里保存的是什么:

  • 基本类型变量保存数值,例如 int 中保存 10;
  • 引用变量保存“指向对象的引用值”,可以把它理解为一张地址卡,但不必等同于真实内存地址。

一张图看懂复制过程

text
调用前:
main.account ───────────────► Account{balance=100}

调用 debit(account) 时:复制引用值
main.account ───────┐
                    ├────────► 同一个 Account 对象
debit.parameter ────┘

所以:通过 parameter 修改对象,main 能看到;
但:把 parameter 改指向另一个对象,不会改变 main.account。

用一段代码验证三种情况

java
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?

java
static void swap(Object left, Object right) {
    Object temp = left;
    left = right;
    right = temp;
}

这段代码交换的只是两个形参副本。调用方的两个引用变量没有变化。要把新值带回去,可以返回结果、修改可变容器,或使用明确的领域操作。

工程上的启示

方法收到可变对象后,可能影响调用方可见的状态。这是一种隐式输出。高层设计中可以优先使用不可变命令和值对象,让输入真正只读;需要变化时返回新对象,副作用更清晰。

java
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 查找为例:

text
key
 │
 ├─► hashCode() ─► 扰动 ─► 定位桶下标
 │                              │
 │                              ▼
 └──────────────────────► 在桶内用 equals() 确认

契约要求:两个对象只要**equals**相等,**hashCode**就必须相等。 反过来不要求成立,不同对象允许哈希碰撞。

如果只重写 equals,两个业务上相等的对象可能被放进不同桶里,HashMap.get 根本没有机会调用 equals,于是出现“明明相等却找不到”的怪事。

一个合格的值对象

java
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:

java
public record OrderId(String value) {
    public OrderId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("订单号不能为空");
        }
    }
}

千万别修改 HashMap 的 Key

java
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、字符串拼接看起来在“修改”,实际返回的是新对象。

java
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 实现细节,不应写业务代码去依赖具体字段布局。

为什么值得付出“创建新对象”的成本?

  1. 字符串常量池可以安全共享。 多个引用指向同一个字面量,不会被其中一方改坏。
  2. 天然线程安全。 创建完成后没有内部写操作,多个线程读取不需要锁。
  3. 哈希值稳定。String 常作为 HashMap Key,内容不变就不会发生“换桶失联”。
  4. 安全边界更可靠。 类名、文件路径、URL、数据库连接信息等传给其他代码后,不会被对方原地篡改。
  5. 便于缓存。 JDK 可以缓存已经计算过的哈希值。

字符串池与 new String

text
堆内存
┌──────────────────────────────────────┐
│ 字符串常量池                          │
│   "pay" ◄──── a                      │
│      ▲                                │
│      └──── b = new String("pay").intern()
│                                      │
│ 普通堆对象                            │
│   new String("pay") ◄──── b(intern 前)
└──────────────────────────────────────┘
java
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() 当成通用性能开关。它会引入池管理成本,是否收益要由数据规模、重复率和压测决定。

拼接到底创建多少对象?

编译期可确定的常量表达式通常会被折叠:

java
String a = "pay" + "ment"; // 通常直接成为常量 "payment"

运行期拼接会由编译器使用优化过的拼接机制;在循环中反复 result += item 仍可能产生大量中间结果。明确的可变拼接应使用 StringBuilder:

java
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 的三种常见位置

java
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:会执行,但不要把它说成“百分之百执行”

java
try {
    process();
} finally {
    release();
}

正常返回、抛出异常、break 或 continue 等控制流离开 try 时,finally 通常会执行。但进程被强制终止、JVM 崩溃、机器掉电、Runtime.halt 等情况下没有保证。因此面试中说“finally 一定执行”不严谨。

不要在 finally 中 return:它可能覆盖 try 中的返回值,甚至吞掉正在传播的异常。

资源释放首选 try-with-resources

java
try (InputStream in = Files.newInputStream(path);
     BufferedInputStream buffered = new BufferedInputStream(in)) {
    return buffered.readAllBytes();
}

实现 AutoCloseable 的资源会按声明的逆序关闭。如果业务代码和 close() 同时抛异常,业务异常作为主异常,关闭异常进入 getSuppressed(),信息不会被简单覆盖。

text
申请资源 A ─► 申请资源 B ─► 执行业务
                              │
                  ┌───────────┴───────────┐
                  ▼                       ▼
                成功                    抛异常
                  │                       │
                  └────► 关闭 B ─► 关闭 A ┘
                         (逆序)

为什么 finalize 不可靠?

finalize() 的执行时间由 GC 和终结线程决定:可能很晚,也可能在进程结束前根本没有机会执行。终结队列积压会拖住大量对象;终结方法还可能让对象“复活”,带来复杂的安全与性能问题。JEP 421 已将终结机制标记为待移除。

结论很直接:绝不能用 finalize 释放数据库连接、文件句柄、锁等稀缺资源。

Cleaner 的正确位置:安全网,不是主流程

Cleaner 比 finalize 更可控,但仍依赖 GC 触发,不能保证及时。正确设计是:资源类实现 AutoCloseable,调用方显式 close;Cleaner 只在调用方漏关时做最后兜底。

java
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 只适合作为漏关时的兜底。


把七个考点连成一个设计

回到支付系统,可以这样分配职责:

text
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。

面试表达建议

回答这七类基础题时,可以用固定的四步结构:

  1. 先给准确结论;
  2. 解释编译期或运行期机制;
  3. 给一个能暴露差异的最小例子;
  4. 补充工程风险和选择依据。

例如回答参数传递,不要停在“Java 是值传递”。继续说明“引用值也会被复制”,再用“能改对象、不能换掉调用方引用”收尾,答案才完整。

基础题真正区分候选人的地方,不在于术语数量,而在于能否把语言规则、运行机制与工程设计连成一条因果链。