大家好,我是程序员小策。

场景:你写了一个配置解析框架,需要根据配置文件里的类名动态加载不同的策略类。本地用 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 的,甚至能修改它们(做手术)。

当然,就像手术有风险一样——反射也有代价。

代码实现:反射的完整操作链路

场景:模拟一个简易的"策略加载器"——根据配置的类名,动态加载策略并执行。

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

interface Strategy {
    void execute();
}

class DiscountStrategy implements Strategy {
    private String discountType = "满减";

    @Override
    public void execute() {
        System.out.println("执行折扣策略: " + discountType);
    }
}

public class ReflectionDemo {
    public static void main(String[] args) throws Exception {
        String className = "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);
        Object instance = constructor.newInstance();

        // 3. 调用方法 — 找到 execute 方法并执行
        Method executeMethod = clazz.getDeclaredMethod("execute");
        executeMethod.setAccessible(true);
        executeMethod.invoke(instance);

        // 4. 修改私有字段 — 把 discountType 改成 "打折"
        Field discountField = 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 不可变?反射说:不,我可以。

后果:修改 Stringvalue 字段?可以做到,但 JVM 优化(如字符串常量池、内联)全部失效,行为不可预测。

解法:永远不要在生产代码中用反射修改 final 字段。 如果一定要改,说明你的设计有问题。

陷阱三:编译期丢失类型安全。 反射调用方法时,参数类型是 Object[],编译器不会帮你检查类型是否匹配。类型错了?运行时抛 IllegalArgumentException

后果:一个拼写错误、一个参数类型不匹配,编译期不报错,运行时才炸。

解法:反射代码必须有完善的异常处理和单元测试。 把反射调用封装在工具方法里,对外提供类型安全的 API。

高级考量:反射在框架生态中的全局影响

反射在单机场景下只是"慢一点"。但放到框架生态里,它的影响是全局性的。

Spring 的整个 IoC 容器就是建立在反射之上的。 @Component 扫描 → Class.forName() 加载 → 反射创建实例 → @Autowired 反射注入字段。这意味着什么?

Spring Boot 启动慢,一部分原因就是大量的反射操作。Spring 也意识到了这个问题——从 Spring Framework 5 开始,逐步引入了基于 MethodHandleLambda 的更快代理机制,以及 objenesiscglib 优化。

反射与模块化(JPMS)的冲突。 Java 9 引入模块系统后,反射访问其他模块的内部类受到了严格限制。--add-opens--add-exports 成了启动参数里的常客——这不是 bug,是模块化对反射的"有意限制"。

反射与泛型擦除。 泛型在编译后会被擦除,List<String> 运行时只是 List。但反射提供了一个"后门"——通过 ParameterizedTypegetGenericSuperclass(),可以在运行时获取部分泛型信息。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 机。

更多推荐