Java反射:从运行时窥探到动态代理的工程实践
大家好,我是程序员小策。
场景:你写了一个配置解析框架,需要根据配置文件里的类名动态加载不同的策略类。本地用 if-else 硬编码跑得一切正常,但每加一个策略就要改一次代码、发一次版。
你觉得是设计模式没选对,想用工厂模式,结果发现工厂里还是一堆 if-else。
最后你发现——所有框架(Spring、MyBatis、JUnit)都在用同一个东西解决这个问题。这个东西就是 Java 反射。
问题定义:框架怎么做到"不认识你的类,却能调用你的方法"?
Java 是一门静态类型语言。编译器在编译期就确定了变量类型、方法签名、访问权限——你写User user = new User(),编译器就知道 user 是 User 类型,只能调 User 的方法。
这很好,很安全。但有一个矛盾:框架代码怎么做到"不认识你的类,却能调用你的方法"?
Spring 怎么知道你的@Autowired字段该注入什么?MyBatis 怎么把 ResultSet 映射到你写的 POJO?JUnit 怎么找到所有标了@Test的方法?
朴素方案:框架硬编码支持所有可能的类。显然不可能。
真正的答案:框架在运行时"看穿"了你的类结构,然后动态操作它。这个能力,就是反射。
核心概念:反射是什么?
反射(Reflection):在程序运行期间,动态获取类的内部信息(字段、方法、构造器、注解等),并能够动态调用方法和修改字段值的能力。
想象你去医院做体检。
平时你看一个人,只能看到外表——身高、体型、肤色。这就像编译期:编译器只能看到你声明的类型和 public 成员。
但医生给你拍了个 CT。骨骼、器官、血管——所有内部结构一览无余。医生甚至能通过手术操作这些内部结构——切除一个囊肿、植入一个支架。
反射就是 Java 的 CT 机。正常情况下你只能通过 public 方法操作对象(看外表),但反射让你在运行时看到类的所有成员(拍 CT),包括 private 的,甚至能修改它们(做手术)。
当然,就像手术有风险一样——反射也有代价。
代码实现:反射的完整操作链路
场景:模拟一个简易的"策略加载器"——根据配置的类名,动态加载策略并执行。
importjava.lang.reflect.Constructor;importjava.lang.reflect.Field;importjava.lang.reflect.Method;interfaceStrategy{voidexecute();}classDiscountStrategyimplementsStrategy{privateStringdiscountType="满减";@Overridepublicvoidexecute(){System.out.println("执行折扣策略: "+discountType);}}publicclassReflectionDemo{publicstaticvoidmain(String[]args)throwsException{StringclassName="DiscountStrategy";// 1. 获取 Class 对象 — 三种方式Class<?>clazz=Class.forName(className);// Class<?> clazz2 = DiscountStrategy.class;// Class<?> clazz3 = new DiscountStrategy().getClass();// 2. 创建实例 — 通过反射调用构造器Constructor<?>constructor=clazz.getDeclaredConstructor();constructor.setAccessible(true);Objectinstance=constructor.newInstance();// 3. 调用方法 — 找到 execute 方法并执行MethodexecuteMethod=clazz.getDeclaredMethod("execute");executeMethod.setAccessible(true);executeMethod.invoke(instance);// 4. 修改私有字段 — 把 discountType 改成 "打折"FielddiscountField=clazz.getDeclaredField("discountType");discountField.setAccessible(true);discountField.set(instance,"打折");// 再次执行,看效果executeMethod.invoke(instance);}}输出:
执行折扣策略: 满减 执行折扣策略: 打折逐段解释:
获取 Class 对象是反射的入口。三种方式各有适用场景:Class.forName()适合配置驱动的动态加载(你只有类名字符串);.class语法适合编译期就知道类型的场景;getClass()适合你已经有实例的情况。
setAccessible(true)是关键。它打破了 Java 的访问控制——private 的字段和方法,正常情况下外部无法访问,但setAccessible(true)相当于告诉 JVM:“我知道我在干什么,让我进去。” 这就是反射的"手术刀"。
invoke()是反射的核心操作——通过 Method 对象调用方法,而不是通过对象.方法名()的常规方式。框架就是这样做到"不认识你的类,却能调用你的方法"的。
边界与陷阱:反射的三个大坑
看起来很强大对吧?但反射的坑,比你想的多。
陷阱一:性能损耗。反射调用比直接调用慢 10-100 倍。原因有三:每次调用都要做方法查找、参数类型检查、访问权限校验。虽然setAccessible(true)可以跳过权限校验,但方法查找的开销无法避免。
后果:在高频调用路径上用反射(比如循环里每次都getDeclaredMethod),性能直接崩。
解法:缓存 Method/Field 对象。只在第一次调用时查找,后续复用。
陷阱二:破坏封装。setAccessible(true)能修改 private 字段,包括final的。你以为final不可变?反射说:不,我可以。
后果:修改String的value字段?可以做到,但 JVM 优化(如字符串常量池、内联)全部失效,行为不可预测。
解法:永远不要在生产代码中用反射修改 final 字段。如果一定要改,说明你的设计有问题。
陷阱三:编译期丢失类型安全。反射调用方法时,参数类型是Object[],编译器不会帮你检查类型是否匹配。类型错了?运行时抛IllegalArgumentException。
后果:一个拼写错误、一个参数类型不匹配,编译期不报错,运行时才炸。
解法:反射代码必须有完善的异常处理和单元测试。把反射调用封装在工具方法里,对外提供类型安全的 API。
高级考量:反射在框架生态中的全局影响
反射在单机场景下只是"慢一点"。但放到框架生态里,它的影响是全局性的。
Spring 的整个 IoC 容器就是建立在反射之上的。@Component扫描 →Class.forName()加载 → 反射创建实例 →@Autowired反射注入字段。这意味着什么?
Spring Boot 启动慢,一部分原因就是大量的反射操作。Spring 也意识到了这个问题——从 Spring Framework 5 开始,逐步引入了基于MethodHandle和Lambda的更快代理机制,以及objenesis和cglib优化。
反射与模块化(JPMS)的冲突。Java 9 引入模块系统后,反射访问其他模块的内部类受到了严格限制。--add-opens和--add-exports成了启动参数里的常客——这不是 bug,是模块化对反射的"有意限制"。
反射与泛型擦除。泛型在编译后会被擦除,List<String>运行时只是List。但反射提供了一个"后门"——通过ParameterizedType和getGenericSuperclass(),可以在运行时获取部分泛型信息。Spring 的ParameterizedTypeReference就是靠这个实现的。
对比表格
| 获取 Class 的方式 | 使用时机 | 是否触发类初始化 | 典型场景 |
|---|---|---|---|
Class.forName("全限定名") | 运行时只有类名字符串 | 是 | JDBC 加载驱动、配置驱动加载 |
类名.class | 编译期已知类型 | 否(使用时才初始化) | 泛型传参、同步锁对象 |
对象.getClass() | 已有实例 | 已初始化 | 运行时判断实际类型 |
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 直接调用 | 编译期确定类型和方法 | 类型安全、性能最优 | 灵活性差,无法动态扩展 | 业务代码 |
| 反射 | 运行时动态获取和调用 | 极高灵活性,框架基石 | 性能损耗、破坏封装、丢失类型安全 | 框架、工具类 |
| MethodHandle | 方法句柄,编译期类型安全 | 比反射快、类型安全 | API 复杂、学习成本高 | 高性能动态调用 |
| 动态代理 | 基于反射的 AOP 机制 | 无侵入增强 | 仅限接口(JDK 代理) | AOP、RPC、中间件 |
一句话:业务代码用直接调用,框架代码用反射,追求性能用 MethodHandle,需要增强用动态代理。
面试追问
面试追问 1:反射为什么慢?能优化吗?
→ 回答方向:方法查找 + 权限校验 + 参数装箱。优化手段:缓存 Method 对象、使用setAccessible(true)跳过权限检查、考虑 MethodHandle 替代。
面试追问 2:setAccessible(true)到底做了什么?它真的能绕过所有访问控制吗?
→ 回答方向:它关闭了 JVM 的访问权限检查。但在 Java 9+ 的模块系统下,跨模块访问非导出包时,setAccessible(true)会抛InaccessibleObjectException,除非用--add-opens显式开放。
面试追问 3:JDK 动态代理和 CGLIB 代理的区别?为什么 Spring 默认用 CGLIB?
→ 回答方向:JDK 代理基于接口,CGLIB 基于继承。JDK 代理只能代理接口方法,CGLIB 能代理类方法(final 除外)。Spring Boot 2.x 默认用 CGLIB,因为大部分场景需要代理类而非接口。
面试追问 4:反射能获取泛型信息吗?不是说泛型擦除了吗?
→ 回答方向:泛型确实会擦除,但编译器会在 class 文件的 Signature 属性中保留泛型信息。通过getGenericSuperclass()和ParameterizedType可以在运行时读取这些信息——前提是泛型是在继承或实现时显式指定的(如class MyList extends ArrayList<String>)。
总结
反射不是"黑魔法",是框架的"基础设施"——你不需要天天写反射代码,但你需要理解框架是怎么用反射替你干活的。
读完这篇你应该能:解释 Spring 为什么能"自动"注入依赖、写出通过反射动态加载和调用方法的完整代码、在面试中区分反射的三种获取 Class 方式的差异、理解setAccessible(true)的代价和限制。
下次看到Class.forName(),别只想到 JDBC——想想它背后那台 CT 机。
