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

Java 从入门到精通(十一):反射、注解与动态代理——框架底层的三块基石
神经蛙本篇属于「知其然更知其所以然」的一章。反射、注解、动态代理这三样东西,日常写业务代码几乎碰不到,但只要你点开 Spring 的 AutowiredAnnotationBeanPostProcessor、MyBatis 的 MapperProxy、Jackson 的 BeanDeserializer,就会发现它们无处不在。理解这三块基石,你读框架源码时就不会再迷路,也能在真正需要”通用能力”的场景里写出靠谱的底层工具,而不是把反射当成万能胶乱抹一气。阅读建议:跟着代码敲一遍,尤其是第六节的 DI 容器和第七节的代理切面,跑通之后很多面试题会自然解开。
写在前面:为什么这三个东西要一起讲
很多教材把反射、注解、动态代理拆成三章,结果读者学完只记住了三套 API,串不起来。实际上它们是一条流水线上的三道工序:注解负责”打标记”(声明元数据),反射负责”读标记 + 执行动作”(运行期解析与调用),动态代理负责”把动作织入调用过程”(无侵入增强)。
Spring 里 @Transactional 就是一个典型闭环:你在方法上打一个注解(注解),Spring 容器启动时用反射扫描到它并判读事务属性(反射),然后为目标对象生成一个代理对象,让每次方法调用先经过事务拦截器(动态代理)。少了任何一环,这个能力都实现不了。
所以本篇的顺序是:先把反射讲透(它是唯一真正”做事”的那个),再讲注解(给反射提供输入),最后讲代理(把反射包装成优雅的形态)。
一、反射是什么:编译期确定 vs 运行期确定
1.1 从一行最普通的代码说起
1 | User user = new User(); |
这行代码在编译期就已经完全确定了:编译器知道 User 有 setName 方法、参数是 String、返回值是 void。生成的字节码里是一条 invokevirtual 指令,指向常量池中一个明确的符号引用,JVM 加载类时把它解析成直接引用,之后每次调用都是一次近乎”硬编码”的跳转。
反射把这个过程推迟到了运行期:你手上只有一个字符串 "com.example.User" 和另一个字符串 "setName",要等程序跑起来才知道有没有这个类、有没有这个方法、参数类型对不对。
1 | // 编译期:不知道类名、方法名、参数类型,全部来自运行期的字符串 |
这种”推迟”带来了一个关键能力:代码可以操作它编译时不认识的类型。这正是框架存在的意义——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 无法帮你重构)。
合理的边界可以概括成三条:
- 写通用基础设施时用反射(框架、ORM、序列化、测试框架、插件系统)。
- 写业务逻辑时不要用反射。能用
if解决的不要用Class.forName;能用接口解决的不要用Method.invoke。 - 热路径上必须缓存反射对象。
Class.forName/getMethod这些查找操作的成本远高于invoke本身,把Method对象缓存成静态常量,往往是最大的一次优化。
1.4 反射的三种典型误用
- 用反射代替多态:拿到
type字符串后if ("A".equals(type))再Method.invoke,本质上是在用字符串重新实现一遍switch。正确做法是定义一个接口 + 多个实现类 + 一个Map<String, Handler>,把运行期分派交给 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 三种获取方式对比
1 | public class ClassObtainDemo { |
| 获取方式 | 语法 | 是否加载类 | 是否触发静态初始化 | 编译期类型安全 | 典型场景 |
|---|---|---|---|---|---|
| 类字面量 | User.class |
否(仅解析符号引用,首次主动使用时才加载) | 不触发(属于被动引用) | 是,可写 Class<User> |
已知类型时首选,如 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 与包装类
1 | public class PrimitiveClassDemo { |
注意 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:创建对象的两种方式
1 | public class ConstructorDemo { |
newInstance 与 new 的区别:new 是编译期确定、字节码直接分配,Constructor.newInstance 要走访问检查、参数装箱、异常包装(目标构造抛出的异常会被包装成 InvocationTargetException,需要用 getCause() 取真实异常),并且无法被 JIT 内联。性能上后者慢一到两个数量级,但它能在运行期决定要 new 什么。
3.2 Field:读写字段
1 | public class FieldDemo { |
不要试图修改 final 字段。 JDK 8 时代它”看起来能用”,但依赖的是未定义行为:另一个类里 static final int MAX = 100 在编译期就被内联进调用方的字节码,你改了字段值,别人读到的还是 100。JDK 9 引入模块系统后,反射修改 final 的口子越收越紧(records、hidden classes 的 trusted final 完全禁止),JDK 17 之后很多场景直接抛 IllegalAccessException。把它当成不存在的能力。
3.3 Method:调用方法
1 | public class MethodDemo { |
三个高频坑:参数类型要精确(int.class 不能写 Integer.class)、可变参数要二次包装(new Object[]{...})、异常要 getCause()。
3.4 数组反射与动态二维数组
1 | import java.lang.reflect.Array; |
注意 Object[] 与 int[] 不能直接互转:new int[]{1,2}.getClass() 不是 Object[].class 的子类型,(Object[]) Array.get(...) 在基本类型数组上会 ClassCastException。用 Array.getXxx 系列方法最稳。
3.5 泛型信息获取
Java 泛型是擦除的,但擦除不等于”什么都不剩”:签名(Signature)属性会保留声明处的泛型信息,getGenericXxx 系列方法可以把它们读出来。
1 | import java.lang.reflect.*; |
这套 API 是 MyBatis 解析 interface UserMapper extends BaseMapper<User>、Spring 解析 Repository<User> 的关键:通过 getGenericInterfaces() 拿到 ParameterizedType,再取 getActualTypeArguments()[0] 就得到了实体类型 User。
四、反射性能:invoke 到底慢在哪
4.1 完整调用链路
一次 Method.invoke 在 HotSpot 上大致经历这些步骤:
- 可变参数打包:
invoke(obj, args...)本身是可变参数方法,调用方要先构造一个Object[](这是逃逸分析能优化掉的少数部分)。 - 访问检查:若
override == false,执行Reflection.getCallerClass()拿到调用者类,再走Reflection.verifyMemberAccess()+checkAccess(),逐层检查public/ 同包 / 子类 / 模块导出关系。这一步每次调用都会做。 - 获取 MethodAccessor:
Method内部持有一个MethodAccessor引用,首次调用时通过ReflectionFactory创建。 - DelegatingMethodAccessorImpl:只是一个可变的委托壳子,方便后续”热替换”实现。
- NativeMethodAccessorImpl:真正的第一版实现,内部是 JNI 调用
Method.invoke0。它维护一个numInvocations计数器,超过阈值(默认 15 次)后触发膨胀(inflation):由MethodAccessorGenerator在运行时生成一段字节码(类名类似GeneratedMethodAccessor1),并把它塞进DelegatingMethodAccessorImpl替换掉 Native 版本。 - GeneratedMethodAccessor:生成的是普通 Java 字节码(做了参数拆箱、类型转换、直接
invokevirtual),可以被 JIT 编译优化甚至内联,性能接近直接调用。
1 | // 观察膨胀现象:打开 -Dsun.reflect.inflationThreshold=1 与默认值对比 |
相关 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 ~ 3 | JDK 7 引入,JIT 友好,可内联 |
缓存的 Method.invoke(已膨胀 + setAccessible) |
5 ~ 20 | 框架中最常用,够快 |
未膨胀的 Method.invoke(前 15 次) |
50 ~ 200 | JNI 调用,慢 |
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 的官方替代品。
1 | import java.lang.invoke.*; |
4.5 框架里的优化策略
- 缓存元数据:Spring 的
CachedIntrospectionResults、Jackson 的BeanPropertyWriter都是把反射结果缓存成访问器对象,避免每次解析。 LambdaMetafactory生成函数式访问器:不用Method.invoke,而是在运行期生成一个实现了Function/BiConsumer的类,get/set 变成普通接口调用。MyBatis 的PropertyAccessor、很多 JSON 库的高性能路径都这么干。- 一次性 setAccessible:启动时统一处理,而不是每次调用时判断。
- 必要时用字节码增强(ASM / ByteBuddy / CGLIB):直接生成访问类,彻底消灭反射调用。
五、注解基础:会说话的元数据
5.1 注解的本质
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() 数组元素 |
1 | import java.lang.annotation.*; |
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 用反射读取运行时注解
1 | import java.lang.annotation.*; |
6.2 实战一:@JsonField + 迷你序列化器
1 | import java.lang.annotation.*; |
这个例子已经能看到 Jackson 的雏形:注解声明映射规则 → 反射读取字段 → 缓存并生成访问器。真实实现会额外处理循环引用、日期格式、@JsonIgnore 的继承语义等。
6.3 实战二:@Log + 反射实现调用日志
1 | import java.lang.annotation.*; |
6.4 实战三:注解 + 反射实现迷你 DI 容器
下面这段是可直接运行的完整示例,涵盖组件扫描、依赖注入、单例缓存三个核心环节。
1 | import java.lang.annotation.*; |
它和 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 动态代理
1 | import java.lang.reflect.*; |
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()。
1 | // 需要依赖:cglib:cglib:3.3.0(Spring 已内置重打包版本 spring-core 中的 org.springframework.cglib) |
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 逻辑非常直白:
- 如果
proxyTargetClass = true(<aop:aspectj-autoproxy proxy-target-class="true"/>或@EnableAspectJAutoProxy(proxyTargetClass = true)),直接用 CGLIB。 - 否则如果目标类实现了接口,用 JDK 代理。
- 否则(没有接口)用 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 客户端
1 | import java.lang.reflect.*; |
7.7 代理在业务中的四类典型用法
- 日志与监控:方法级耗时、入参出参埋点、链路追踪(SkyWalking 的 Java Agent 本质上是更强的字节码增强)。
- 声明式事务:
@Transactional的开启/提交/回滚,业务代码零侵入。 - 缓存:
@Cacheable在调用前查缓存、命中直接返回,未命中才走目标方法并回填。 - 限流与熔断:在
invoke里先过令牌桶 / 信号量,超限快速失败,这是 Sentinel、Resilience4j 的常见织入方式。
记住这条判断标准:当你发现”很多方法都要在前后加同一段逻辑”,而且这段逻辑与业务无关时,就该考虑代理了。反之,只有一两个方法需要特殊处理,直接写在方法里更清楚。
八、安全、边界与高频面试题
8.1 setAccessible 的危害与模块化限制
setAccessible(true) 能撬开 private,这在生产代码里基本等于主动放弃封装:你依赖了别人明确声明为内部实现的东西,对方一次小版本升级就能让你崩掉。JDK 9 引入模块系统后,官方从 JVM 层面收紧了这条口子。
1 | // 假设目标是 JDK 内部类 java.util.ArrayList 的私有字段(java.base 模块未 opens java.util) |
规则是这样的:只有声明了 opens(或 open module)的包,才允许对其非 public 成员做 setAccessible(true)。跨模块访问未导出的类型会抛 IllegalAccessException,访问未 open 的包会抛 InaccessibleObjectException。
应急方案是加 VM 参数(仅在确实需要时,且要清楚后果):
1 | # JDK 9~16:放宽非法反射访问(JDK 16 起默认 deny) |
到了 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(默认初始化) |
| 3 | getMethods() 与 getDeclaredMethods() 区别? |
前者含继承的 public 方法,后者只含本类所有修饰符但不含继承 |
| 4 | Method.invoke 为什么慢? |
参数装箱、访问检查、JNI(未膨胀时)、无法内联;膨胀后生成字节码访问器 |
| 5 | inflation 机制是什么? | 前 15 次走 NativeMethodAccessorImpl,超过阈值由 MethodAccessorGenerator 生成 GeneratedMethodAccessor 替换;-Dsun.reflect.inflationThreshold 可调 |
| 6 | 如何优化反射性能? | 缓存 Class/Method/Field、setAccessible(true)、MethodHandle、LambdaMetafactory、字节码增强 |
| 7 | Class.forName 会触发静态初始化吗? |
默认会;forName(name, false, loader) 和 ClassLoader.loadClass 不会 |
| 8 | 注解的本质是什么? | 继承 Annotation 的接口,运行期由 JVM 生成动态代理实例 |
| 9 | @Retention 三种策略区别? |
SOURCE 编译后丢弃 / CLASS 进 class 文件但反射读不到 / RUNTIME 可被反射读取 |
| 10 | @Inherited 对方法注解生效吗? |
不生效,只对类继承有效,且接口上的注解不会被继承 |
| 11 | JDK 动态代理为什么必须基于接口? | 生成的代理类必须 extends Proxy,Java 单继承,只能靠实现接口扩展 |
| 12 | $Proxy0 里的方法是怎么转发的? |
静态块缓存 Method 对象,方法体调用 InvocationHandler.invoke(this, m, args) |
| 13 | JDK 代理与 CGLIB 的区别? | 接口 vs 继承子类;final 限制;性能与依赖差异 |
| 14 | Spring 默认用哪种代理? | Spring 5.2+ / Boot 2.x 起默认 CGLIB(proxyTargetClass=true) |
| 15 | 为什么同一个类里 this.method() 的 @Transactional 不生效? |
没走代理对象,直接是目标对象内部调用 |
| 16 | JDK 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 · (十一)反射、注解与动态代理(本篇)

















