Java 从入门到精通(十一):反射、注解与动态代理——框架底层的三块基石

本篇属于「知其然更知其所以然」的一章。反射、注解、动态代理这三样东西,日常写业务代码几乎碰不到,但只要你点开 Spring 的 AutowiredAnnotationBeanPostProcessor、MyBatis 的 MapperProxy、Jackson 的 BeanDeserializer,就会发现它们无处不在。理解这三块基石,你读框架源码时就不会再迷路,也能在真正需要”通用能力”的场景里写出靠谱的底层工具,而不是把反射当成万能胶乱抹一气。阅读建议:跟着代码敲一遍,尤其是第六节的 DI 容器和第七节的代理切面,跑通之后很多面试题会自然解开。

写在前面:为什么这三个东西要一起讲

很多教材把反射、注解、动态代理拆成三章,结果读者学完只记住了三套 API,串不起来。实际上它们是一条流水线上的三道工序:注解负责”打标记”(声明元数据),反射负责”读标记 + 执行动作”(运行期解析与调用),动态代理负责”把动作织入调用过程”(无侵入增强)。

Spring 里 @Transactional 就是一个典型闭环:你在方法上打一个注解(注解),Spring 容器启动时用反射扫描到它并判读事务属性(反射),然后为目标对象生成一个代理对象,让每次方法调用先经过事务拦截器(动态代理)。少了任何一环,这个能力都实现不了。

所以本篇的顺序是:先把反射讲透(它是唯一真正”做事”的那个),再讲注解(给反射提供输入),最后讲代理(把反射包装成优雅的形态)。

一、反射是什么:编译期确定 vs 运行期确定

1.1 从一行最普通的代码说起

这行代码在编译期就已经完全确定了:编译器知道 User 有 setName 方法、参数是 String、返回值是 void。生成的字节码里是一条 invokevirtual 指令,指向常量池中一个明确的符号引用,JVM 加载类时把它解析成直接引用,之后每次调用都是一次近乎”硬编码”的跳转。

反射把这个过程推迟到了运行期:你手上只有一个字符串 "com.example.User" 和另一个字符串 "setName",要等程序跑起来才知道有没有这个类、有没有这个方法、参数类型对不对。

这种”推迟”带来了一个关键能力:代码可以操作它编译时不认识的类型。这正是框架存在的意义——Spring 编译时不认识你的 @Service 类,MyBatis 编译时不认识你的 UserMapper,Jackson 编译时不认识你要反序列化的任何 POJO。

1.2 框架为什么到处都在用反射

框架反射用在哪典型类
Spring扫描类路径、实例化 Bean、注入字段、调用初始化方法ClassPathScanningCandidateComponentProvider、AutowiredAnnotationBeanPostProcessor
MyBatis接口方法 → SQL 映射、结果集 → 对象属性填充MapperProxy、DefaultResultSetHandler
Jackson / Fastjson属性名 ↔ 字段名映射、getter/setter 调用BeanSerializer、BeanDeserializer
JUnit发现 @Test 方法并调用BlockJUnit4ClassRunner
Dubbo / Feign接口方法 → 远程调用参数拼装InvokerInvocationHandler

共同点是:框架代码必须在不知道用户类型的情况下,完成”创建对象 / 读写属性 / 调用方法”这三件事。而这三件事恰好就是反射的 Constructor、Field、Method 三个核心类。

1.3 反射的代价与边界

反射不是银弹,它有三笔明确的账单:性能开销(访问检查、JNI 调用、无法内联)、安全与封装风险(setAccessible 可以撬开 private)、可维护性损失(字符串硬编码、编译期失去类型保护、IDE 无法帮你重构)。

合理的边界可以概括成三条:

  1. 写通用基础设施时用反射(框架、ORM、序列化、测试框架、插件系统)。
  2. 写业务逻辑时不要用反射。能用 if 解决的不要用 Class.forName;能用接口解决的不要用 Method.invoke。
  3. 热路径上必须缓存反射对象。Class.forName / getMethod 这些查找操作的成本远高于 invoke 本身,把 Method 对象缓存成静态常量,往往是最大的一次优化。

1.4 反射的三种典型误用

  • 用反射代替多态:拿到 type 字符串后 if ("A".equals(type)) 再 Method.invoke,本质上是在用字符串重新实现一遍 switch。正确做法是定义一个接口 + 多个实现类 + 一个 Map,把运行期分派交给 JVM 的虚方法表。
  • 用反射绕过不合理的设计:某个字段是 private、没有 setter,于是 setAccessible(true) 强改。这不是在解决问题,是在掩盖问题——正确做法是推动接口暴露,或者用包级可见的测试钩子。
  • 在循环里做反射查找:for (...) { Method m = clazz.getMethod("xxx"); m.invoke(obj); },查找成本被放大 N 倍。把 getMethod 提到循环外,通常一个改动就能快一个数量级。

二、Class 对象:反射的入口

2.1 Class 对象的本质

JVM 每加载一个类(或接口、数组、基本类型、void),都会在堆(JDK 8 之后是元空间管理的类元数据 + 堆中的 java.lang.Class 实例)中为其创建一个唯一的 Class 对象。这个对象就是该类在运行期的”元数据句柄”,反射的一切都从这里出发。

关键点:一个类在一个 ClassLoader 下只有一个 Class 对象,但同一个类被两个不同的 ClassLoader 加载,会产生两个不同的 Class 对象,且 a.equals(b) 为 false、a.isAssignableFrom(b) 也为 false——这是热部署、插件隔离、以及 ClassCastException 里那句令人抓狂的 Xxx cannot be cast to Xxx 的根本原因。

2.2 三种获取方式对比

获取方式语法是否加载类是否触发静态初始化编译期类型安全典型场景
类字面量User.class否(仅解析符号引用,首次主动使用时才加载)不触发(属于被动引用)是,可写 Class已知类型时首选,如 String.class、int.class
对象实例obj.getClass()已加载已触发(否则拿不到实例)返回 Class<? extends T>已有对象,需要动态判断实际类型
全限定名Class.forName("x.y.Z")是(用调用者的 ClassLoader)默认触发(initialize=true)否,返回 Class<?>JDBC 驱动加载、插件、配置驱动

这里有个很容易踩的坑:Class.forName 的第二个参数控制是否初始化。Class.forName(name, false, loader) 只加载不初始化,常用于”只想探测类是否存在”的场景;而 ClassLoader.loadClass(name) 默认就是只加载不初始化。JDBC 4.0 之前的 Class.forName("com.mysql.cj.jdbc.Driver") 之所以必须写,正是因为要靠它触发 Driver 的静态注册块。

2.3 基本类型的 Class 与包装类

注意 int.class 和 Integer.class 是两个不同的 Class,这在反射调用方法时非常重要:setAge(int) 的参数类型必须写 int.class,写 Integer.class 会 NoSuchMethodException。另外 getName() 对数组返回 JVM 内部表示([I、[[Ljava.lang.String;),要得到可读形式请用 getCanonicalName() 或 getTypeName()。

2.4 Class 常用 API 速查

API作用注意点
getName()全限定名,数组返回 [I 形式内部类是 a.b.Outer$Inner
getSimpleName()去掉包名匿名内部类返回空字符串
getCanonicalName()规范名,数组返回 int[] 形式可能为 null
getSuperclass()直接父类接口、Object、int.class 返回 null
getInterfaces()直接实现的接口数组不含父类间接实现的接口
isInterface() / isEnum() / isArray() / isPrimitive()类型判定isEnum 对匿名枚举子类返回 false
isAssignableFrom(Class)当前类是否能接收参数类型(A.isAssignableFrom(B) ≡ B 是 A 的子类型)判断”能否赋值”用它,别搞反方向
instanceof / isInstance(Object)判断对象是否属于某类型isInstance 是 instanceof 的动态版
getModifiers()返回修饰符位掩码配合 Modifier.isPublic/Static/Final/Abstract 解析
getDeclaredClasses() / getEnclosingClass()内部类 / 外部类用于扫描嵌套配置类
getComponentType()数组元素类型非数组返回 null
getClassLoader()加载该类的 ClassLoader可能为 null(Bootstrap 加载的类,如 String.class)

isAssignableFrom 是最容易搞反的一个,记住这句口诀:A.isAssignableFrom(B) 为 true,表示”能把 B 的对象赋给 A 类型的引用”,即 A 是 B 的父类或接口。

三、反射核心 API 实战

3.1 Constructor:创建对象的两种方式

newInstance 与 new 的区别:new 是编译期确定、字节码直接分配,Constructor.newInstance 要走访问检查、参数装箱、异常包装(目标构造抛出的异常会被包装成 InvocationTargetException,需要用 getCause() 取真实异常),并且无法被 JIT 内联。性能上后者慢一到两个数量级,但它能在运行期决定要 new 什么。

3.2 Field:读写字段

不要试图修改 final 字段。 JDK 8 时代它”看起来能用”,但依赖的是未定义行为:另一个类里 static final int MAX = 100 在编译期就被内联进调用方的字节码,你改了字段值,别人读到的还是 100。JDK 9 引入模块系统后,反射修改 final 的口子越收越紧(records、hidden classes 的 trusted final 完全禁止),JDK 17 之后很多场景直接抛 IllegalAccessException。把它当成不存在的能力。

3.3 Method:调用方法

三个高频坑:参数类型要精确(int.class 不能写 Integer.class)、可变参数要二次包装(new Object[]{...})、异常要 getCause()。

3.4 数组反射与动态二维数组

注意 Object[] 与 int[] 不能直接互转:new int[]{1,2}.getClass() 不是 Object[].class 的子类型,(Object[]) Array.get(...) 在基本类型数组上会 ClassCastException。用 Array.getXxx 系列方法最稳。

3.5 泛型信息获取

Java 泛型是擦除的,但擦除不等于”什么都不剩”:签名(Signature)属性会保留声明处的泛型信息,getGenericXxx 系列方法可以把它们读出来。

这套 API 是 MyBatis 解析 interface UserMapper extends BaseMapper、Spring 解析 Repository 的关键:通过 getGenericInterfaces() 拿到 ParameterizedType,再取 getActualTypeArguments()[0] 就得到了实体类型 User。

四、反射性能:invoke 到底慢在哪

4.1 完整调用链路

一次 Method.invoke 在 HotSpot 上大致经历这些步骤:

  1. 可变参数打包:invoke(obj, args...) 本身是可变参数方法,调用方要先构造一个 Object[](这是逃逸分析能优化掉的少数部分)。
  2. 访问检查:若 override == false,执行 Reflection.getCallerClass() 拿到调用者类,再走 Reflection.verifyMemberAccess() + checkAccess(),逐层检查 public / 同包 / 子类 / 模块导出关系。这一步每次调用都会做。
  3. 获取 MethodAccessor:Method 内部持有一个 MethodAccessor 引用,首次调用时通过 ReflectionFactory 创建。
  4. DelegatingMethodAccessorImpl:只是一个可变的委托壳子,方便后续”热替换”实现。
  5. NativeMethodAccessorImpl:真正的第一版实现,内部是 JNI 调用 Method.invoke0。它维护一个 numInvocations 计数器,超过阈值(默认 15 次)后触发膨胀(inflation):由 MethodAccessorGenerator 在运行时生成一段字节码(类名类似 GeneratedMethodAccessor1),并把它塞进 DelegatingMethodAccessorImpl 替换掉 Native 版本。
  6. GeneratedMethodAccessor:生成的是普通 Java 字节码(做了参数拆箱、类型转换、直接 invokevirtual),可以被 JIT 编译优化甚至内联,性能接近直接调用。

相关 VM 参数:-Dsun.reflect.inflationThreshold=N 调整膨胀阈值(设为 1 表示第二次调用就生成字节码访问器,设为很大则长期停留在 Native 实现);-Dsun.reflect.noInflation=true 直接跳过 Native 阶段、首次就生成字节码版(代价是首次调用要付出生成与类加载的时间,且每个 Method 都会生成一个类,元空间压力变大)。

4.2 setAccessible(true) 为什么能提速

从上面的链路可以看出,setAccessible(true) 把 Method 的 override 标志置为 true,直接砍掉了第 2 步:不再需要 Reflection.getCallerClass() 和逐层的成员访问校验。同时生成版访问器里也不需要插入访问检查逻辑。在 JDK 8 时代,这一项通常能带来 20%~50% 的提升。

但要注意两件事:

  • setAccessible(true) 不等于绕过膨胀,Native → Generated 的切换依然会发生。
  • 在 JDK 9+ 模块化环境下,setAccessible(true) 对未 opens 的包会抛 InaccessibleObjectException,不是”一定能成功”。

4.3 三种调用方式的量级对比

下表是量级参考,具体数字随 JDK 版本、硬件、JIT 预热程度变化很大,重点是数量级关系而不是绝对值:

调用方式相对耗时(直接调用 = 1)特点
直接调用 obj.method()1可被内联,最优
MethodHandle(static final 常量持有)1 ~ 3JDK 7 引入,JIT 友好,可内联
缓存的 Method.invoke(已膨胀 + setAccessible)5 ~ 20框架中最常用,够快
未膨胀的 Method.invoke(前 15 次)50 ~ 200JNI 调用,慢
Class.forName + getMethod + invoke 全套1000+查找成本远大于调用,必须缓存

结论很直白:真正拖慢反射的往往不是 invoke,而是反复的 Class.forName 和 getMethod。把 Class/Method/Field 缓存进 Map 或静态常量,通常就能拿到 90% 的收益。

4.4 MethodHandle 与 VarHandle

java.lang.invoke.MethodHandle 是 JDK 7 引入的、比反射更贴近 JVM 的”方法指针”:它是类型化的(MethodType)、不依赖字符串查找、由 JVM 在链接期做签名校验,JIT 可以把常量持有的 MethodHandle 完全内联,性能接近直接调用。invokedynamic(lambda、字符串拼接、record 的方法)底层就是它。

java.lang.invoke.VarHandle(JDK 9)则是字段/数组元素的”安全句柄”,除了读写,还支持 CAS、volatile 语义、内存屏障等原子操作,是 AtomicXxxFieldUpdater 和 Unsafe 的官方替代品。

4.5 框架里的优化策略

  • 缓存元数据:Spring 的 CachedIntrospectionResults、Jackson 的 BeanPropertyWriter 都是把反射结果缓存成访问器对象,避免每次解析。
  • LambdaMetafactory 生成函数式访问器:不用 Method.invoke,而是在运行期生成一个实现了 Function/BiConsumer 的类,get/set 变成普通接口调用。MyBatis 的 PropertyAccessor、很多 JSON 库的高性能路径都这么干。
  • 一次性 setAccessible:启动时统一处理,而不是每次调用时判断。
  • 必要时用字节码增强(ASM / ByteBuddy / CGLIB):直接生成访问类,彻底消灭反射调用。

五、注解基础:会说话的元数据

5.1 注解的本质

注解就是一个继承了 java.lang.annotation.Annotation 的接口,编译后 JVM 会为它生成一个动态代理实现($Proxy1 implements MyAnno, Annotation),你通过反射拿到的注解对象其实就是那个代理实例,调用 anno.value() 会走 AnnotationInvocationHandler 从 MemberValues 的 Map 里取值。

元素类型限制:只能是基本类型、String、Class、枚举、注解,以及它们的一维数组。不能用 null 作默认值,不能是任意对象类型,不能是多维数组。

5.2 元注解

元注解作用关键点
@Retention保留策略:SOURCE(编译后丢弃)/ CLASS(写进 class 文件但反射读不到,默认)/ RUNTIME(运行期可用反射读取)想靠反射解析必须 RUNTIME;@Override 是 SOURCE;Lombok、MapStruct 的注解多为 SOURCE
@Target可标注位置:TYPE、FIELD、METHOD、PARAMETER、CONSTRUCTOR、LOCAL_VARIABLE、ANNOTATION_TYPE、PACKAGE、TYPE_PARAMETER、TYPE_USE不写则可标在任何位置
@Documented是否被 Javadoc 收录仅影响文档
@Inherited是否允许子类继承父类类上的注解只对 @Target(TYPE) 生效,对方法/字段无效;接口上的注解不会被继承
@Repeatable是否可重复标注(JDK 8)需要配套一个”容器注解”,容器必须含 value() 数组元素

5.2.1 @Inherited 的三个陷阱

@Inherited 有个经典陷阱:它只作用于类继承,而且 getAnnotations() 才能拿到继承来的注解,getDeclaredAnnotations() 拿不到。另外,方法上的注解永远不会被继承或合并——子类重写方法后,父类的 @Transactional 就丢了(Spring 对此有额外的查找逻辑,会去父类/接口上找)。

5.3 注解 vs 配置文件

注解的优势是就近声明、类型安全、IDE 可跳转、重构友好;劣势是硬编码在源码里,改配置要重新编译,且无法按环境差异化。配置文件的优势是外置、不改代码可调整、适合运维接管。实践中的取舍:

  • 结构性、稳定的元数据用注解(@Entity、@RequestMapping、@Autowired)。
  • 需要按环境变化的用配置(数据源地址、超时时间、线程池参数)。
  • 两者可以结合:注解里写 ${xxx} 占位符,由框架从配置解析填充。

5.4 编译期注解处理(APT)

注解并非只有运行期一条路。APT(Annotation Processing Tool,javax.annotation.processing.Processor) 允许你在编译期扫描注解并生成新的源文件,不需要反射、零运行期开销。

  • Lombok:@Data、@Getter 是 SOURCE 级别,它并没有用标准 APT API,而是通过修改 javac 的 AST 直接”注入”方法,所以运行时 class 里已经存在 getter,运行时完全无感知。
  • MapStruct:@Mapper 走标准 APT,编译期生成 UserMapperImpl,把 DTO 转换变成普通方法调用,比 BeanUtils 反射拷贝快得多。
  • Dagger / ButterKnife / AutoService:都是 APT 的典型用户。

选择依据:如果能力可以在编译期固化(生成代码就够了),优先 APT;只有必须”运行期根据环境动态决定”时才用反射注解。

六、注解解析与实战

6.1 用反射读取运行时注解

6.2 实战一:@JsonField + 迷你序列化器

这个例子已经能看到 Jackson 的雏形:注解声明映射规则 → 反射读取字段 → 缓存并生成访问器。真实实现会额外处理循环引用、日期格式、@JsonIgnore 的继承语义等。

6.3 实战二:@Log + 反射实现调用日志

6.4 实战三:注解 + 反射实现迷你 DI 容器

下面这段是可直接运行的完整示例,涵盖组件扫描、依赖注入、单例缓存三个核心环节。

它和 Spring 的差距在于:没有循环依赖处理(Spring 用三级缓存)、没有 @Qualifier 区分同类型多实现、没有 BeanPostProcessor 扩展点、没有作用域与生命周期回调。但核心骨架是一致的:扫描 → 实例化 → 注入 → 缓存 → 需要时织入代理。

6.5 Spring 中注解的作用链条简述

  • @Autowired:AutowiredAnnotationBeanPostProcessor 在 Bean 属性填充阶段(populateBean)用反射遍历字段/方法,查找该注解,构造 DependencyDescriptor,再从 BeanFactory 按类型(必要时按 @Qualifier、@Primary)解析出依赖实例,最后 field.set。
  • @Transactional:InfrastructureAdvisorAutoProxyCreator(一个 BeanPostProcessor)在 Bean 初始化后判断类/方法上是否有 @Transactional,有则创建 AnnotationTransactionAttributeSource 解析事务属性,生成 TransactionInterceptor 织入代理;调用时由拦截器完成”开启事务 → 执行目标 → 提交/回滚”。

一句话总结:注解只是标记,真正干活的是”扫描注解的反射代码 + 把逻辑织入调用的代理”。

七、动态代理:无侵入增强的实现

7.1 静态代理的问题

静态代理要求你为每个被代理类手写或生成一个代理类:接口增加一个方法,所有代理类都要改;代理逻辑(日志、事务)稍有变化就要改 N 个文件。这是一场维护灾难。动态代理的目标就是:在运行期自动生成代理类,让增强逻辑只写一份。

7.2 JDK 动态代理

7.2.1 $Proxy0 的类结构与反编译要点

用 -Dsun.misc.ProxyGenerator.saveGeneratedFiles=true(JDK 8)或 -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true(JDK 9+)可以把生成的 class 文件落到磁盘。反编译 $Proxy0 你会看到它的结构:

结构要素内容
继承关系public final class $Proxy0 extends Proxy implements UserService(单继承被占用,所以必须基于接口)
静态字段每个被代理方法一个 private static Method m1/m2/m3...,在静态块里通过 Class.forName + getMethod 初始化
构造器public $Proxy0(InvocationHandler h),直接调用 super(h)
方法体public String getUserName(long id) { return (String) super.h.invoke(this, m3, new Object[]{id}); }
三个 Object 方法equals/hashCode/toString 也被转发到 InvocationHandler
类修饰符final,无法再被继承

完整的调用链是:proxy.getUserName(7) → $Proxy0.getUserName(生成的字节码,把 this、缓存好的静态 Method m3、装箱后的参数数组准备好)→ InvocationHandler.invoke(proxy, m3, args) → 你的增强逻辑 → m3.invoke(target, args)(反射调用真实对象)→ 返回结果(基本类型拆箱、强转)。

容易忽略的两点:一是 invoke 的第一个参数 proxy 不要在增强逻辑里再调用它的方法,否则会递归死循环,要调用就调 target;二是 代理类会缓存(默认在 Proxy 的 WeakCache 里),同一个接口重复生成不会无限产生新类。

7.3 CGLIB 动态代理

CGLIB(底层用 ASM)不要求接口,它的做法是在运行期生成目标类的子类字节码,重写非 final 的公开方法,在方法里调用 MethodInterceptor.intercept()。

CGLIB 的三个硬限制:不能代理 final 类(无法继承)、不能代理 final / private 方法(无法重写)、目标类必须有可访问构造器(生成子类要调用 super())。另外它的 FastClass 机制通过给方法编号、用 switch 分派,绕开了反射调用,这也是它在多次调用场景下性能好于 JDK 代理的原因之一。

7.4 JDK 代理与 CGLIB 对比

维度JDK 动态代理CGLIB
实现方式运行时生成实现指定接口的代理类运行时生成目标类的子类(ASM 字节码)
前提目标必须实现接口目标类不能是 final、方法不能是 final/private
生成速度较快(生成逻辑简单)较慢(要生成 FastClass 等多个类)
调用性能JDK 8+ 已大幅优化,多数场景与 CGLIB 持平甚至更快单次调用通常略优(FastClass 索引调用)
内存/元空间较轻较重,每个代理类附带多个辅助类
依赖JDK 自带,零依赖需要额外依赖(Spring 已内置)
典型场景RPC 客户端(Feign/Dubbo)、基于接口的 AOP类没有接口、需要代理具体类
toString/equals也被转发到处理器同样会被拦截

7.5 Spring AOP 如何在两者之间选

Spring 的 DefaultAopProxyFactory 逻辑非常直白:

  1. 如果 proxyTargetClass = true( 或 @EnableAspectJAutoProxy(proxyTargetClass = true)),直接用 CGLIB。
  2. 否则如果目标类实现了接口,用 JDK 代理。
  3. 否则(没有接口)用 CGLIB。

重要变化:从 Spring Boot 2.x / Spring 5.2 起,proxyTargetClass 的默认值被改成了 true,也就是默认全部使用 CGLIB。原因是 JDK 代理有个经典坑:如果 Bean 有接口,注入时按接口类型拿到的代理对象无法强转回实现类(ClassCastException),大量用户踩坑;再加上 JDK 8 之后 CGLIB 的性能劣势基本消失,于是官方干脆统一为 CGLIB。在 JDK 17 环境下的现代 Spring Boot 项目,默认就是 CGLIB 代理,理解这一点能帮你少排查很多”为什么这个类的 @Transactional 不生效”(答案是:同一类内部方法自调用,压根没走代理)。

7.6 实战:用 JDK 代理实现耗时统计 + 重试的迷你 RPC 客户端

7.7 代理在业务中的四类典型用法

  1. 日志与监控:方法级耗时、入参出参埋点、链路追踪(SkyWalking 的 Java Agent 本质上是更强的字节码增强)。
  2. 声明式事务:@Transactional 的开启/提交/回滚,业务代码零侵入。
  3. 缓存:@Cacheable 在调用前查缓存、命中直接返回,未命中才走目标方法并回填。
  4. 限流与熔断:在 invoke 里先过令牌桶 / 信号量,超限快速失败,这是 Sentinel、Resilience4j 的常见织入方式。

记住这条判断标准:当你发现”很多方法都要在前后加同一段逻辑”,而且这段逻辑与业务无关时,就该考虑代理了。反之,只有一两个方法需要特殊处理,直接写在方法里更清楚。

八、安全、边界与高频面试题

8.1 setAccessible 的危害与模块化限制

setAccessible(true) 能撬开 private,这在生产代码里基本等于主动放弃封装:你依赖了别人明确声明为内部实现的东西,对方一次小版本升级就能让你崩掉。JDK 9 引入模块系统后,官方从 JVM 层面收紧了这条口子。

规则是这样的:只有声明了 opens(或 open module)的包,才允许对其非 public 成员做 setAccessible(true)。跨模块访问未导出的类型会抛 IllegalAccessException,访问未 open 的包会抛 InaccessibleObjectException。

应急方案是加 VM 参数(仅在确实需要时,且要清楚后果):

到了 JDK 17(JEP 403),JDK 内部元素被强封装,--illegal-access 的作用被极大限制,很多老框架(尤其是深度依赖 sun.misc.Unsafe、反射修改 JDK 内部字段的库)必须显式 --add-opens 才能跑起来。这也是很多团队升级 JDK 17 时的第一道坎——升级前先用 jdeps --jdk-internals 扫一遍依赖。

8.2 反射与序列化漏洞

反射是 Java 反序列化漏洞链条上的关键一环:ObjectInputStream 可以在运行期根据字节流里的类名 Class.forName 并实例化任意类;攻击者构造恶意的 gadget chain(如 InvokerTransformer 通过反射调用 Runtime.exec),就能实现远程代码执行。防御手段包括:

  • 使用 ObjectInputFilter(JDK 9 起,JDK 17 可用 JEP 415 的过滤器工厂)做白名单校验。
  • 不反序列化不可信数据,改用 JSON/Protobuf 等纯数据格式。
  • 及时升级 commons-collections、fastjson 等历史高危库。

8.3 高频面试题

#问题要点
1反射是什么?有什么优缺点?运行期获取类元数据并操作;优点是通用灵活,缺点是性能、安全、可维护性
2获取 Class 对象有哪几种方式?区别?Xxx.class(不初始化)、obj.getClass()(需实例)、Class.forName(默认初始化)
3getMethods() 与 getDeclaredMethods() 区别?前者含继承的 public 方法,后者只含本类所有修饰符但不含继承
4Method.invoke 为什么慢?参数装箱、访问检查、JNI(未膨胀时)、无法内联;膨胀后生成字节码访问器
5inflation 机制是什么?前 15 次走 NativeMethodAccessorImpl,超过阈值由 MethodAccessorGenerator 生成 GeneratedMethodAccessor 替换;-Dsun.reflect.inflationThreshold 可调
6如何优化反射性能?缓存 Class/Method/Field、setAccessible(true)、MethodHandle、LambdaMetafactory、字节码增强
7Class.forName 会触发静态初始化吗?默认会;forName(name, false, loader) 和 ClassLoader.loadClass 不会
8注解的本质是什么?继承 Annotation 的接口,运行期由 JVM 生成动态代理实例
9@Retention 三种策略区别?SOURCE 编译后丢弃 / CLASS 进 class 文件但反射读不到 / RUNTIME 可被反射读取
10@Inherited 对方法注解生效吗?不生效,只对类继承有效,且接口上的注解不会被继承
11JDK 动态代理为什么必须基于接口?生成的代理类必须 extends Proxy,Java 单继承,只能靠实现接口扩展
12$Proxy0 里的方法是怎么转发的?静态块缓存 Method 对象,方法体调用 InvocationHandler.invoke(this, m, args)
13JDK 代理与 CGLIB 的区别?接口 vs 继承子类;final 限制;性能与依赖差异
14Spring 默认用哪种代理?Spring 5.2+ / Boot 2.x 起默认 CGLIB(proxyTargetClass=true)
15为什么同一个类里 this.method() 的 @Transactional 不生效?没走代理对象,直接是目标对象内部调用
16JDK 9 之后反射访问 JDK 内部类被拦截怎么办?InaccessibleObjectException,用 --add-opens 精确开放,或升级框架
17反射能修改 final 字段吗?实例字段在特定版本”看似可行”但不可靠(常量折叠);静态 final 与 record / hidden class 的 trusted final 已被禁止,不要依赖
18泛型擦除后怎么拿到真实类型?getGenericReturnType / getGenericParameterTypes / getGenericSuperclass + ParameterizedType.getActualTypeArguments()

8.4 一条实践准则

反射是给框架用的,注解是给框架看的,代理是框架给你的。 业务代码里如果出现了 Class.forName、Method.invoke、Proxy.newProxyInstance,先停下来问一句:能不能用接口、策略模式、或已有的框架能力替代?如果答案是”我在写一个通用组件”,那就放手去写,但请把反射对象缓存起来、把异常处理干净、把模块化兼容性考虑进去。

8.5 全篇小结与下一篇预告

本篇沿着”元数据 → 解析 → 织入”这条线,把三块基石串了起来:Class 对象是入口,Constructor/Field/Method 是三把工具,invoke 的膨胀机制决定了它的性能下限,注解为反射提供了声明式输入,动态代理则把反射包装成了对用户无感知的优雅增强。理解它们之后,Spring 的 IoC 与 AOP 就不再是”魔法”,而是一套可以被你自己复现的工程实现。

下一篇我们将进入 Java 并发编程的核心:JMM 与 volatile、synchronized 的底层实现,从 CPU 缓存一致性协议讲到对象头里的 Mark Word,把”可见性、原子性、有序性”这三件事真正讲清楚。


系列导航:Java 从入门到精通(一) · (二)面向对象 · (三)集合框架 · (四)异常与泛型 · (五)IO 与 NIO · (六)并发基础 · (七)JUC 工具类 · (八)JVM 内存与垃圾回收 · (九)类加载机制 · (十)Lambda 与 Stream · (十一)反射、注解与动态代理(本篇)

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论