泛型、反射、注解与异常:从类型约束到故障边界
学完本章,你能把“类型安全、框架元数据和异常处理”串成一条可验证的执行链:编译器先保障泛型,再以擦除兼容旧字节码;框架读取元数据后组织代理与调用;异常决定控制流,却不能替代一致性设计。
1. 学习目标与版本边界
- 能用 WMS(仓储管理系统)批量导入、导出接口解释泛型不变性、上界、下界和 PECS(生产者扩展消费者超类原则)。
- 能用
javac(Java 编译器)和javap(字节码反汇编工具)给出类型擦除、强制转换与 bridge method(桥接方法)的证据。 - 能说明 Reflection(反射)、MethodHandle(方法句柄)、动态代理与注解如何形成 Spring(Java 应用框架)和 MyBatis(持久层框架)的执行模型。
- 能按“调用者能否合理恢复”选择异常类型,并说明支付、履约、库存和异步任务的一致性边界。
| 版本或组件 | 本章可依赖的结论 | 不能误读为 |
|---|---|---|
| JDK(Java 开发工具包)8 | 泛型擦除、Signature(泛型签名)属性、反射与 JDK Dynamic Proxy(JDK 动态代理)模型已成立。 | 对所有私有成员都能随意反射访问。 |
| JDK(Java 开发工具包)9+ | 模块强封装会使 setAccessible(true) 抛出 InaccessibleObjectException(不可访问对象异常);优先使用 trySetAccessible() 判断。 | setAccessible(设置可访问标志)永远有效。 |
| JDK(Java 开发工具包)18+ | JEP(JDK 增强提案)416 把核心反射的内部实现迁到 MethodHandle(方法句柄)之上,公开 Reflection(反射)API(应用程序接口)语义不变。 | “反射一定慢”或“反射已等同于直接调用”。 |
| Spring(Java 应用框架)事务 | 默认未处理的 RuntimeException(运行时异常)和 Error(严重错误)触发回滚;受检异常默认不触发,可用 rollbackFor(指定回滚异常)覆盖。 | 这是 Java(编程语言)语言的异常规则,或捕获后返回成功仍会自动回滚。 |
2. 泛型:用编译期安全换取二进制兼容
2.1 不变性、通配符与 PECS(生产者扩展消费者超类原则)
泛型默认不变:即使 SkuRow(库存行)继承 ImportRow(导入行),List<SkuRow>(库存行列表)也不是 List<ImportRow>(导入行列表)。否则调用方可以向前者写入另一种 ImportRow(导入行),读取时便会破坏类型承诺。通配符把“能读什么、能写什么”显式写进 API(应用程序接口)。
| WMS(仓储管理系统)操作 | 合适形态 | 允许的核心操作 | 原因 |
|---|---|---|---|
| 读取不同来源的库存行导入 | List<? extends ImportRow> | 按 ImportRow(导入行)读取;除 null(空值)外不能安全写入。 | 来源是 producer(生产者),只承诺至少是 ImportRow(导入行)。 |
| 把校验后的库存行写入父类收集器 | List<? super SkuRow> | 写入 SkuRow(库存行);读取只能视为 Object(对象基类)。 | 目标是 consumer(消费者),能够消费 SkuRow(库存行)或其父类。 |
| 同时读取、写入且需精确类型 | List<SkuRow> | 读写 SkuRow(库存行)。 | 这是真正的双向不变容器。 |
| 旧系统未参数化接口 | raw type(原始类型)List | 编译器只给 unchecked warning(未检查警告)。 | 兼容入口,不应扩散到领域层。 |
WMS(仓储管理系统)数据演绎。 供应商 A 返回 List<SkuRow>(库存行列表),供应商 B 返回 List<ColdSkuRow>(冷链库存行列表)。批量导入只消费其共同字段,所以参数写为 ? extends ImportRow(上界通配符);导出器接收 ? super SkuRow(下界通配符),可以把标准化后的 SkuRow(库存行)写入 List<ImportRow>(导入行列表)或 List<Object>(对象列表)。不能把 A 的列表赋给 List<ImportRow>(导入行列表),因为随后可能被写入一个不带 SKU(库存单位)字段的 ManualRow(人工行)。
类型参数 T(类型参数)在方法内部会遇到 wildcard capture(通配符捕获):List<?>(未知列表)里的 ?(未知实参)不是 Object(对象基类),而是编译器为这一次调用捕获的某个未知但固定类型。因而不能把 Object(对象基类)写回该列表;需要在不改变元素类型的场景,可让私有泛型辅助方法接收 List<T>(类型参数列表)以捕获该类型。泛型数组限制来自数组在运行期按组件类型检查、泛型实参却已擦除的冲突:不能创建 new T[10](创建类型参数数组),也不能安全地创建 new List<String>[10](创建字符串列表数组);应改用 List<List<String>>(字符串列表的列表)或由调用者提供数组构造器。
import java.util.ArrayList;
import java.util.List;
public class WmsPecsDemo {
static class ImportRow { }
static class SkuRow extends ImportRow {
final String sku;
SkuRow(String sku) { this.sku = sku; }
}
static class ColdSkuRow extends SkuRow {
ColdSkuRow(String sku) { super(sku); }
}
static List<SkuRow> normalize(List<? extends ImportRow> source) {
List<SkuRow> result = new ArrayList<>();
for (ImportRow ignored : source) {
// 导入阶段只读取父类视图,避免把不兼容行写回来源列表。
result.add(new SkuRow("normalized"));
}
return result;
}
static void export(List<? super SkuRow> target, SkuRow row) {
target.add(row);
}
public static void main(String[] args) {
List<ColdSkuRow> source = List.of(new ColdSkuRow("C-1"));
List<ImportRow> target = new ArrayList<>();
export(target, normalize(source).get(0));
System.out.println(target.size());
}
}flowchart LR
A["供应商 A:List<SkuRow>(库存行列表)"] --> B["? extends ImportRow(上界通配符)"]
C["供应商 B:List<ColdSkuRow>(冷链库存行列表)"] --> B
B --> D["标准化:只读 ImportRow(导入行)"]
D --> E["List<SkuRow>(库存行列表)"]
E --> F["? super SkuRow(下界通配符)"]
F --> G["导出目标:List<ImportRow>(导入行列表)"]图中 A、C 是生产者,先在 B 汇总为共同父类视图;D 不向来源写入,避免破坏具体列表;E 生成统一行;F、G 是消费者,允许写入标准行。误区是把“能读取子类”误说成“能把子类列表当父类列表写入”。
热门面试题
问题(基础题):为什么
List<SkuRow>(库存行列表)不能赋值给List<ImportRow>(导入行列表)?- 考点:不变性、写入安全、协变边界。
- 回答思路:先构造父类列表可写入另一子类的反例,再说明通配符把只读或只写意图显式化。
- 详细答案:若该赋值被允许,接收方就能向
List<ImportRow>(导入行列表)加入任意ImportRow(导入行),原持有者再按SkuRow(库存行)读取便会失败。Java(编程语言)选择不变性来在编译期阻断这种污染。导入源只读时用? extends ImportRow(上界通配符);导出目标只写标准行时用? super SkuRow(下界通配符)。这不是语法偏好,而是 API(应用程序接口)对数据流方向的约束。 - 进阶追问:
? extends ImportRow(上界通配符)为什么不能安全add(添加)? - 进阶回答:它可能实际指向
List<ColdSkuRow>(冷链库存行列表),写入普通SkuRow(库存行)会破坏该列表的元素约束。编译器不知道精确实参,只允许写入null(空值);读取则可安全上转为ImportRow(导入行)。
问题(原理题):如何用 PECS(生产者扩展消费者超类原则)设计仓储批量 API(应用程序接口)?
- 考点:producer(生产者)、consumer(消费者)、通配符方向。
- 回答思路:按数据从哪里读、向哪里写判断,不按“参数看起来复杂”判断。
- 详细答案:批量校验器从多种供应商行读取共同字段,参数应是
Iterable<? extends ImportRow>(上界可迭代对象);导出器向父类收集器写统一库存行,参数应是Collection<? super SkuRow>(下界集合)。若方法既要读取又要写同一列表,则保留精确List<SkuRow>(库存行列表)。这会让调用者无法把来源和目标混用,错误在编译期暴露。 - 进阶追问:能否返回
List<? extends ImportRow>(上界列表)提升灵活性? - 进阶回答:通常不建议。返回通配符会把不确定性传给调用者,使其难以继续操作。方法内部知道精确结果类型时应返回
List<SkuRow>(库存行列表);通配符主要适合消费外部输入。
问题(项目题):原始类型如何让 WMS(仓储管理系统)导入在远处失败?
- 考点:raw type(原始类型)、unchecked warning(未检查警告)、失败延迟。
- 回答思路:说明污染发生在旧接口边界,异常发生在后续按泛型读取的位置。
- 详细答案:若适配器把未校验的 CSV(逗号分隔值)对象通过 raw type(原始类型)塞进
List<SkuRow>(库存行列表),写入点只出现警告;稍后库存扣减代码取出元素时,编译器插入的转换才抛出 ClassCastException(类型转换异常)。排查要从异常栈中的读取点反向寻找 raw type(原始类型)、未经检查转换和可变参数入口;修复是在边界完成解析和校验,领域集合始终保持参数化。 - 进阶追问:为什么不能用压制警告掩盖问题?
- 进阶回答:压制只隐藏诊断,不恢复类型证明。它应局限于经人工证明安全的兼容适配器,并紧邻窄范围转换;不能放在整个类或业务包上,否则后续污染无从定位。
2.2 擦除、签名元数据与 bridge method(桥接方法)
泛型编译链是:类型检查 → 参数化类型擦为 raw type(原始类型),类型变量擦为左边第一个界 → 在读取处插入必要转换 → 为覆写多态补 bridge method(桥接方法)。例如 T(类型参数)无界时擦为 Object(对象基类),有上界时擦为最左边界;Box<T>(泛型盒子)整体则擦为 raw type(原始类型)Box(盒子类型),不是 Box<Object>(对象盒子)。这样新代码能调用旧字节码;代价是不能按不同泛型实参重载、不能 new T()(创建类型参数实例)、不能创建普通泛型数组。
flowchart LR
A["源码:Box<String>(字符串盒子)"] --> B["编译器类型检查"]
B --> C["擦除:Box<T> → raw type(原始类型)Box;T → Object(对象基类)"]
C --> D["读取处插入 checkcast(类型检查转换)"]
C --> E["子类覆写签名不一致"]
E --> F["生成 bridge method(桥接方法)"]
B --> G["Signature(泛型签名)属性保留声明信息"]
G --> H["Reflection(反射)读取 Class(类元数据)/Method(方法元信息)/Field(字段元信息)声明"]图中 C 描述执行用的擦除类型:Box<T>(泛型盒子)作为参数化类型擦成 raw type(原始类型)Box(盒子类型),其中无界 T(类型参数)再擦成 Object(对象基类);因此字节码描述符才会出现 Object(对象基类)参数,而不是一个仍带实参的 Box<Object>(对象盒子)。D 保证读出时仍符合源码承诺,F 保证经父类引用调用仍能分派到子类实现,G 则保存声明处可恢复的泛型结构。误区是“运行期完全没有泛型信息”:对象实例一般不携带实参,但类、方法、字段的 Signature(泛型签名)元数据可由 Reflection(反射)读取;若声明也不存在,运行期不能凭空推回实参。
public class GenericBridgeEvidence {
static class Box<T> {
T normalize(T value) { return value; }
}
static class StringBox extends Box<String> {
@Override
String normalize(String value) { return value.trim(); }
}
public static void main(String[] args) {
Box<String> box = new StringBox();
System.out.println(box.normalize(" WMS "));
}
}本机 JDK(Java 开发工具包)17.0.19 的实际证据如下;-c -p -s 显示擦除后的描述符,-v 显示桥接访问标志。
$ javac GenericBridgeEvidence.java
$ javap -c -p -s 'GenericBridgeEvidence$StringBox'
java.lang.String normalize(java.lang.String);
descriptor: (Ljava/lang/String;)Ljava/lang/String;
java.lang.Object normalize(java.lang.Object);
descriptor: (Ljava/lang/Object;)Ljava/lang/Object;
$ javap -v -p 'GenericBridgeEvidence$StringBox'
... ACC_BRIDGE, ACC_SYNTHETIC ...| 现象 | 擦除后实际状态 | 可保留或可恢复的信息 | 工程边界 |
|---|---|---|---|
List<String>(字符串列表)实例 | 运行时主要是 List(列表接口)对象。 | 声明它的字段或方法可有 Signature(泛型签名)。 | 不能用对象自身区分 List<String>(字符串列表)与 List<Integer>(整数列表)。 |
| 泛型数组 | new T[10](创建类型参数数组)不安全。 | 可创建可具体化类型数组,如 List<?>[](未知列表数组)。 | 数组运行期检查与泛型擦除模型冲突。 |
| 可变参数泛型 | 编译器通常创建擦除数组。 | @SafeVarargs(安全可变参数标注)只能声明调用体不污染或泄漏数组。 | 不把可变参数数组存储、返回或写入异构值。 |
| TypeReference(类型引用)/TypeToken(类型令牌) | 匿名子类的父类声明可保存 ParameterizedType(参数化类型)。 | 可读取 List<SkuRow>(库存行列表)这一声明。 | 不能从普通 new TypeReference<T>()(创建类型引用)恢复调用者的未知 T(类型参数)。 |
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.List;
public class TypeReferenceDemo {
static abstract class TypeReference<T> {
Type type() {
Type parent = getClass().getGenericSuperclass();
return ((ParameterizedType) parent).getActualTypeArguments()[0];
}
}
public static void main(String[] args) {
Type type = new TypeReference<List<String>>() { }.type();
System.out.println(type.getTypeName());
}
}泛型不是 C++(C++ 编程语言)模板式的“每个实参生成一份机器码”:Java(编程语言)在一次擦除后的字节码实现上复用类型参数,以编译期检查和少量转换换取与泛型出现前库的二进制兼容、较小的类数量及一致的部署模型。
热门面试题
问题(原理题):type erasure(类型擦除)后,为什么仍需 bridge method(桥接方法)?
- 考点:覆写、多态、擦除描述符。
- 回答思路:比较父类擦除后的
normalize(Object)(规范化对象)和子类的normalize(String)(规范化字符串),说明二者不是同一 JVM(Java 虚拟机)签名。 - 详细答案:父类
Box<T>(泛型盒子)擦除后方法描述符接收并返回Object(对象基类);子类源码覆写的是String(字符串)版本。没有桥接时,父类引用调用擦除后的normalize(Object)(规范化对象)无法落到子类版本,破坏多态。编译器生成带ACC_BRIDGE(桥接访问标志)和ACC_SYNTHETIC(合成访问标志)的normalize(Object)(规范化对象),先转换为String(字符串)再委派。 - 进阶追问:桥接方法中的转换失败说明什么?
- 进阶回答:通常是 raw type(原始类型)或未经检查转换绕过了编译器,把错误对象送进了原本安全的泛型调用链。根因在污染写入点,而不是桥接方法;桥接只是较早暴露了违反泛型承诺的事实。
问题(原理题):为什么“运行期没有任何泛型信息”不准确?
- 考点:实例实参、Signature(泛型签名)、Reflection(反射)。
- 回答思路:区分对象运行值与声明元数据,再给字段、方法、匿名子类三个信息来源。
- 详细答案:擦除使普通对象实例通常不带具体实参,因此仅拿到一个
ArrayList(数组列表)无法判断原先是字符串还是库存行列表;但编译器可在类、字段、方法及父接口的Signature(泛型签名)属性中记录声明。Reflection(反射)的getGenericType()(获取泛型类型)和getGenericReturnType()(获取泛型返回类型)可以解析这些声明;TypeReference(类型引用)借匿名子类将实参放进父类声明。若变量声明已经擦成Object(对象基类),就没有可靠信息可恢复。 - 进阶追问:序列化框架为什么常要求传 TypeReference(类型引用)?
- 进阶回答:反序列化只拿到目标对象值时,元素类型往往已丢失;TypeReference(类型引用)提供
ParameterizedType(参数化类型)让框架知道列表元素应构造为什么类型。它是声明保留机制,不是绕过擦除的魔法。
问题(项目题):如何处理 varargs(可变参数)加泛型的 heap pollution(堆污染)风险?
- 考点:泛型数组、数组协变、
@SafeVarargs(安全可变参数标注)。 - 回答思路:说明编译器会创建擦除数组,风险只在方法体破坏数组或让其逃逸时出现。
- 详细答案:泛型可变参数会被实现成数组,而数组是协变且运行期可检查,泛型实参又已擦除;若方法把该数组赋给
Object[](对象数组)并写入不同参数化列表,就会在后续读取时远距离抛 ClassCastException(类型转换异常)。仓储批处理应优先传List(列表接口)或Collection(集合接口);确需可变参数时,只遍历它、不存储、不返回、不写入,并且仅在static(静态关键字)、final(最终关键字)或私有方法上经审查后使用@SafeVarargs(安全可变参数标注)。 - 进阶追问:
@SafeVarargs(安全可变参数标注)会做运行期保护吗? - 进阶回答:不会。它只是向编译器和读者声明方法体满足安全条件,从而压制调用点警告;错误标注会把风险隐藏,因此必须以“不污染、不逃逸”为可审计约束。
- 考点:泛型数组、数组协变、
2.3 Reflection(反射)、类加载身份与代理边界
Class(类元数据)的身份由**全限定类名 + ClassLoader(类加载器)**共同确定。同名 com.wms.Rule(仓储规则)若被两个 ClassLoader(类加载器)加载,就是两个不兼容的类型;插件卸载、热部署和缓存设计都受此影响。
| 查询或调用方式 | 成员边界与成本 | 适用场景 | 关键风险 |
|---|---|---|---|
| 直接调用 | 编译期绑定,最易被 JIT(即时编译)优化。 | 业务主路径。 | 扩展点需在编译期确定。 |
| Reflection(反射) | 查找、访问检查、参数数组、装箱与 invoke(调用)分派均有成本。 | 启动扫描、低频绑定、通用框架。 | 不缓存元数据、高频循环调用。 |
| MethodHandle(方法句柄) | 显式查找后可组合,调用签名更接近底层类型。 | 动态语言运行时、热点可优化调用。 | invokeExact(精确调用)类型不匹配会失败,学习成本更高。 |
| 代码生成 | 启动或构建期生成专用调用路径。 | 高频序列化、映射、AOT(提前编译)场景。 | 生成、调试与版本兼容成本。 |
成员查找必须按类别拆开:getDeclaredField(获取声明字段)、getDeclaredMethod(获取声明方法)和 getDeclaredConstructor(获取声明构造器)都只查当前 Class(类元数据)直接声明的成员,包含 private(私有访问控制),不递归父类或接口。getField(获取公共字段)按 JDK(Java 开发工具包)21 Class(类元数据)API(应用程序接口)规则查找:先当前类,再按声明顺序递归直接公共接口,最后递归父类;因此公共接口继承字段参与查找。getMethod(获取公共方法)按其独立规则匹配公共成员方法,公共父类和公共接口继承方法都参与,返回协变返回值场景中更具体的覆写方法。构造器不继承:getConstructor(获取公共构造器)只在当前 Class(类元数据)声明的 public(公共访问控制)构造器中按形参匹配,接口、数组、基本类型和 void(无返回类型)没有匹配构造器。访问检查仍在使用成员时执行;JDK(Java 开发工具包)9+ 还受模块 exports(导出)和 opens(开放)限制,trySetAccessible()(尝试设置可访问标志)返回 false(假值)时应换公开 API(应用程序接口)或在受控启动参数中开放包,而不是吞异常。
| 成员类别 | getDeclared*(获取声明成员) | 公共查找 API(应用程序接口) | 排查误区 |
|---|---|---|---|
| Field(字段元信息) | getDeclaredField(获取声明字段)只查当前类或接口直接声明的字段。 | getField(获取公共字段)先当前类,再递归直接公共接口,最后递归父类。 | 只沿父类向上找会漏掉接口常量。 |
| Method(方法元信息) | getDeclaredMethod(获取声明方法)只查当前类或接口直接声明的方法。 | getMethod(获取公共方法)按 Class(类元数据)API(应用程序接口)规则匹配公共成员,父类和接口继承方法均参与。 | 不能用字段的搜索顺序臆测方法的协变返回值选择。 |
| Constructor(构造器元信息) | getDeclaredConstructor(获取声明构造器)只查当前类直接声明的构造器。 | getConstructor(获取公共构造器)只匹配当前类直接声明的 public(公共访问控制)构造器。 | 构造器不继承,父类无参构造器不会被子类查找到。 |
缓存 Method(方法元信息)或 Constructor(构造器元信息)可避免重复查找、解析和部分访问准备,但不能把缓存无界地按类名保存到父加载器:静态 Map(映射接口)强引用 Class(类元数据)会阻止热部署 ClassLoader(类加载器)回收。按类计算且随类卸载回收的 ClassValue(类值)更适合元数据缓存;弱引用可减轻泄漏,但必须处理被回收后的重建与并发竞态。
JDK Dynamic Proxy(JDK 动态代理)只能代理接口:外部调用先进入 InvocationHandler(调用处理器),再决定拦截、委派或返回。需要代理具体类时 CGLIB(类代理生成库)可作为子类代理的对照,但其 final(最终关键字)类或方法不能覆写;Spring(Java 应用框架)章节再展开代理选择。无论哪一种代理,目标对象内部 this(当前对象引用)调用不会再次经过代理,这是事务、鉴权和缓存“自调用失效”的共同边界。equals(相等判断方法)、hashCode(哈希码方法)、toString(字符串展示方法)等 Object(对象基类)方法也必须由 InvocationHandler(调用处理器)显式定义语义,不能误当业务接口方法。
热门面试题
问题(基础题):同名类为什么可能不能强制转换?
- 考点:Class(类元数据)身份、ClassLoader(类加载器)隔离。
- 回答思路:说明类型身份含加载器,给插件或热部署的双加载器场景。
- 详细答案:JVM(Java 虚拟机)把“二进制名相同但加载器不同”的类当作不同 Class(类元数据)。插件 A 的
com.wms.Rule(仓储规则)传给主应用 B 时,即使字节码一致,也不能当作 B 加载器定义的同名类型转换。这解释了热部署中的 ClassCastException(类型转换异常)和缓存泄漏:缓存应以实际 Class(类元数据)为键,且不能意外用父加载器长生命周期对象强引用子加载器类。 - 进阶追问:如何为反射元数据设计不泄漏的缓存?
- 进阶回答:优先用 ClassValue(类值)把值绑定到 Class(类元数据)生命周期;缓存值也不能反向强引用整个加载器图。若使用弱引用,要接受条目可消失、实现原子重建,并用压测验证不会因频繁回收造成抖动。
问题(原理题):Reflection(反射)为什么不应被笼统说成“慢”?
- 考点:查找、检查、装箱、调用、缓存、JEP(JDK 增强提案)416。
- 回答思路:拆分一次性成本与每次调用成本,说明版本变化不改变 API(应用程序接口)选择原则。
- 详细答案:每次从类名查找成员、做访问检查、创建参数数组、基本类型装箱/拆箱并通过通用
invoke(调用)路径,通常比静态调用更难内联;这些是可测成本而非固定倍数。框架绑定前还要按成员类别选择正确 API(应用程序接口):字段的getField(获取公共字段)包含接口递归查找,方法的getMethod(获取公共方法)包含父类和接口公共继承方法,构造器则不能继承。启动期扫描和低频管理操作往往不是瓶颈;热点映射应缓存成员并测量真实吞吐。JEP(JDK 增强提案)416 在 JDK(Java 开发工具包)18 用 MethodHandle(方法句柄)重实现核心反射,改变的是内部路径,不保证所有基准都更快,因此仍需用 JFR(Java 飞行记录器)或基准测试定位。 - 进阶追问:缓存
Method(方法元信息)后是否就等于直接调用? - 进阶回答:不等于。缓存去掉了重复查找,但访问、参数适配和动态分派仍存在;同时缓存引入内存、加载器生命周期和并发发布问题。是否改为 MethodHandle(方法句柄)或代码生成取决于测得的热点和可维护性。
问题(项目题):支付验签适配器用 JDK Dynamic Proxy(JDK 动态代理)时,哪些边界最容易漏?
- 考点:接口限制、InvocationHandler(调用处理器)、Object(对象基类)方法、自调用。
- 回答思路:按“接口外部调用 → 处理器 → 目标适配器 → 通道 SDK(软件开发工具包)”讲链路,再说明未经过代理的路径。
- 详细答案:代理只能包住接口方法;处理器应先识别
equals(相等判断方法)、hashCode(哈希码方法)和toString(字符串展示方法),再执行验签、审计、超时统计和目标委派。若适配器内部通过this(当前对象引用)调用另一个带注解的方法,该调用不经过代理,拦截逻辑不会执行。支付验签失败要形成明确领域结果或异常并记录渠道交易号、签名版本和 TraceId(链路标识),但不能记录密钥和完整敏感载荷。 - 进阶追问:为什么不能让代理替代幂等和对账?
- 进阶回答:代理只能改变本进程某次方法调用前后行为,无法原子覆盖通道重试、数据库提交和进程宕机。幂等仍要靠唯一业务键、状态机和持久化条件更新;资金一致性还要靠回调记录、对账与补偿。
2.4 注解:声明、处理器与框架行为
Annotation(注解)是元数据声明,不会自行执行。Retention(保留策略)决定其存活阶段,Target(适用目标)限制可标注位置,Inherited(可继承注解)只影响类级查询而不自动作用于方法,Repeatable(可重复注解)让同类型注解以容器形式重复出现。
| Retention(保留策略) | 存活范围 | 典型处理者 | 选择依据 |
|---|---|---|---|
| SOURCE(源码期) | 编译后丢弃。 | 编译器、静态检查。 | 只需给开发阶段规则。 |
| CLASS(类文件期) | 写入类文件,运行时不要求可反射读取。 | 字节码工具、构建工具。 | 需要产物元数据但不做运行时扫描。 |
| RUNTIME(运行期) | 可由 Reflection(反射)读取。 | Spring(Java 应用框架)、校验器、MyBatis(持久层框架)扩展。 | 框架须在运行时据此建模。 |
flowchart LR
A["业务类:@Transactional(事务注解)/@Column(列映射注解)/@VerifySign(验签注解)"] --> B["扫描或 Annotation Processor(注解处理器)"]
B --> C["读取元数据:Reflection(反射)或编译期模型"]
C --> D["框架模型:事务属性、SQL(结构化查询语言)映射、验签规则"]
D --> E["代理或生成代码"]
E --> F["行为:开启事务、生成 SQL(结构化查询语言)、校验签名"]图中 A 只表达意图;B、C 才把意图转为可执行模型;D 统一框架规则;E 将规则接到调用点;F 才出现事务、SQL(结构化查询语言)或校验行为。常见误区是认为加上注解即自动生效:对象若不是容器管理、调用没有经过代理、保留策略不是 RUNTIME(运行期),都可能没有行为。
编译期 Annotation Processor(注解处理器)可在编译时校验并生成类型安全代码,启动快、失败早,但生成物与构建链耦合;运行期 Reflection(反射)扫描扩展灵活,适合插件和声明式框架,但需要缓存、启动校验和明确错误信息。Spring(Java 应用框架)通常在启动时读取 RUNTIME(运行期)注解形成 Bean(对象实例)定义与代理规则;MyBatis(持久层框架)把映射元数据组织为 SQL(结构化查询语言)执行模型;支付验签适配器可用注解标明渠道、签名算法和字段策略,但密钥仍应来自受控配置而非注解常量。
热门面试题
问题(基础题):注解为什么不会自己开启事务?
- 考点:元数据与执行器分离、代理调用链。
- 回答思路:把注解、扫描、事务属性、代理拦截器和目标调用分开说明。
- 详细答案:
@Transactional(事务注解)只提供元数据。Spring(Java 应用框架)读取它后建立事务属性,并在容器创建的代理上安装拦截器;外部调用代理时,拦截器才开启、提交或标记回滚事务。直接new(创建)对象、非代理调用或对象内部自调用都绕开该拦截器,因此“注解存在”不等于“事务存在”。 - 进阶追问:
@Inherited(可继承注解)能让父类方法的事务规则自动传给子类覆写方法吗? - 进阶回答:不能把它当作方法继承机制。
@Inherited(可继承注解)只影响从类上查询注解时的继承行为;方法注解需由框架按方法解析规则处理,覆写方法应明确声明或验证其元数据来源。
问题(原理题):何时用 Annotation Processor(注解处理器),何时用运行期 Reflection(反射)?
- 考点:失败时机、可扩展性、启动成本。
- 回答思路:按规则是否必须在构建时确定、是否需要发现运行期类型来选择。
- 详细答案:字段命名、接口实现完整性、固定路由表等可在编译时确定的规则,适合 Annotation Processor(注解处理器):错误早、可生成无反射的调用代码。需要按部署插件、配置或容器中实际 Bean(对象实例)组合决定的规则,适合运行期 Reflection(反射)。后者应在启动期一次扫描、校验冲突并缓存模型,而不是每个请求重复扫描。
- 进阶追问:CLASS(类文件期)注解为什么可能被工具看见但框架看不见?
- 进阶回答:它可保留在类文件供字节码处理,但 Reflection(反射)的运行期查询只返回 RUNTIME(运行期)保留的注解。选择保留策略必须由消费阶段决定,不能因为“类文件中有”就假设容器可扫描。
问题(项目题):支付验签注解如何避免变成安全幻觉?
- 考点:元数据边界、失败闭环、敏感信息。
- 回答思路:注解只声明策略;真正的验签器、密钥管理、重放保护和审计要独立落地。
- 详细答案:可用
@VerifySign(验签注解)声明渠道和签名版本,框架在启动时校验其对应适配器;请求进入后读取原始载荷,按已配置的 HMAC(基于哈希的消息认证码)密钥或证书验签,并验证 Timestamp(时间戳)和 Nonce(随机数)以防重放。失败必须返回通道规定的可重试或不可重试响应码,并保存脱敏证据。注解不应携带密钥,也不能替代幂等键、回调落库和资金对账。 - 进阶追问:注解扫描失败时应在何时暴露?
- 进阶回答:渠道、算法或字段策略缺失属于部署配置错误,应在应用启动期失败并给出精确类、方法和缺失项;把它拖到首笔支付回调才报错,会把可预防故障变成资金链路事故。
2.5 Throwable(可抛出对象)、资源关闭与一致性边界
异常分类的核心标准不是“谁更高级”,而是调用者能否采取有意义的恢复动作。Error(严重错误)通常表示运行环境已不可信;checked exception(受检异常)适合调用方确实能重试、换资源或提示用户处理的已知失败;RuntimeException(运行时异常)适合参数、状态、领域规则或无法由每一层恢复的失败。
异常会沿调用栈传播,直到某层 catch(捕获)它;真正能恢复的一层才处理或转换,其他层应保留 cause(原因)并继续抛出。转换时必须补充订单号、渠道号、任务号等上下文,不能只留模糊文案;同一异常通常只在最终边界完整记录一次,既避免重复日志,也避免吞异常、丢堆栈或让上游误判成功。
flowchart TD
T["Throwable(可抛出对象)"] --> ER["Error(严重错误)"]
T --> EX["Exception(异常)"]
EX --> CE["checked exception(受检异常)"]
EX --> RE["RuntimeException(运行时异常)"]
RE --> BE["业务异常:不可扣减/验签失败"]
CE --> IO["I/O(输入输出)失败:调用者可选重试或降级"]
ER --> OOM["OOM(内存溢出):记录、隔离、重启,不伪造成功"]
RE --> CAUSE["cause(原因)链:保留底层异常"]
CE --> SUP["suppressed exception(被抑制异常):资源关闭失败"]图中业务异常与基础设施异常可通过 cause(原因)链连接,顶层统一处理器据此记录一次完整上下文并映射响应;try-with-resources(自动资源管理)按后创建先关闭关闭资源,主异常保留,后续关闭异常附到 suppressed exception(被抑制异常)。误区是“捕获了就处理了”:吞掉异常或重复打印同一 cause(原因)链都会破坏定位与重试语义。
public class ExceptionResourceDemo {
static final class FailingResource implements AutoCloseable {
private final String name;
FailingResource(String name) { this.name = name; }
@Override public void close() throws Exception {
throw new Exception("close-" + name);
}
}
public static void main(String[] args) {
try (FailingResource first = new FailingResource("first");
FailingResource second = new FailingResource("second")) {
throw new IllegalStateException("business-failure");
} catch (Exception exception) {
System.out.println(exception.getMessage());
System.out.println(exception.getSuppressed().length);
}
}
}| 场景 | 异常与返回策略 | 一致性仍需的机制 | 失败边界 |
|---|---|---|---|
| 支付回调 | 验签或临时通道失败返回可重试响应;不确定结果不能伪造成功。 | 回调幂等键、持久化记录、对账补偿。 | 异常仅影响本次控制流,不能确认资金最终状态。 |
| 订单履约 | 局部成功后抛出异常并记录可补偿状态。 | 状态机、Outbox(发件箱)、补偿任务。 | 回滚本地库不能撤回已调用的物流接口。 |
| WMS(仓储管理系统)扣库存 | 条件更新影响行数为零时抛业务异常。 | where available >= ?(可用库存条件更新)、事务、幂等扣减号。 | 不能先查后扣,也不能靠捕获异常补出库存。 |
| Runner(执行器)异步任务 | CompletionException(完成异常)包装异步失败;按根因更新任务状态。 | 租约、检查点、重试上限、死信记录。 | CompletableFuture(异步编排)完成不等于业务已持久化成功。 |
Spring(Java 应用框架)事务默认规则来自其官方事务拦截器:未处理的 RuntimeException(运行时异常)或 Error(严重错误)通常标记回滚,checked exception(受检异常)默认不标记;rollbackFor(指定回滚异常)可显式覆盖。它还受代理边界约束:只有经过事务代理的方法调用才被拦截。若在事务方法中捕获异常后返回成功,拦截器看到的是正常返回,除非显式标记回滚或重新抛出匹配规则的异常。
故障演绎:支付回调的“成功假象”。
| 时刻 | 错误实现 | 结果 |
|---|---|---|
| T1 | 回调进入事务,更新支付单后数据库异常。 | 事务准备回滚。 |
| T2 | 代码 catch(捕获)后只记录日志并返回 HTTP(超文本传输协议)200(成功响应)。 | 事务因正常返回提交或已回滚,但上游收到成功。 |
| T3 | 通道不再重试,支付单仍未正确落库。 | 资金状态出现待人工对账缺口。 |
| 修复 | 记录带回调号的失败证据;异常向上抛出或标记回滚;按通道契约返回可重试响应;用唯一回调号幂等落库。 | 重试、对账和补偿都有确定入口。 |
热门面试题
问题(基础题):checked exception(受检异常)和 RuntimeException(运行时异常)如何选择?
- 考点:恢复能力、接口边界、异常转换。
- 回答思路:不按“业务异常一律运行时”背答案,先判断直接调用者是否能可靠恢复。
- 详细答案:文件读取失败若调用者能选择备用文件、重试或交互修正,可表达为 checked exception(受检异常);参数非法、状态机非法迁移、库存不足通常需要统一错误码或上层统一处理,逐层强制声明反而制造噪声,可用清晰的 RuntimeException(运行时异常)子类。跨基础设施边界应转换为领域可理解异常并以 cause(原因)保留原始异常、渠道、订单号和安全的错误码,不能让每层依赖具体客户端异常。
- 进阶追问:为什么不建议捕获 Error(严重错误)后继续处理请求?
- 进阶回答:例如 OOM(内存溢出)后进程可能连构造日志、响应或补偿对象都无法可靠完成;吞掉它并返回成功会扩大损失。可在顶层做最小化记录、熔断或让编排系统重启,但不能把它当普通业务分支。
问题(原理题):
try-with-resources(自动资源管理)如何同时保留业务异常和关闭异常?- 考点:关闭顺序、suppressed exception(被抑制异常)、可诊断性。
- 回答思路:说明资源逆序关闭,以及主异常不被关闭异常覆盖。
- 详细答案:资源按声明逆序关闭;若 try(尝试)块已抛主异常,关闭阶段再抛的异常会通过
addSuppressed(添加被抑制异常)附着在主异常上,调用getSuppressed(获取被抑制异常)可查看。若没有主异常,第一个关闭异常成为主异常。这使“业务执行失败”和“连接关闭失败”都保留,日志应一次打印异常对象及其 cause(原因)链和被抑制异常,而不是只记getMessage(获取消息)。 - 进阶追问:为什么重复记录再抛出会污染排障?
- 进阶回答:同一异常在 DAO(数据访问对象)、服务和控制器各打印一次,会制造三份相同堆栈并掩盖真正的边界上下文。应在能增加处理价值的边界转换或补充字段,最终由统一入口记录一次完整链路。
问题(项目题):Runner(执行器)任务捕获异步异常后为什么会永久悬挂?
- 考点:CompletableFuture(异步编排)、CompletionException(完成异常)、状态终结。
- 回答思路:说明异步异常包装、状态回写缺失与调度租约没有释放三件事。
- 详细答案:异步阶段抛出异常时,CompletableFuture(异步编排)通常以 CompletionException(完成异常)完成;若回调只打印日志而不把任务从“执行中”条件更新到“失败/待重试”,租约仍被占用,调度器便认为任务正在运行,形成永久悬挂。修复是统一解包 ExecutionException(执行异常)或 CompletionException(完成异常)的根因,携带任务号、尝试次数、TraceId(链路标识)和 MDC(映射诊断上下文),再用条件更新原子结束状态并决定重试、死信或人工介入。
- 进阶追问:异常重试能解决全部一致性问题吗?
- 进阶回答:不能。网络超时可能已在下游成功,盲目重试会重复扣款、重复发货或重复扣库存。重试必须配合业务幂等键、状态查询、补偿和对账;异常只是触发决策的信息,不是业务一致性方案本身。
3. 线上排查清单
| 症状 | 先验证什么 | 证据与处理 |
|---|---|---|
InaccessibleObjectException(不可访问对象异常) | 模块是否 opens(开放)目标包,是否误反射 JDK(Java 开发工具包)内部类。 | 记录声明类、模块和成员;优先公开 API(应用程序接口)或受控开放包。 |
| 反射热点导致延迟 | 是否每请求查找 Method(方法元信息),是否存在装箱和字符串查找。 | 用 JFR(Java 飞行记录器)采样;缓存元数据后复测,避免只凭经验换技术。 |
| ClassCastException(类型转换异常)出现在集合读取 | 是否有 raw type(原始类型)、未经检查转换、泛型可变参数。 | 从读取栈回溯污染写入点;收紧边界类型。 |
| 支付已回滚却上游未重试 | 是否捕获异常后返回成功,事务代理是否生效。 | 对照回调响应码、事务日志和回调幂等记录;恢复可重试语义并对账。 |
| Runner(执行器)任务长期执行中 | 是否遗漏异常完成回调或租约释放。 | 查任务状态转换、CompletableFuture(异步编排)异常根因和 MDC(映射诊断上下文);条件更新终结状态。 |
4. 项目话术
“在 WMS(仓储管理系统)批量导入中,我不会把所有来源都硬转成同一种列表。读取端用 extends(上界关键字)表达只读生产者,导出端用 super(下界关键字)表达只写消费者,让供应商扩展行在编译期就不能污染标准库存行。框架层面,我把反射放在启动绑定而不是请求热路径,按 Class(类元数据)生命周期缓存元数据,避免热部署泄漏;注解只用于声明策略,实际由代理和校验器执行。支付与异步任务中,异常不等于一致性:失败会带上订单号、渠道号和 TraceId(链路标识)回写状态,通道按响应码重试,最终仍以幂等、条件更新、对账和补偿收敛。”
5. 参考来源与证据
- Java(编程语言) Language Specification(语言规范)4.6:type erasure(类型擦除)
- Java(编程语言) Tutorials(教程):bridge method(桥接方法)
- OpenJDK(开放 Java 开发工具包)JEP(JDK 增强提案)416
- JDK(Java 开发工具包)21 AccessibleObject(可访问对象)
- JDK(Java 开发工具包)21 Class(类元数据)成员查找 API(应用程序接口)
- Spring(Java 应用框架)事务回滚规则
6. 本章复习清单
- 能用 WMS(仓储管理系统)导入/导出 API(应用程序接口)解释不变性和 PECS(生产者扩展消费者超类原则)。
- 能画出“检查 → 擦除 → 转换 → bridge method(桥接方法)”并解释 Signature(泛型签名)元数据边界。
- 能分别说清字段、方法与构造器的
get*(获取成员)/getDeclared*(获取声明成员)边界、接口参与规则、模块强封装和缓存泄漏风险。 - 能把注解到代理、SQL(结构化查询语言)或验签行为的执行链复述出来。
- 能区分异常控制流、Spring(Java 应用框架)事务规则和业务一致性方案。
