语言层语法只是表象,本章把 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 阶段已校验完毕。

警告

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

28.1.2 继承:vtable 的构造与字段隐藏

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

1
2
3
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首次执行时链接 CallSiteLambda、字符串拼接(JDK 9+)否(绑定到 MethodHandle)

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

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

提示

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


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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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:ObjectAlignmentInBytes8对齐粒度;提高它可扩大压缩指针的寻址上限;堆 > 32 GB 时压缩被关闭

重要

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


28.3 ==、equals 与契约

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

契约精确含义被违反的典型后果
反身性x.equals(x) 恒为 truelist.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 错导致连桶都进不去。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@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));
}

注意

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[]
循环内 +每次迭代新建 StringBuilderO(n²) 拷贝,必须显式用 StringBuilder

警告

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


28.5 自动装箱与缓存

1
2
3
4
5
6
7
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 数组
Character0 ~ 127CharacterCache
BooleanTRUE / 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,因为混用基本类型时先拆箱再比较。

小心

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 节互为印证)。

注意

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

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


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

三分体系的关键差别不在"是什么”,而在谁承担处理责任:

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

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

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

1
2
3
4
5
6
7
8
9
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 返回的是备份的旧值,字段却已是新值;引用类型返回的是备份的引用,对象内部状态变更照样可见。这就是"改返回值无效、改对象状态生效"的原因。

警告

编译器不禁止 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 没生效"。

重要

面试官会接着问"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)。

提示

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


28.10 序列化

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

1
2
3
4
序列化流头部(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 链风险。

小心

“内部服务间用 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] 占多少?

答案与解析

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 会按宽度重排字段。

Q2(判断 + 解释)下面代码打印什么?逐行给出理由。

1
2
3
4
5
6
7
8
9
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));
答案与解析

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。

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

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

实际输出是 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() 会怎样。

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

答案与解析

实现即 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 在热路径上的开销。

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

答案与解析

前半句正确但不完整: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> 为什么只能读。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
为什么说重载是编译期绑定、重写是运行期绑定?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 节。