# 第 28 章 Java 语言与对象模型


> 语言层语法只是表象，本章把 Java 的对象模型、分派机制与语言陷阱落到 JVM 字节码与内存布局上。

## 学习目标

读完本章，你应当能够：

1. 用 JVMS 的方法调用指令集解释"重载编译期绑定、重写运行期绑定"的机制根因，并说出 `vtable` 在类加载期是如何构造的。
2. 计算一个 Java 对象与数组在开启指针压缩下的字节数，并推导 32 GB 堆边界是怎么算出来的。
3. 说明违反 `equals`/`hashCode` 契约后 `HashMap` 查找失败发生在哪一步，并写出正确重写模板。
4. 辨析 `final`、`static`、`String` 常量池、自动装箱缓存各自"在什么时机生效、为什么是这个时机"。
5. 从字节码层面解释 `finally` 里 `return` 吞异常、`try-with-resources` 的抑制异常、桥接方法的产生原因。
6. 判断 JDK 动态代理与 CGLIB 的适用边界，并为 Spring AOP 的代理选择给出判据。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| 运行时数据区划分、类加载过程与初始化时机 | 第 32 章 32.1 节 |
| 对象创建、TLAB 与内存分配路径 | 第 32 章 32.2 节 |
| Mark Word 位域与锁升级 | 第 30 章 30.3 节 |
| JMM、`happens-before`、`final` 域语义 | 第 30 章 30.1 / 30.5 节 |
| `HashMap` 哈希扰动、树化与扩容拆分 | 第 29 章 29.2 节 |

---

## 28.1 三大特性在 JVM 层的落点

### 28.1.1 封装：访问控制与逃逸分析是两件事

`private` 是**编译期**约束：javac 把跨类访问私有成员直接编译报错，字节码里用 `ACC_PRIVATE` 标记，运行期由 `ClassLoader` 之后的对象布局与反射访问检查兜底。它的真正价值不是"隐藏数据"，而是**把不变量（invariant）的维护责任锁进一个类**。

逃逸分析（escape analysis）与封装没有因果关系：它判断的是对象引用能否逃出方法或线程，用来做标量替换（scalar replacement）、栈上分配与锁消除（lock elision）；字段是否 `private` 与它无关，因为访问控制在 ClassLoader 阶段已校验完毕。

> [!WARNING]
> 面试官常问"逃逸分析为什么没让对象都在栈上分配"。错误回答是"因为 JVM 没开这个参数"。正确方向是：栈上分配在 HotSpot 中并没有作为独立的分配路径实现，真正落地的是**标量替换**；且只有 `-XX:+DoEscapeAnalysis`（默认开启）判定不逃逸、且对象可被拆解时才生效，还必须等 JIT 编译（C2）之后，解释执行阶段一律走堆上 TLAB 分配。

### 28.1.2 继承：`vtable` 的构造与字段隐藏

HotSpot 在类加载的链接阶段构造虚方法表（virtual method table, `vtable`）：先**复制父类 `vtable`**，再把本类中"签名相同且父类条目非 `final`/非 `private`"的方法替换到对应槽位，新方法追加到尾部。因此子类中重写的方法与父类方法占用**同一个槽位下标**。

```text
class A { void f(); void g(); }            A.vtable: [0]=A.f  [1]=A.g
class B extends A { void f(); void h(); }  B.vtable: [0]=B.f  [1]=A.g  [2]=B.h
                                           ↑ 同槽位覆盖 → 调用点只需固定偏移
```

字段完全不同：字段访问在**编译期**按引用的**声明类型**生成 `getfield`/`putfield`，是"隐藏（hiding）"而非"重写（overriding）"。所以 `A a = new B(); a.x` 取的是 `A.x`，即使 `B` 也定义了 `x`——这是八股里最容易答错的一题。

### 28.1.3 多态：五条方法调用指令

| 指令 | 绑定方式 | 典型场景 | 是否走 `vtable` |
|---|---|---|---|
| `invokestatic` | 编译期静态解析 | `static` 方法 | 否 |
| `invokespecial` | 编译期静态解析 | 构造器、`private` 方法、`super.` 调用 | 否 |
| `invokevirtual` | 运行期动态分派 | 普通实例方法、`final` 实例方法 | 是 |
| `invokeinterface` | 运行期动态分派 | 接口方法 | 走接口方法表（itable），按偏移查表后取实现 |
| `invokedynamic` | 首次执行时链接 `CallSite` | Lambda、字符串拼接（JDK 9+） | 否（绑定到 `MethodHandle`） |

有两条反直觉结论必须记住：`final` 实例方法仍编译为 `invokevirtual`（但 JIT 能证明无重写从而去虚化），而**重载（overload）走的也是 `invokevirtual`**——重载的选择发生在 javac 阶段，运行期那条指令照样是虚调用，只是它已经指向了唯一确定的描述符。

重载的编译期选择分三个阶段（JLS 15.12.2）：先按**参数个数与子类型关系**找适用方法，若多个适用则选**最具体**的；自动装箱、可变参数（varargs）在最后阶段才参与，所以 `f(int)` 与 `f(Integer)` 重载时传入字面量 `1` 永远选 `f(int)`。

> [!TIP]
> Lambda、方法引用与字符串拼接都走 `invokedynamic`：首次执行时链接出一个 `CallSite`，之后直接走 `MethodHandle`，因此它能比"编译期硬编码调用"更晚决定调用目标。这也是接口默认方法与记录类相关字节码的基础设施。

---

## 28.2 对象的内存布局与大小计算

```text
64 位 HotSpot + 指针压缩（默认）：
┌─ 对象头 12 B ─────────────────────────────────────────────────────────┐
│ Mark Word 8 B：hashCode:25 | age:4 | biased_lock:1 | lock:2           │
│   无锁/偏向态存位域；轻量级锁存栈上 Lock Record 指针；                    │
│   重量级锁存 ObjectMonitor 指针（位域与锁升级见第 30 章 30.3 节）         │
│ Klass Pointer 4 B：压缩后的类元数据指针                                 │
├─ 实例数据：按宽度重排 ─────────────────────────────────────────────────┤
│   long/double 8 B → int/float 4 B → short/char 2 B → byte/boolean 1 B  │
│   父类字段在前，同宽度字段尽量相邻以减少 padding                          │
├─ 对齐填充：补齐到 8 字节整数倍（对象起始地址 8 字节对齐）                  │
└───────────────────────────────────────────────────────────────────────┘
数组对象额外多 4 B length（压缩后），元素区按 8 B 对齐
```

一个可直接背下来的算例：`class A { boolean b; long l; }`，新对象头 12 B，字段重排后 `l`（8 B）在前、`b`（1 B）在后，实例数据 9 B，合计 21 B，填充到 **24 B**。重排的收益在字段更多时才显现：两个 `boolean` 与一个 `long` 混排时，重排能省下整个 8 B 的填充块。

指针压缩（compressed oops）的原理：JVM 把对象起始地址**右移 3 位**存入 32 位字段（8 字节对齐保证低 3 位为 0），因此 32 位可表示 `2³² × 8 = 2³⁵ = 32 GB`——这就是"32 GB 界线"的全部来源。超过后基址无法再用零基偏移表示，压缩指针失效，对象头从 12 B 涨到 16 B、引用从 4 B 涨到 8 B。

| 参数 | 默认值 | 含义与影响 |
|---|---|---|
| `-XX:+UseCompressedOops` | 堆 ≤ 32 GB 时默认开启 | 引用 4 B、对象头 12 B，缓存友好 |
| `-XX:+UseCompressedClassPointers` | 默认开启 | Klass Pointer 压到 4 B，依赖 Metaspace 映射在低地址 |
| `-XX:ObjectAlignmentInBytes` | 8 | 对齐粒度；提高它可扩大压缩指针的寻址上限；**堆 > 32 GB 时压缩被关闭** |

> [!IMPORTANT]
> 堆 > 32 GB 时压缩指针被关闭，**对象头与引用各多 4 B**——在存活对象上千万的场景里足以吃掉多出来的那几 GB，净收益为负。所以生产上要么压在 32 GB 以内，要么一次跳到 48 GB 以上。

---

## 28.3 `==`、`equals` 与契约

`==` 对基本类型比值，对引用类型比**引用身份**；`equals` 的 `Object` 默认实现就是 `==`，语义完全取决于重写。契约来自 `Object.equals` 的 javadoc 与 JLS 对 `hashCode` 的要求：

| 契约 | 精确含义 | 被违反的典型后果 |
|---|---|---|
| 反身性 | `x.equals(x)` 恒为 true | `list.contains(x)` 返回 false，`remove` 删不掉 |
| 对称性 | `x.equals(y)` ⇔ `y.equals(x)` | 父子类混用 `instanceof` 判断时，`Set` 中出现"重复"元素 |
| 传递性 | `x=y`、`y=z` ⇒ `x=z` | 带"容差"或时间戳的比较破坏排序与去重 |
| 一致性 | 只要参与比较的字段不变，结果不变 | 用可变字段做 `hashCode`，对象入 `HashMap` 后改字段，查找必失败 |
| `equals` 为 true ⇒ `hashCode` 必相等 | 相等对象落到不同桶，`HashMap` 查不到 | 反方向不要求：`hashCode` 相等**不**蕴含 `equals` 为 true |

"用可变字段做 `hashCode`"的失败链条值得完整走一遍：`put` 时按旧 `hashCode` 落在桶 3；改字段后 `get`，新 `hashCode` 落在桶 7，桶 7 里根本没有这个节点，返回 `null`；而 `size` 仍是 1——**对象在表里，但永远取不出来**。`HashMap.getNode` 的顺序是"桶下标相同 → `hash` 相等且 `(k == key || key.equals(k))`"，所以 `equals` 错导致同桶内找不到，`hashCode` 错导致连桶都进不去。

```java
@Override
public boolean equals(Object o) {
    if (this == o) {                                  // 引用相同直接返回，天然满足反身性
        return true;
    }
    if (o == null || getClass() != o.getClass()) {    // 用 getClass 而非 instanceof：保证对称性
        return false;
    }
    User other = (User) o;
    return age == other.age                           // 基本类型用 ==，避免装箱
            && Objects.equals(name, other.name)       // 引用类型可能为 null，交给 Objects 兜底
            && Arrays.equals(tags, other.tags);       // 数组必须用 Arrays.equals，否则比引用
}

@Override
public int hashCode() {
    // 字段必须与 equals 完全一致；31 = 2^5 − 1 可编译成移位减，且碰撞率低
    return Objects.hash(name, age, Arrays.hashCode(tags));
}
```

> [!NOTE]
> `Objects.hash(...)` 内部做 `Arrays.hashCode(new Object[]{...})`，每次调用都有一次数组分配与多轮装箱。高频路径（对象当 `HashMap` 键、每秒百万次）建议手写 `31 * result + field`；但**不要**为了性能牺牲与 `equals` 字段的一致性。

---

## 28.4 `String` 不可变性与字符串常量池

不可变的四个理由各自对应一处具体收益：

1. **常量池共享安全**：同一份字面量被多个类共享，若可变，一处修改会污染所有持有者。
2. **`hashCode` 可缓存**：`String` 用一个非 `volatile` 的 `hash` 字段懒计算并缓存，不可变才让这个缓存永远有效。
3. **线程安全与传参安全**：不可变对象天然可安全发布；文件路径、类名、SQL、URL 这类"传递即信任"的场景不会被中途篡改。

要区分三个容易混淆的"池"：常量池在 `.class` 文件中，是字面量与符号引用的**静态表**；运行时常量池在方法区（JDK 8+ 属 Metaspace），是类加载后装入内存的版本，且可动态添加（`String.intern` 就是其一）；字符串池则是 JDK 7 起位于**堆**中的一个 `StringTable` 哈希表（默认桶数 60013），JDK 6 时它在永久代。

`intern()` 的差异是必考项：JDK 6 中池在永久代，`intern` 必要时**复制**一份到永久代并返回那一份；JDK 7+ 池在堆中，`intern` 直接把**堆上该对象的引用**存入 `StringTable` 并返回，不再复制。所以 JDK 7+ 下两个内容相同的 `String` 分别 `intern()` 后 `==` 成立，且 `-XX:StringTableSize` 直接决定 `intern` 的吞吐。

`+` 拼接的编译期/运行期差异：

| 场景 | 产物 | 说明 |
|---|---|---|
| 全是编译期常量 | 直接折叠为一个常量 | `final static String A = "a"; A + "b"` 编译后就是 `"ab"` |
| 运行期 `+`（JDK 8 及以前） | `new StringBuilder().append(...).toString()` | 每次拼接新建一个 `StringBuilder` |
| 运行期 `+`（JDK 9+） | `invokedynamic` + `makeConcatWithConstants` | 首次链接生成 `MethodHandle`，按目标长度一次性分配 `byte[]` |
| 循环内 `+` | 每次迭代新建 `StringBuilder` | `O(n²)` 拷贝，必须显式用 `StringBuilder` |

> [!WARNING]
> "JDK 9 之后字符串拼接一定比 `StringBuilder` 快"是错误结论。`invokedynamic` 的方案免掉了中间 `StringBuilder` 对象、且能精确预算长度，在**一次性拼接少量片段**时更优；但**循环累积**场景编译器无法提升到循环外，仍然每次迭代一次链接调用与一次分配，性能与显式 `StringBuilder` 同级甚至更差。面试官追问"那为什么还要 `StringBuilder`"，答案就是循环与需要 `delete`/`insert` 的场景。

---

## 28.5 自动装箱与缓存

```text
Integer.valueOf(int) 的实际逻辑（JDK 源码语义）：
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-low)];   // 直接返回缓存数组元素，零分配
    return new Integer(i);                       // 超出范围才新建

IntegerCache：low  = -128（固定）
              high = 127  （默认；-XX:AutoBoxCacheMax=<n> 可提高到该值）
```

| 包装类 | 缓存范围 | 缓存实现 |
|---|---|---|
| `Byte`、`Short`、`Long`、`Integer` | `-128 ~ 127`（JLS 5.1.7 要求至少覆盖此区间） | 各自的静态内部 `*Cache` 数组 |
| `Character` | `0 ~ 127` | `CharacterCache` |
| `Boolean` | `TRUE` / `FALSE` | 两个静态常量，**没有范围概念**，`valueOf` 恒返回同一个实例 |
| `Float`、`Double` | 无缓存 | `valueOf` 每次 `new`，所以 `==` 几乎总是 false |

于是高频陷阱题有了统一答案：`Integer a = 100, b = 100; a == b` 为 `true`（缓存命中）；`Integer c = 1000, d = 1000; c == d` 为 `false`（各自 `new`）；而 `Integer a = 100; int b = 100; a == b` 恒为 `true`，因为混用基本类型时**先拆箱再比较**。

> [!CAUTION]
> `a == b` 中若一边是包装类一边是基本类型，编译器插入 `intValue()` 拆箱——如果 `a` 是 `null`，直接 `NullPointerException`，且异常位置与业务语义完全无关（常见于 `Map<String,Integer> map; if (map.get(k) == 0)`）。这类 NPE 在代码 review 中极难发现，必须显式用 `Objects.equals` 或先判空。

循环里的装箱损耗要有数字感：把累加变量声明为 `Long` 而不是 `long`，每次迭代多一次 `Long.valueOf` 与一次 `longValue`；更要命的是**对象无法被提升为寄存器**，破坏 JIT 的循环优化，吞吐通常下降数倍到十倍量级，超出缓存范围的 `new` 还会持续产生 GC 压力。

---

## 28.6 `final` 与 `static` 的时机语义

| 位置 | 语义 | 生效时机 |
|---|---|---|
| `final class` | 不可被继承 | 编译期报错 + 类文件 `ACC_FINAL` |
| `final` 方法 | 不可被重写 | 编译期报错；运行期仍是 `invokevirtual`，但 JIT 可去虚化 |
| `final` 字段 | 只能赋值一次（构造器/声明处/静态块） | 编译期检查；JLS 17.5 另有**运行期可见性保证** |
| `static` 字段 | 属于类 | 类初始化 `<clinit>` 时赋值，与对象创建无关 |
| `static final` 常量 | 编译期常量 | 若初始化表达式是常量表达式，则**调用方字节码里直接内联字面量** |

`final` 字段的可见性保证要精确表述：**构造器正常结束之后，其他线程通过正确发布得到的引用读取该 `final` 字段，一定能看到构造器中的写入值（含该字段所指向对象的字段）**，无需额外同步。但引用若通过数据竞争（data race）拿到，保证退化。两个前提容易被忽略：`final` 字段的写入必须发生在构造器返回之前（`this` 在构造期间逸出则保证失效）；该保证与 `volatile` 无关，是 JMM 单独定义的一条语义，也是不可变对象能免同步安全发布的根因（与第 30 章 30.5 节互为印证）。

> [!NOTE]
> 面试官会追问"`final` 字段是不是就不用 `volatile` 了"。答案分两种情形：如果对象**本身**是被安全发布出去的（`final` 字段、`volatile` 引用、`synchronized`、`static` 初始化、并发容器），则 `final` 字段无需 `volatile`；如果引用本身没有安全发布路径，`final` 字段只保证"构造器内的值不撕裂"，不保证引用可见——发布路径的问题必须由发布方式解决。

`static final` 的内联陷阱：`public static final int MAX = 100;` 会被编译进**调用方**字节码，改动后只重编译常量所在类，调用方不重编译仍读到旧值。规避办法是改用 `static final Integer`（非常量表达式）或在构建流程里全量重编译。

---

## 28.7 异常体系、`finally` 与抑制异常

三分体系的关键差别不在"是什么"，而在**谁承担处理责任**：

| 类型 | 直接父类 | 编译期强制 | 设计意图 | 典型成员 |
|---|---|---|---|---|
| `Error` | `Throwable` | 否 | 程序无法也不应恢复的 JVM 级故障 | `OutOfMemoryError`、`StackOverflowError`、`NoClassDefFoundError` |
| `RuntimeException` | `Exception` | 否 | 编程错误（调用方可避免） | `NullPointerException`、`IndexOutOfBoundsException` |
| 其他 `Exception` | `Exception` | 是（受检） | 可预期、调用方应处理的外部条件 | `IOException`、`SQLException` |

受检异常的设计争议：它试图把错误处理编进类型系统，代价是**签名污染与层层包裹**，且在 Lambda/Stream 中几乎无法使用；主流 API（Spring 的 `DataAccessException`、多数 RPC 框架）把外部异常统一转成非受检异常，责任移交给全局异常处理器。它并非无用——资源型 API（`Closeable`）靠它强制释放资源。

### 28.7.1 `finally` 里的 `return` 为什么吞异常与返回值

```text
public static int f() {   javac 的等价结构：
    int x = 1;            int x = 1;  Throwable saved = null;
    try {                 try {
        return x;   ────►     int tmp = x;      // 返回值先备份到临时槽位
    } finally {               goto FINALLY;
        x = 2;            } catch (Throwable e) { saved = e; goto FINALLY; }
        return x;         FINALLY: x = 2;  return x;
    }                     // ← 直接 return：覆盖返回值，且 saved 再无人 athrow ⇒ 异常被吞
}
```

结论分三条，必须分开说：

1. `finally` 中的 `return` **覆盖** `try` 的返回值：`f()` 返回 2，不是 1，因为返回值已在进入 `finally` 前被复制到临时槽位，而 `finally` 里的 `return x` 又读了一次被改过的 `x`。
2. `finally` 中的 `return` **吞掉** `try` 抛出的异常：字节码里保存异常引用的局部变量存在，但控制流走了 `return` 而不再 `athrow`，异常静默消失——这是最危险的一类 bug。
3. 若 `finally` 只改字段（`this.x = 2`）而不 `return`，`try` 的 `return x` 返回的是备份的**旧值**，字段却已是新值；引用类型返回的是备份的引用，对象内部状态变更照样可见。这就是"改返回值无效、改对象状态生效"的原因。

> [!WARNING]
> 编译器不禁止 `finally` 里写 `return`（只给告警）。要保证异常不丢，就不要在 `finally` 里 `return`/`break`/`continue`/`throw`——需要保留原异常就用 `try-with-resources`。

### 28.7.2 `try-with-resources` 与抑制异常

`try (Resource r = open()) { ... }` 的字节码形态：资源初始化在 `try` 之前，`try` 体与隐式的 `close()` 都在异常表的覆盖范围内。当 `try` 体抛 `A`、随后 `close()` 抛 `B` 时：若 `A` 存在则调用 `A.addSuppressed(B)` 并继续抛 `A`，否则抛 `B`。这就是抑制异常（suppressed exception）——`B` 不丢失但不替换主异常，要用 `getSuppressed()` 或主异常的 `printStackTrace` 才能看到。

因此 `try-with-resources` 里若两者都抛异常，日志必须看**主异常**的 `printStackTrace`（它才会打印 suppressed 列表），只打 `close()` 的异常会丢掉主异常。

`OutOfMemoryError` 与 `StackOverflowError` **都可以被 `catch`**——它们是普通对象，只是捕获后能做的事极其有限（堆或栈仍处于耗尽状态，日志与序列化本身都可能再失败）。两条工程结论：不要写 `catch (Throwable t) {}` 把 `Error` 一并吞掉；在连接池、批量任务这类"元素级失败不应拖垮整体"的场景，捕获后快速释放资源并退出当前任务是可接受的兜底，但不能指望继续长期服务。

---

## 28.8 反射与动态代理

`Class` 对象的三种获取方式与副作用差异：`X.class` 不触发类初始化；`obj.getClass()` 需要已有实例；`Class.forName("X")` **默认触发类初始化**（`initialize=false` 可关闭），而 `ClassLoader.loadClass` 只加载不初始化。这是"静态块什么时候跑"的经典考法。

反射的性能代价由四部分构成：方法查找（`getDeclaredMethods` 每次返回**拷贝**）、每次调用都做的访问检查（`setAccessible(true)` 可跳过）、参数装箱与 `Object[]` 分配、以及**无法内联**（目标方法对 JIT 不可见，失去常量传播与去虚化）。JDK 自带 `inflation` 机制：某调用点执行超过阈值（HotSpot 默认 15 次）后从 JNI 切换到生成字节码的 `MethodAccessor`，开销降一个数量级。框架坚持用反射，是因为它换来的**在未知类型上延迟绑定**的能力无可替代——注解扫描、依赖注入、序列化、AOP 都建立在"运行期才知道具体类型"之上；若反射成为热点，可用 `MethodHandle` 或运行期生成字节码（LambdaMetafactory）替代。

| 维度 | JDK 动态代理（`java.lang.reflect.Proxy`） | CGLIB（第三方字节码生成） |
|---|---|---|
| 实现基础 | 运行期生成实现指定接口的类，方法体统一转发到 `InvocationHandler` | 继承目标类并生成子类，方法体转发到 `MethodInterceptor` |
| 代理前提 | 目标**必须实现接口** | 目标类**不能是 `final`**，方法不能是 `final`/`private`/`static` |
| 局限 | 只能代理接口中声明的方法，无法代理类的自有方法 | 无法代理 `final` 方法（不报错，只是**不被增强**，静默失效） |
| 构造器 | 代理类通过 `Proxy` 的规范构造器接收 handler | 必须调用父类构造器，故目标类需有可访问的无参构造器 |
| 调用开销 | `InvocationHandler.invoke` 参数含 `Method` 对象，反射调用链较长 | 生成的是直接字节码调用，通常更快 |
| 依赖 | JDK 自带 | 需引入字节码库（Spring 已内置重打包版本） |

Spring AOP 的选择规则：目标类实现了接口且未强制 `proxyTargetClass = true` 时用 JDK 动态代理，否则用 CGLIB。因此 `@Transactional` 不生效的一种经典原因是**类内 `this` 自调用**——根本不经过代理对象，与代理类型无关，但排查时最容易被误判成"CGLIB 没生效"。

> [!IMPORTANT]
> 面试官会接着问"JDK 17 之后 CGLIB 还能用吗"。要点是：CGLIB 靠子类化实现，在强封装趋势下某些内部结构仍需 `--add-opens`；Spring Framework 6 已把 AOP 默认代理策略统一为 CGLIB 并自带字节码生成。回答要说清"这是字节码生成能力的问题，不是动态代理概念的问题"。

---

## 28.9 泛型擦除与桥接方法

擦除的证据是一行代码：`new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()` 为 `true`，且 `getTypeParameters()` 只有 `E` 这样的占位符。擦除的动机是**二进制兼容**：`List` 与 `List<String>` 的类文件格式不变，旧代码无需重编译；JVMS 只在 `Signature` 属性里额外记录泛型签名供反射读取。

擦除带来的限制可以一句话统一解释——**凡是需要运行期知道实参类型的写法都不合法**：

| 限制 | 编译错误 | 根因 |
|---|---|---|
| `new T[]`、`new List<String>[10]` | generic array creation | 数组是**协变且运行期携带元素类型**的，擦除后无法做 `ArrayStoreException` 检查 |
| `x instanceof T`、`x instanceof List<String>` | illegal generic type for instanceof | 运行期没有 `T`，`instanceof List<String>` 无法区分实参 |
| `static T f()`、`static` 字段用类型参数 | cannot be referenced from a static context | 静态成员属于类级，类初始化时没有实参 |
| `catch (MyException<T> e)` | generic class may not extend Throwable | 异常表按类登录，擦除后无法区分 |
| `new T()` | type parameter cannot be instantiated | 构造器目标未知 |

桥接方法（bridge method）的产生原因：`class Box<T> { void set(T t) }` 的子类 `class StringBox extends Box<String> { void set(String s) }`，擦除后父类签名是 `set(Object)`、子类签名是 `set(String)`——**参数类型不同，不构成重写**，多态失效。编译器于是在子类合成一个 `set(Object)`，内部 `checkcast` 后委派给 `set(String)`，标记为 `ACC_BRIDGE | ACC_SYNTHETIC`。它在反射中的两个可考点：`getDeclaredMethods()` 会返回它（需用 `Method.isBridge()` 过滤）；`getMethod("set", Object.class)` 能命中它而不是报 `NoSuchMethodException`。

PECS（Producer Extends, Consumer Super）的判据是**类型参数的读写方向**：`List<? extends Number>` 能 `get` 出 `Number` 但不能 `add`（编译器不知道真实元素类型）；`List<? super Integer>` 能 `add(Integer)` 但 `get` 只得到 `Object`。记忆锚点是"extends 只读、super 只写"，典型应用是 `Collections.copy(List<? super T> dest, List<? extends T> src)`。

> [!TIP]
> 追问"擦除能不能绕过"。标准答案是：运行期拿不到实参，但可以通过**继承泛型父类时保留签名**（如 `class StringList extends ArrayList<String>`），再用 `getGenericSuperclass()` 读出 `ParameterizedType` 的实参——这正是 `TypeReference`/`TypeToken` 这类模式（Jackson 的 `TypeReference`、Gson 的 `TypeToken`）能工作的原因。它能工作不是因为擦除被绕过，而是因为**匿名子类的类文件里写死了父类的泛型签名**。

---

## 28.10 序列化

`Serializable` 授权 JVM 用反射遍历对象图生成字节流，流格式的关键字段可以直接背：

```text
序列化流头部（ObjectOutputStream）：
   AC ED 00 05                    ← magic 0xACED + 版本 5
   目标类描述符：类名、serialVersionUID(8B)、字段数量与类型、标志位
   随后是各字段值（按字段排序，transient 字段不写入）
```

`serialVersionUID` 的默认值是编译器按类名、修饰符、接口、字段与方法签名计算的哈希，**任何一项变化都会让它改变**；反序列化时流中的值与本地类不一致就抛 `InvalidClassException: local class incompatible`。所以需要持久化或跨服务传输的类必须**显式声明**，这样新增字段时旧数据仍能读出（新字段取默认值）。

| 关键字 / 机制 | 作用 | 易错点 |
|---|---|---|
| `transient` | 该字段不参与默认序列化，反序列化后为默认值（`null`/0/false） | 集合字段标记 `transient` 后若不做懒初始化，反序列化得到的对象里是 `null` |
| `serialVersionUID` | 版本兼容校验 | 不显式声明，则改动稍大便不兼容；声明后是**放弃严格校验**，语义变化需自行保证 |
| `writeObject`/`readObject` | 自定义序列化逻辑（`private` 且签名固定，由反射调用） | `readObject` 会在**对象构造器之外**执行，绕过不变量校验 |
| `writeReplace`/`readResolve` | 序列化时替换对象 / 反序列化时返回单例 | 枚举天然免疫这些攻击，是单例的首选 |
| `Externalizable` | 完全自定义：需无参构造器 + `writeExternal`/`readExternal` | 父类需有无参构造器，且**没有**默认字段处理，容易漏字段 |

反序列化安全问题的根因：`readObject` 是**在构造器之外执行可控代码的入口**，调用链可被设计成 gadget（工具链），因此它不是"数据可信度"问题，而是"**把字节流当代码执行**"的问题；处置方式是不要反序列化不受信任的输入、用 `ObjectInputFilter`（JDK 9+）做类白名单。现代系统倾向 JSON/Protobuf 的理由：跨语言、可读可调试、**数据结构与代码解耦**（字段缺失时取默认值而非报不兼容）、体积更小且无需反射遍历对象图，以及从根本上消除 gadget 链风险。

> [!CAUTION]
> "内部服务间用 Java 序列化所以安全"是高风险回答：只要有一处外部可控的入站字节流（缓存、MQ 报文、会话、RMI 端口），gadget 链就可能被触发。加分回答要给出治理动作：序列化入口白名单、缓存与 MQ 改用 JSON/Protobuf、RMI 端口不对外暴露。

---

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 分派机制 | 重载编译期按声明类型选签名，重写运行期按实际类型查 `vtable` 同槽位 | 答"重载也是运行期"或说"`final` 方法不走 `invokevirtual`" |
| 字段隐藏 | 字段按引用声明类型静态绑定，是 hiding 不是 overriding | 认为子类同名字段会"覆盖"父类字段 |
| 装箱缓存 | `-128~127` 是 JLS 强制下限，其余靠 `IntegerCache` | 以为所有包装类都有相同缓存范围 |
| `finally` | 字节码层面 `finally` 的 `return` 直接覆盖返回值并放弃 `athrow` | 答"`finally` 一定会执行所以返回值是 `finally` 里的" |
| 泛型擦除 | 擦除为二进制兼容服务，桥接方法补救重写的多态性 | 认为 `getClass()` 能看出实参类型 |
| 序列化 | 显式声明 `serialVersionUID`，`readObject` 是代码执行入口 | 认为内部服务间用原生序列化就安全 |

## 自测题

**Q1**（计算）64 位 HotSpot、开启压缩指针、堆 16 GB。`class Point { int x; int y; }` 与 `class Node { Point p; int id; }` 各占多少字节？数组 `new Point[10]` 占多少？

<details><summary>答案与解析</summary>

`Point`：对象头 12 B（Mark Word 8 + Klass Pointer 4），两个 `int` 共 8 B，合计 20 B，对齐到 **24 B**。`Node`：头 12 B + 引用 `p` 4 B + `int id` 4 B = 20 B，对齐到 **24 B**。

`new Point[10]`：数组头 = 12 B + 4 B `length` = 16 B，元素区 10 × 4 B = 40 B，合计 56 B，已是 8 的倍数，故 **56 B**。

错在哪：把 `Point` 算成 16 B（漏掉对齐填充）、把数组头算成 12 B（漏掉 `length` 字段）、或把数组元素算成 `Point` 的完整大小（数组只存引用，不是内联对象）。面试官追问方向：`Point[]` 与 `Point` 的局部性差异（数组是引用跳转，`int[]` 才是连续内存）；再把 `Point` 改成三个 `boolean` 一个 `long`，考察你是否知道 JVM 会按宽度重排字段。
</details>

**Q2**（判断 + 解释）下面代码打印什么？逐行给出理由。

```java
Integer a = 127, b = 127;
Integer c = 128, d = 128;
Integer e = new Integer(127);
Long f = 127L, g = 127L;
System.out.println(a == b);
System.out.println(c == d);
System.out.println(a == e);
System.out.println(a == f);
System.out.println(a.equals(f));
```

<details><summary>答案与解析</summary>

`true`（127 在 `IntegerCache` 内，`valueOf` 返回同一实例）；`false`（128 超出默认上限，各自 `new`）；`false`（`new` 绕过 `valueOf`）；**编译错误**（`Integer` 与 `Long` 是无关类型，`==` 不可比较——写成 `a.equals(f)` 才合法且返回 `false`，因为 `Integer.equals` 只接受 `Integer`）。

错在哪：把第 4 行答成 `false`（那是对 `Object` 引用比较的直觉，但这里根本不过编译）；把第 5 行答成 `true`（以为 `equals` 会做数值比较——`Integer.equals` 只接受 `Integer`）。面试官追问：`-XX:AutoBoxCacheMax=1000` 后哪些输出改变；`Map<String,Integer>` 取值与 `== 0` 比较时为什么可能 NPE。
</details>

**Q3**（推导）解释为什么下面程序输出 `200` 和 `0`，并说明这暴露的是 JVM 行为还是纯语法行为。

```java
static int f() {
    int x = 1;
    try {
        return x;      // 返回值被复制到临时槽位
    } finally {
        x = 2;         // 只改局部变量 → 不影响已复制的返回值
        // 若这里写 return x; 则既覆盖返回值、又让暂存的异常再无人 athrow
    }
}
```

<details><summary>答案与解析</summary>

实际输出是 `100` 和 `0`。`value()` 返回 **100**：`try` 的 `return x` 在进入 `finally` 前把 `x` 的值复制到返回值临时槽位，`finally` 里改的是**局部变量** `x`，不是那个临时槽位。`risky()` 返回 **0**：`finally` 中的 `return 0` 直接返回，`try` 抛出的 `IllegalStateException` 被静默吞掉。

这正是"改局部变量无效、改字段有效"的分界。若 `value()` 改成 `this.x = 200`（字段），`try` 里 `return x` 的临时槽位仍是旧值 100，字段却已是 200——所以要看清"返回值复制"与"字段写入"是两件事。

错在哪：把 `value()` 答成 200（认为 `finally` 一定影响返回值）；把 `risky()` 答成抛异常（漏掉 `finally` 的 `return` 改变了控制流）。面试官追问：这段代码在字节码里哪些位置出现 `athrow`、为什么保存异常的局部变量存在却未被使用；如果 `finally` 里是 `throw new RuntimeException()` 会怎样。
</details>

**Q4**（设计）你要为一个"用户对象做缓存的 `HashMap` 键"的类写 `equals`/`hashCode`。给出实现，并说明为什么用 `getClass()` 而不用 `instanceof`，以及为什么两个方法必须覆盖同一组字段。

<details><summary>答案与解析</summary>

实现即 28.3 节的模板：`==` 短路、`getClass()` 类型比较、逐字段比较（基本类型 `==`、引用类型 `Objects.equals`、数组 `Arrays.equals`）；`hashCode` 用同一组字段按 `31 * result + field` 或 `Objects.hash` 组合。

用 `getClass()`：保证**对称性**。若父类用 `instanceof`，则 `parent.equals(child)` 可能为 true 而 `child.equals(parent)` 为 false（子类多出的字段参与比较），这样的对象进入 `HashSet` 后会出现"看起来相同的两个元素都留着"。若确实需要父子类可互相 equals，就必须把 `equals` 声明为 `final`。

必须覆盖同一组字段：`HashMap` 先用 `hashCode` 定位桶、再用 `equals` 在桶内确认。`hashCode` 用了 `equals` 没用的字段，`equals` 为 true 的两个对象可能落在不同桶，`get` 返回 `null`；反之 `equals` 用了 `hashCode` 没用的字段，同桶内定位后仍判为不相等。两者都表现为"`put` 成功但 `get` 取不到"。

错在哪：只写 `equals` 忘写 `hashCode`（编译器不报错，`HashSet` 去重直接失效）；用 `id` 重写 `equals` 但 `hashCode` 用 `Objects.hash(id, name)`。面试官追问：字段可变时怎么办（不放入哈希容器，或改用不可变键）；`Objects.hash` 在热路径上的开销。
</details>

**Q5**（辨析）"`List<String>` 与 `List<Integer>` 在运行期是同一个类型，所以泛型毫无价值。"请评价这句话，并说明桥接方法与 `TypeReference` 分别在什么意义上"突破了"擦除。

<details><summary>答案与解析</summary>

前半句正确但不完整：`getClass()` 相同、`getTypeParameters()` 只有占位符，都证明运行期确实没有实参类型，这是为**二进制兼容**付的代价。但"毫无价值"是错的：擦除发生在编译期检查之后，泛型把大量 `ClassCastException` 从运行期（不可预测的位置）提前到编译期（精确定位的那一行），并且让 API 自文档化、支持泛型算法复用。

桥接方法不"突破"擦除，而是**修复擦除带来的多态缺失**：`class StringBox extends Box<String>` 擦除后父类方法签名是 `set(Object)`，子类只有 `set(String)`，参数类型不同不构成重写；编译器合成 `set(Object)` 桥接方法，内部 `checkcast String` 后委派给 `set(String)`，同时保留了强转失败的运行期检查。`getDeclaredMethods()` 会看到它，需用 `isBridge()` 过滤。

`TypeReference`/`TypeToken` 也不"突破"擦除，而是利用了**类文件会记录父类的泛型签名**这一事实：`new TypeReference<List<User>>() {}` 是匿名子类，其 `Signature` 属性里写死了 `TypeReference<List<User>>`，`getGenericSuperclass()` 返回带实参的 `ParameterizedType`。所以它要求必须用匿名子类（写成 `new TypeReference<List<User>>()` 无花括号则拿不到）。

错在哪：认为桥接方法可以用 `instanceof T` 的替代（不能）；认为 `TypeToken` 靠反射拿到方法局部变量的泛型（不可能，签名只存在于类声明处）。面试官追问：`new T[]` 为什么非法（数组协变 + 运行期 `ArrayStoreException` 需要元素类型）；`List<? extends Number>` 为什么只能读。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 为什么说重载是编译期绑定、重写是运行期绑定？JVM 里具体靠什么实现？ | 方法调用指令与 `vtable` | 错误回答："重载在编译期把方法体替换进去"——把"选定签名"说成了内联。加分点：五条 `invoke*` 指令与"`vtable` 复制父类表后同槽位覆盖" | 28.1 |
| 子类与父类定义了同名字段，用父类引用访问取哪个？那一个只含两个 `int` 的对象占多少字节？ | 字段隐藏 + 对象布局与对齐 | 错误回答："取子类的，和重写一样"——字段按声明类型静态绑定，没有 `vtable` 参与；若说"8 字节（两个 int）"则漏掉 12 B 对象头与 8 字节对齐。加分点：给出 `12 + 8 = 20 → 24 B` 的算式 | 28.1.2 / 28.2 |
| 为什么建议堆不要刚超过 32 GB？ | 压缩指针原理 | 错误回答："超过 32 GB 压缩指针就失效"就停住——说不出压缩指针靠 8 字节对齐右移 3 位。加分点：`2³² × 8 = 32 GB` 的推导与"对象头与引用各多 4 B"的代价 | 28.2 |
| 只重写 `equals` 不重写 `hashCode` 会发生什么？为什么？ | 契约与 `HashMap` 查找流程 | 错误回答："能正常工作，只是效率低"——实际上 `HashSet` 去重与 `HashMap` 查找会直接失效。加分点：说清"先按 `hashCode` 定位桶再按 `equals` 确认"两步各自的失败形态 | 28.3 |
| `Integer a = 128, b = 128; a == b` 是什么结果？为什么是这个范围？ | 自动装箱缓存 | 错误回答："`true`，因为 Integer 有缓存"——不记得默认上限 127。加分点：JLS 5.1.7 的下限要求、`AutoBoxCacheMax` 可调、`Float`/`Double` 无缓存 | 28.5 |
| `finally` 里写 `return` 会怎样？为什么？ | 控制流与字节码 | 错误回答："`finally` 总会执行，所以返回 `finally` 的值"——方向对但说不出返回值的**复制时机**。加分点：指出 `finally` 的 `return` 让保存异常引用的槽位永不被 `athrow`，异常被吞 | 28.7.1 |
| `try-with-resources` 里 `close()` 也抛异常，哪个异常才是被抛出的？ | 抑制异常机制 | 错误回答："后抛出的覆盖先抛出的"——实际是先抛出的为主异常，`close()` 的进 `getSuppressed()`。加分点：`addSuppressed` 的插入条件（仅当已有主异常时） | 28.7.2 |
| `OutOfMemoryError` 能被 `catch` 吗？ | `Error` 与恢复可行性 | 错误回答："不能，`Error` 不能被捕获"——它是普通对象，可以捕获。加分点：区分"语法上可捕获"与"捕获后不可靠"，并说明为何不该 `catch (Throwable)` 吞掉 | 28.7 |
| Spring AOP 什么时候用 JDK 动态代理、什么时候用 CGLIB？ | 代理机制与限制 | 错误回答："Spring 默认全用 CGLIB"或"有接口就一定用 JDK 代理"——要区分版本与 `proxyTargetClass`。加分点：指出 `final` 方法被静默跳过、类内自调用绕过代理 | 28.8 |
| `List<String>` 和 `List<Integer>` 的 `getClass()` 相同吗？泛型信息去哪了？ | 类型擦除 | 错误回答："不相同，运行时能拿到实参"或"擦除后泛型就没用了"。加分点：`Signature` 属性、桥接方法、`TypeReference` 的工作原理 | 28.9 |
| 为什么 `new T[]` 和 `x instanceof T` 都不允许？ | 擦除的后果 | 错误回答："Java 语法不允许，没有原因"——根因是数组**协变且运行期携带元素类型**，需要能抛 `ArrayStoreException`。加分点：`instanceof List<String>` 同样非法 | 28.9 |
| 不显式声明 `serialVersionUID` 有什么风险？ | 序列化兼容性 | 错误回答："没影响，编译器会给一个"——默认值随字段/方法签名变化，一改就 `InvalidClassException`。加分点：显式声明的代价是放弃严格校验，语义变化需自行保证 | 28.10 |
| Java 原生序列化为什么不安全？ | `readObject` 与 gadget 链 | 错误回答："只要不走网络就安全"——任何外部可控入站字节流都危险。加分点：给出 `ObjectInputFilter` 白名单、缓存/MQ 改用 JSON/Protobuf 的治理动作 | 28.10 |

## 延伸阅读

- Oracle《Java 语言规范》第 15.12 节（方法调用与重载解析）、第 17.5 节（`final` 字段语义）、第 5.1.7 节（装箱转换）。
- Oracle《Java 虚拟机规范》第 2.11 节（类型擦除与桥接方法）、第 6.5 节（`invokevirtual`/`invokeinterface`/`invokedynamic` 的解析过程）。
- Oracle Java SE 文档：`Object.equals`/`hashCode` 契约、`Serializable` 与 `ObjectInputFilter`、`Proxy` 与 `InvocationHandler`；OpenJDK HotSpot 源码说明中的 `oops/oop.hpp`（对象头布局）、`compressedOops` 基址与移位策略、`IntegerCache`/`StringTable`。
- 与本书的衔接：`HashMap` 的哈希扰动与树化见第 29 章 29.2 节；`final` 域与安全发布见第 30 章 30.5 节；对象分配、TLAB 与 Metaspace 见第 32 章 32.1 / 32.2 节。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch28/  

