Java 反射是什么?有什么优缺点?
简化版
反射(Reflection)让程序在运行时拿到一个类的完整信息——字段、方法、构造器、注解,并能动态地创建对象、调方法、读写字段。它是「框架的魔法」:Spring 的依赖注入、MyBatis 的 ORM、JSON 序列化,底层都靠它。代价是损失编译期检查、性能略低、可能破坏封装。
详细版
普通代码在编译期就定死了「调哪个类的哪个方法」;反射把这个决定推迟到运行时。入口是 Class 对象,三种拿法:
Class<?> c1 = User.class; // 编译期已知类型
Class<?> c2 = user.getClass(); // 从实例拿
Class<?> c3 = Class.forName("com.example.User"); // 只有字符串名,运行时加载
拿到 Class 后就能动态操作:
Object user = c3.getDeclaredConstructor().newInstance(); // 无参构造建对象
Method m = c3.getMethod("setName", String.class); // 找方法
m.invoke(user, "Alice"); // 动态调用
框架为什么离不开它?因为框架在写的时候不知道你的业务类长什么样。Spring 扫到一个带 @Component 的类,只能靠反射去实例化、去找 @Autowired 的字段注入;MyBatis 拿到查询结果,只能靠反射把列名映射到你 POJO 的字段。缺点也很实在:编译器管不到反射调用,写错方法名要运行时才炸;反射调用比直接调用慢;setAccessible(true) 能撬开私有成员,破坏封装。
完整版教学
一、反射到底「反」的是什么
正常流程是「源码 → 类」:你写 new User(),编译期就把类型信息固化进字节码。反射是反过来:程序运行时,从一个已经加载的类「往回」读出它的结构(这些结构信息存在方法区 / 元空间里)。所以叫 reflection——程序在照镜子看自己。
它的能力边界是:只要类被 JVM 加载了,它的所有成员(包括私有的)都能被反射读到、操作到。
二、框架三大典型用法
- 依赖注入(Spring):扫描注解找到 Bean,反射调构造器实例化,反射给
@Autowired字段赋值。 - ORM 映射(MyBatis / JPA):数据库列名 → 反射 set 到实体字段;实体字段 → 反射 get 拼成 SQL 参数。
- 序列化(Jackson / Gson):反射遍历对象所有字段读值写成 JSON,或反过来把 JSON 值反射塞进字段。
共同点:框架代码在编译时不认识你的业务类,只能在运行时靠反射「临场」处理任意类型。这就是反射不可替代的价值。
三、代价一:性能
反射调用比直接调用慢,原因是它要做动态的方法查找、访问权限检查、参数装箱等额外工作,且难以被 JIT 内联优化。
但别夸大:现代 JVM 对反射有优化,且框架通常在启动时反射一次、之后缓存
Method/Field对象复用。所以真正性能敏感的热点循环别频繁反射,一般业务完全无感。对极致场景,可用MethodHandle或 JDK 的动态字节码生成替代。
四、代价二:破坏编译期安全与封装
Method m = c.getMethod("setName", String.class); // 方法名是字符串
方法名写成了字符串 "setName",编译器完全不检查——拼错了、参数类型变了,都要等运行时抛 NoSuchMethodException 才发现。这就把「编译期就能发现的错」拖到了线上。
setAccessible(true) 更要谨慎:它能强行访问 private 字段和方法,直接绕过封装。测试工具、序列化框架用它情有可原,业务代码里出现它,往往是设计有问题的信号。而且在 JPMS 模块化下,它可能被模块系统直接拒绝。
五、从扫描到调用的完整流程
成熟框架不会在每次业务请求中从头扫描反射。常见流程是启动期扫描类和注解,校验构造器或方法签名,把 Constructor、Method、Field 或更高层访问器缓存成元数据;请求期只按缓存执行。这样既把配置错误尽早暴露,又把重复查找成本移出热点路径。
启动:扫描 classpath → 读取注解 → 校验签名 → 构建并缓存访问器
请求:查元数据缓存 → 转换参数 → invoke / MethodHandle → 处理异常
假设处理 10 万次对象映射:若每次都 getDeclaredFields() 并解析注解,就做 10 万轮结构发现;若按 Class<?> 缓存一次,结构发现只做 1 次,后续只剩字段读写。优化重点通常不是“完全禁止反射”,而是把发现与执行拆开并缓存不可变元数据。
| 方案 | 编译期安全 | 运行灵活性 | 热点性能 | 常见用途 |
|---|---|---|---|---|
| 直接调用 | 高 | 低 | 最好 | 已知类型的业务代码 |
| 传统反射 | 低 | 高 | 有额外开销 | 通用框架、工具 |
MethodHandle | 中 | 高 | 预热后更易优化 | 动态调用基础设施 |
| 生成字节码/代码 | 较高 | 中 | 接近直接调用 | 高性能序列化、代理 |
六、常见误区与追问
- 误区:反射一定慢到不能用。 真正的问题是热点路径重复发现和检查,框架通过启动期解析与缓存降低成本。
- 误区:
getMethods()与getDeclaredMethods()等价。 前者包含可见的继承 public 方法,后者只取当前类声明的方法且包含非 public 成员。 - 误区:
setAccessible(true)在任何环境都能突破 private。 JPMS 强封装和安全策略可能拒绝深反射,JDK 内部包尤其如此。 - 追问:
Class.forName()会做什么? 默认会加载并初始化指定类,可能触发静态初始化;需要精细控制时可使用带 initialize 参数的重载。 - 追问:
Method.invoke()抛出的业务异常如何取得? 目标方法异常会被包装为InvocationTargetException,原异常在getCause()中。 - 追问:泛型信息能否通过反射拿到? 签名元数据中的参数化类型通常可以读取,但类型擦除后运行时对象本身不能恢复所有具体类型实参。
记忆钩子:反射的正确姿势是“启动时发现并校验,请求时查缓存执行”,不是每次临场翻遍整个类。
反射调用还改变了异常表面。目标方法本来抛出 IllegalArgumentException,通过 Method.invoke 调用后,调用者首先收到的是 InvocationTargetException;框架若不解包 cause,日志和异常映射都会误判故障类型。
try {
method.invoke(target, args);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
throw new FrameworkInvocationException("调用失败", cause);
}
访问控制也应在启动期完成:发现方法不存在、参数数目不符或模块未开放时立即让应用启动失败,比等到第 10 万个请求才触发反射异常可靠得多。缓存键通常至少包含声明类、成员名和参数类型,因为 Java 允许同名重载,仅按名称缓存会调用错方法。
错误缓存键:User#set
可靠缓存键:User#set(java.lang.String)
如果目标类型可能被卸载,还要警惕静态缓存强引用 Class 导致类加载器泄漏;容器或插件系统常使用随生命周期清理的缓存,或谨慎选择弱引用结构。
七、加强记忆
反射以 Class 为入口,让程序在运行期发现构造器、字段、方法和注解,并动态创建或调用,因此框架才能处理编译时未知的业务类型。它以编译期安全、封装边界和额外调用成本换取通用性;工程上应缓存元数据、限制深反射、正确解包调用异常,并在稳定已知的业务路径优先直接 API。理解“发现阶段”和“执行阶段”的分离,比笼统背一句反射很慢更接近真实框架设计。