第 28 章 Java 语言与对象模型
语言层语法只是表象,本章把 Java 的对象模型、分派机制与语言陷阱落到 JVM 字节码与内存布局上。
学习目标
读完本章,你应当能够:
- 用 JVMS 的方法调用指令集解释"重载编译期绑定、重写运行期绑定"的机制根因,并说出
vtable在类加载期是如何构造的。 - 计算一个 Java 对象与数组在开启指针压缩下的字节数,并推导 32 GB 堆边界是怎么算出来的。
- 说明违反
equals/hashCode契约后HashMap查找失败发生在哪一步,并写出正确重写模板。 - 辨析
final、static、String常量池、自动装箱缓存各自"在什么时机生效、为什么是这个时机"。 - 从字节码层面解释
finally里return吞异常、try-with-resources的抑制异常、桥接方法的产生原因。 - 判断 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“的方法替换到对应槽位,新方法追加到尾部。因此子类中重写的方法与父类方法占用同一个槽位下标。
| |
字段完全不同:字段访问在编译期按引用的声明类型生成 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)。
提示
Lambda、方法引用与字符串拼接都走 invokedynamic:首次执行时链接出一个 CallSite,之后直接走 MethodHandle,因此它能比"编译期硬编码调用"更晚决定调用目标。这也是接口默认方法与记录类相关字节码的基础设施。
28.2 对象的内存布局与大小计算
| |
一个可直接背下来的算例: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 时压缩被关闭 |
重要
堆 > 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 错导致连桶都进不去。
| |
注意
Objects.hash(...) 内部做 Arrays.hashCode(new Object[]{...}),每次调用都有一次数组分配与多轮装箱。高频路径(对象当 HashMap 键、每秒百万次)建议手写 31 * result + field;但不要为了性能牺牲与 equals 字段的一致性。
28.4 String 不可变性与字符串常量池
不可变的四个理由各自对应一处具体收益:
- 常量池共享安全:同一份字面量被多个类共享,若可变,一处修改会污染所有持有者。
hashCode可缓存:String用一个非volatile的hash字段懒计算并缓存,不可变才让这个缓存永远有效。- 线程安全与传参安全:不可变对象天然可安全发布;文件路径、类名、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 |
警告
“JDK 9 之后字符串拼接一定比 StringBuilder 快"是错误结论。invokedynamic 的方案免掉了中间 StringBuilder 对象、且能精确预算长度,在一次性拼接少量片段时更优;但循环累积场景编译器无法提升到循环外,仍然每次迭代一次链接调用与一次分配,性能与显式 StringBuilder 同级甚至更差。面试官追问"那为什么还要 StringBuilder",答案就是循环与需要 delete/insert 的场景。
28.5 自动装箱与缓存
| |
| 包装类 | 缓存范围 | 缓存实现 |
|---|---|---|
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,因为混用基本类型时先拆箱再比较。
小心
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 与抑制异常
三分体系的关键差别不在"是什么”,而在谁承担处理责任:
| 类型 | 直接父类 | 编译期强制 | 设计意图 | 典型成员 |
|---|---|---|---|---|
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 为什么吞异常与返回值
| |
结论分三条,必须分开说:
finally中的return覆盖try的返回值:f()返回 2,不是 1,因为返回值已在进入finally前被复制到临时槽位,而finally里的return x又读了一次被改过的x。finally中的return吞掉try抛出的异常:字节码里保存异常引用的局部变量存在,但控制流走了return而不再athrow,异常静默消失——这是最危险的一类 bug。- 若
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 用反射遍历对象图生成字节流,流格式的关键字段可以直接背:
| |
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(判断 + 解释)下面代码打印什么?逐行给出理由。
| |
答案与解析
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 行为还是纯语法行为。
| |
答案与解析
实际输出是 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 节。