Java 反射全过程原理
反射不是“背几个 API”就能掌握的知识点。真正要理解它,必须知道:类什么时候被 JVM 加载,Class 对象从哪里来,字段、方法、构造方法怎么被查找,Method.invoke 为什么会包装异常,框架为什么能靠注解自动创建对象,以及生产环境为什么不能滥用反射。
一句话先建立直觉:
反射就是把“编译期写死的调用”变成“运行时根据类信息动态决定怎么创建对象、读写字段、调用方法、读取注解”的能力。
学习目标
学完这一页,你应该能说清楚:
- 反射解决什么问题,为什么 Spring、MyBatis、Jackson、JUnit、RPC 框架都离不开它。
Class对象和类加载、方法区、元空间是什么关系。User.class、user.getClass()、Class.forName()、ClassLoader.loadClass()有什么区别。getField、getDeclaredField、getMethod、getDeclaredMethod的查找边界。setAccessible(true)到底做了什么,Java 9 之后为什么可能失败。Method.invoke的完整调用流程,为什么业务异常会被包成InvocationTargetException。- 注解为什么必须是
RetentionPolicy.RUNTIME才能被反射读到。 - 泛型擦除后,反射还能从哪里读到泛型声明信息。
- 商业项目里反射怎么用于 IOC、对象映射、接口分发、注解扫描。
- 反射有哪些性能、安全和维护风险,线上出问题怎么排查。
反射解决的根本问题
普通业务代码通常是这样写的:
UserService userService = new UserService();
userService.create("Tom");这段代码在编译期已经确定了三件事:
- 要创建的是
UserService。 - 要调用的是
create方法。 - 参数类型是
String。
但是框架不可能提前知道你的业务类叫什么。比如 Spring 启动时,它面对的是一堆 class 文件,它要自己判断:
- 哪些类加了
@Component,需要创建 Bean。 - 哪些字段加了
@Autowired,需要注入依赖。 - 哪些方法加了
@RequestMapping,可以处理 HTTP 请求。 - 哪些方法加了事务注解,调用时要包一层事务逻辑。
如果没有反射,就只能让你手工写大量配置:
container.register("userService", new UserService(new UserRepository()));
container.registerMapping("/users", userController::list);反射让框架可以做到:
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
}你只写业务类和注解,框架运行时读取类结构并完成对象创建、依赖注入、方法调用。
总体流程图
反射完整链路可以拆成两段:先有类加载和 Class 对象,再基于 Class 对象操作成员。
flowchart TD
A["编写 User.java"] --> B["javac 编译为 User.class"]
B --> C["ClassLoader 读取字节码"]
C --> D["JVM 验证、准备、解析、初始化"]
D --> E["生成 Class 对象"]
E --> F["查找构造方法 Constructor"]
E --> G["查找字段 Field"]
E --> H["查找方法 Method"]
E --> I["查找注解 Annotation"]
F --> J["newInstance 创建对象"]
G --> K["get 或 set 读写字段"]
H --> L["invoke 调用方法"]
I --> M["根据注解决定框架行为"]注意:Class 对象不是业务对象。业务对象是 new User() 得到的对象;Class<User> 是描述 User 这个类结构的元信息对象。
Class 对象到底是什么
每个被 JVM 加载的类,都会在 JVM 内部形成一份类元数据,Java 代码侧可以通过一个 Class 对象访问这些元数据。
它保存的信息包括:
| 信息 | 例子 | 作用 |
|---|---|---|
| 类名 | com.example.User | 框架扫描、日志、路由 |
| 父类 | BaseEntity | 判断继承关系 |
| 接口 | Serializable | 判断能力和扩展点 |
| 字段 | id、name | 依赖注入、JSON 映射、ORM 映射 |
| 方法 | getName() | RPC 分发、Controller 路由 |
| 构造方法 | User() | 反射创建对象 |
| 注解 | @Service | 框架根据注解做自动配置 |
JDK 7 和 JDK 8 之后有一个常见区别:
| 版本 | 类元数据主要放在哪里 | 常见问题 |
|---|---|---|
| JDK 7 及以前 HotSpot | 永久代 PermGen | 容易出现 java.lang.OutOfMemoryError: PermGen space |
| JDK 8 及以后 HotSpot | 元空间 Metaspace,使用本地内存 | 容易出现 java.lang.OutOfMemoryError: Metaspace |
所以反射和类加载不是孤立知识点。大量动态生成类、热部署、插件化、代理类不断生成,都可能导致元空间压力。
Class 对象和业务对象的关系
public class User {
private String name;
}User u1 = new User();
User u2 = new User();
Class<?> c1 = u1.getClass();
Class<?> c2 = u2.getClass();
Class<?> c3 = User.class;
System.out.println(c1 == c2); // true
System.out.println(c1 == c3); // true同一个类在同一个类加载器下通常只有一个 Class 对象。u1 和 u2 是两个业务对象,但它们的“类说明书”是同一份。
需要特别注意“同一个类名,不同类加载器”:
com.example.User + ClassLoaderA -> Class 对象 A
com.example.User + ClassLoaderB -> Class 对象 B在插件系统、Tomcat 多 Web 应用、热部署、一些中间件隔离场景里,类名相同但类加载器不同,JVM 会认为它们不是同一个类型。这也是有些项目明明类名一样,却出现 ClassCastException 的原因。
获取 Class 的方式
常见写法有四类:
Class<User> c1 = User.class;
User user = new User();
Class<?> c2 = user.getClass();
Class<?> c3 = Class.forName("com.example.User");
ClassLoader loader = Thread.currentThread().getContextClassLoader();
Class<?> c4 = loader.loadClass("com.example.User");对比如下:
| 方式 | 是否需要对象 | 是否需要字符串类名 | 是否通常触发初始化 | 常见场景 |
|---|---|---|---|---|
User.class | 不需要 | 不需要 | 不一定 | 编译期已知类型 |
user.getClass() | 需要 | 不需要 | 对象已存在,类肯定已加载 | 根据对象反查类型 |
Class.forName(name) | 不需要 | 需要 | 默认会触发初始化 | JDBC、配置化加载 |
classLoader.loadClass(name) | 不需要 | 需要 | 通常只加载,不主动初始化 | 框架扫描、延迟初始化 |
Class.forName 和 loadClass 的区别
这是面试高频点,也是理解框架扫描的关键。
public class DemoClass {
static {
System.out.println("DemoClass init");
}
}Class.forName("com.example.DemoClass");默认情况下,Class.forName 会触发类初始化,所以静态代码块会执行。
ClassLoader loader = Thread.currentThread().getContextClassLoader();
loader.loadClass("com.example.DemoClass");loadClass 一般只把类加载进 JVM,不主动触发初始化,静态代码块通常不会立刻执行。
如果想精确控制 Class.forName 是否初始化,可以使用三参数版本:
Class<?> clazz = Class.forName(
"com.example.DemoClass",
false,
Thread.currentThread().getContextClassLoader()
);其中第二个参数 false 表示加载类但不初始化。
为什么框架扫描喜欢避免初始化?因为扫描阶段只是想知道“这个类有没有注解”,如果扫描一个类就执行它的静态代码块,可能提前连接数据库、启动线程、加载大文件,导致启动过程不可控。
示例类
后面的代码都基于下面几个类:
package com.example.reflect;
public class BaseUser {
public String tenantId;
public void baseMethod() {
System.out.println("base");
}
}package com.example.reflect;
public class User extends BaseUser {
private Long id;
private String name;
public User() {
}
public User(Long id, String name) {
this.id = id;
this.name = name;
}
public String hello(String prefix) {
return prefix + "," + name;
}
private String secret() {
return "secret:" + id;
}
}字段查找规则
字段 API 最容易混淆的是 getFields 和 getDeclaredFields。
| API | 查找范围 | 是否包含 private | 是否包含父类 |
|---|---|---|---|
getFields() | public 字段 | 不包含 private | 包含父类 public |
getDeclaredFields() | 当前类声明字段 | 包含 private | 不包含父类 |
getField(name) | 指定 public 字段 | 不包含 private | 包含父类 public |
getDeclaredField(name) | 当前类指定字段 | 包含 private | 不包含父类 |
示例:
Class<User> clazz = User.class;
for (Field field : clazz.getFields()) {
System.out.println("public field: " + field.getName());
}
for (Field field : clazz.getDeclaredFields()) {
System.out.println("declared field: " + field.getName());
}输出会体现差异:
public field: tenantId
declared field: id
declared field: name框架为什么经常用 getDeclaredFields()?因为业务字段通常是 private,例如:
private String userName;Spring 注入字段、Jackson 绑定 JSON、MyBatis 映射结果时,都需要读取非 public 字段。
如果既想拿当前类字段,又想拿父类字段,需要自己沿着继承链遍历:
public static List<Field> getAllFields(Class<?> type) {
List<Field> result = new ArrayList<>();
Class<?> current = type;
while (current != null && current != Object.class) {
result.addAll(Arrays.asList(current.getDeclaredFields()));
current = current.getSuperclass();
}
return result;
}读写字段全过程
User user = new User(1L, "Tom");
Field field = User.class.getDeclaredField("name");
field.setAccessible(true);
Object oldValue = field.get(user);
field.set(user, "Jerry");
System.out.println(oldValue);
System.out.println(user.hello("hi"));执行过程:
flowchart TD
A["拿到 User.class"] --> B["按字段名查找 name"]
B --> C["得到 Field 对象"]
C --> D["setAccessible(true) 关闭语言访问检查"]
D --> E["field.get(user) 读取字段值"]
E --> F["field.set(user, 新值) 修改字段值"]这里要注意两个点:
Field是字段元信息,不是字段值。field.get(user)才是从某个具体对象上读取这个字段的值。
如果字段是 static,它属于类,不属于某个对象:
Object value = staticField.get(null);
staticField.set(null, "newValue");setAccessible(true) 到底做什么
private、protected、包访问权限是 Java 语言层面的访问控制。反射默认也会检查这些权限。
Field field = User.class.getDeclaredField("name");
field.get(user); // 没有 setAccessible 时可能抛 IllegalAccessException调用:
field.setAccessible(true);表示告诉反射 API:后续访问时不要再按 Java 语言访问权限做检查。
但这不等于“无条件能访问一切”。Java 9 引入模块系统后,强封装更严格。如果目标模块没有开放包,反射访问 JDK 内部类或其他模块内部成员时,可能出现:
java.lang.reflect.InaccessibleObjectException常见解决思路:
- 优先不要反射访问 JDK 内部私有实现。
- 使用公开 API。
- 启动参数临时开放模块,例如
--add-opens。 - 框架升级到兼容当前 JDK 的版本。
所以生产排查时要区分:
| 异常 | 常见含义 |
|---|---|
IllegalAccessException | 普通访问权限不够,可能没调用 setAccessible(true) |
InaccessibleObjectException | 模块强封装限制,Java 9+ 常见 |
构造方法查找和创建对象
Constructor<User> c1 = User.class.getDeclaredConstructor();
User user1 = c1.newInstance();
Constructor<User> c2 = User.class.getDeclaredConstructor(Long.class, String.class);
User user2 = c2.newInstance(1L, "Tom");构造方法 API:
| API | 说明 |
|---|---|
getConstructor(...) | 获取 public 构造方法,包含父类不适用,因为构造方法不能继承 |
getDeclaredConstructor(...) | 获取当前类声明的构造方法,包含 private |
newInstance(...) | 调用构造方法创建对象 |
为什么很多框架要求无参构造?
因为框架最通用的创建流程是:
flowchart TD
A["读取 Class 对象"] --> B["查找无参构造"]
B --> C["反射创建空对象"]
C --> D["读取配置、JSON 或数据库结果"]
D --> E["逐个字段或 setter 赋值"]如果没有无参构造,框架就必须知道每个构造参数应该从哪里来、参数名是什么、类型怎么转换、缺失值怎么办。这个难度明显更高。
但 Spring 不只支持无参构造。现代 Spring 更推荐构造器注入,它会根据 Bean 类型解析构造参数并注入依赖。
方法查找规则
方法 API 和字段类似:
| API | 查找范围 | 是否包含 private | 是否包含父类 |
|---|---|---|---|
getMethods() | public 方法 | 不包含 private | 包含父类 public,包括 Object 方法 |
getDeclaredMethods() | 当前类声明方法 | 包含 private | 不包含父类 |
getMethod(name, types...) | 指定 public 方法 | 不包含 private | 包含父类 public |
getDeclaredMethod(name, types...) | 当前类指定方法 | 包含 private | 不包含父类 |
查找带参数方法时,参数类型必须匹配:
Method method = User.class.getDeclaredMethod("hello", String.class);如果方法参数是基本类型:
public int add(int a, int b) {
return a + b;
}查找时要写 int.class,不是 Integer.class:
Method add = Calculator.class.getDeclaredMethod("add", int.class, int.class);这是 NoSuchMethodException 的高频原因。
Method.invoke 完整流程
User user = new User(1L, "Tom");
Method method = User.class.getDeclaredMethod("hello", String.class);
Object result = method.invoke(user, "hi");
System.out.println(result);执行过程可以拆成:
flowchart TD
A["Method 保存方法名、参数类型、返回类型等元信息"] --> B["invoke 接收目标对象和实参数组"]
B --> C["检查目标对象是否属于声明类或其子类"]
C --> D["检查访问权限"]
D --> E["检查参数个数和类型"]
E --> F["执行真实业务方法"]
F --> G["返回结果,基本类型会装箱为 Object"]几个细节很重要:
- 实例方法必须传目标对象。
- 静态方法可以传
null。 - 返回基本类型时会被装箱。
- 业务方法抛出的异常会被包装成
InvocationTargetException。
示例:
public class OrderService {
public void pay() {
throw new IllegalStateException("balance not enough");
}
}try {
Method pay = OrderService.class.getDeclaredMethod("pay");
pay.invoke(new OrderService());
} catch (InvocationTargetException e) {
Throwable realException = e.getTargetException();
System.out.println(realException.getClass().getName());
System.out.println(realException.getMessage());
}为什么要包装?因为 invoke 自己也可能抛反射层面的异常,例如访问权限不够、参数不匹配。业务方法内部抛的异常需要和反射 API 自己的异常区分开,所以用 InvocationTargetException 包一层。
注解读取全过程
先定义注解:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TableName {
String value();
}使用注解:
@TableName("sys_user")
public class UserEntity {
private Long id;
}读取注解:
TableName tableName = UserEntity.class.getAnnotation(TableName.class);
System.out.println(tableName.value());关键是:
@Retention(RetentionPolicy.RUNTIME)注解保留策略决定注解能活到什么时候:
| 策略 | 含义 | 能否运行时反射读取 |
|---|---|---|
SOURCE | 只存在源码中,编译后没有 | 不能 |
CLASS | 进入 class 文件,但 JVM 运行时不一定保留给反射 | 通常不能 |
RUNTIME | 运行时可见 | 可以 |
Spring、MyBatis、JUnit 这类运行时框架依赖注解驱动,所以它们的核心注解一般都是 RUNTIME。
注解目标也很重要:
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})它决定注解能标在哪里。比如 @Autowired 可以标字段、构造方法、方法;@Controller 标在类上。
泛型擦除和反射
Java 泛型主要是编译期类型检查。运行时很多泛型信息会被擦除:
List<String> names = new ArrayList<>();
List<Integer> ages = new ArrayList<>();
System.out.println(names.getClass() == ages.getClass()); // true运行时都是 ArrayList,不是两个不同类型的类。
但是,反射仍然可以读取“声明位置”上的泛型信息。例如字段声明:
public class UserRepository {
private List<String> names;
}Field field = UserRepository.class.getDeclaredField("names");
Type type = field.getGenericType();
if (type instanceof ParameterizedType parameterizedType) {
Type[] arguments = parameterizedType.getActualTypeArguments();
System.out.println(arguments[0].getTypeName());
}这就是很多 JSON 框架、ORM 框架、RPC 框架需要 TypeReference 的原因。因为运行时对象本身的泛型类型容易丢失,只能从字段、方法返回值、方法参数、父类泛型签名等声明位置找回来。
JDK 7/8 写法不能使用模式匹配,需要写成:
if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
Type[] arguments = parameterizedType.getActualTypeArguments();
System.out.println(arguments[0].getTypeName());
}Demo 1:简化版 IOC 注入
这个 Demo 模拟 Spring:扫描字段上的注解,然后把容器里的对象注入进去。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Inject {
}
class UserRepository {
public String findNameById(Long id) {
return "Tom-" + id;
}
}
class UserService {
@Inject
private UserRepository userRepository;
public void printUser(Long id) {
System.out.println(userRepository.findNameById(id));
}
}
public class MiniIocDemo {
public static void main(String[] args) throws Exception {
Map<Class<?>, Object> container = new HashMap<>();
container.put(UserRepository.class, new UserRepository());
UserService userService = new UserService();
inject(userService, container);
userService.printUser(1L);
}
static void inject(Object bean, Map<Class<?>, Object> container) throws Exception {
Class<?> clazz = bean.getClass();
for (Field field : clazz.getDeclaredFields()) {
if (!field.isAnnotationPresent(Inject.class)) {
continue;
}
Object dependency = container.get(field.getType());
if (dependency == null) {
throw new IllegalStateException("missing dependency: " + field.getType().getName());
}
field.setAccessible(true);
field.set(bean, dependency);
}
}
}这个 Demo 背后的真实框架原理:
flowchart TD
A["扫描 class 文件"] --> B["找到 Bean 类"]
B --> C["创建 Bean 对象"]
C --> D["遍历字段和方法"]
D --> E["识别注入注解"]
E --> F["从容器查找依赖"]
F --> G["反射写入字段或调用 setter"]
G --> H["Bean 可以被业务使用"]真实 Spring 比这个复杂得多,还包括 BeanDefinition、生命周期、循环依赖、代理对象、条件装配等,但“读取注解 + 反射注入”是理解入口。
Demo 2:简化版对象映射
商业系统经常把数据库结果、JSON、Map 映射成 Java 对象。下面模拟一个最小对象映射器:
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
class Order {
private Long id;
private String orderNo;
private Integer status;
public String toString() {
return "Order{id=" + id + ", orderNo='" + orderNo + "', status=" + status + "}";
}
}
public class SimpleMapperDemo {
public static void main(String[] args) throws Exception {
Map<String, Object> row = new HashMap<>();
row.put("id", 1001L);
row.put("orderNo", "PO202607060001");
row.put("status", 1);
Order order = mapToBean(row, Order.class);
System.out.println(order);
}
static <T> T mapToBean(Map<String, Object> row, Class<T> type) throws Exception {
T bean = type.getDeclaredConstructor().newInstance();
for (Field field : type.getDeclaredFields()) {
if (!row.containsKey(field.getName())) {
continue;
}
field.setAccessible(true);
field.set(bean, row.get(field.getName()));
}
return bean;
}
}真实 MyBatis、Jackson 会额外处理:
- 下划线转驼峰,例如
order_no->orderNo。 - 类型转换,例如字符串转时间、数字转枚举。
- 嵌套对象和集合。
- 构造器、setter、字段赋值多种策略。
- 缓存反射元数据,避免每次都查字段。
如果不理解反射,就很难理解为什么字段名不一致会映射失败,为什么没有无参构造会报错,为什么 JSON 反序列化有安全风险。
Demo 3:简化版 RPC 方法分发
RPC、Controller 路由、命令处理器,本质上都有一个共同问题:收到一个字符串形式的请求后,如何找到并调用对应方法。
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
class PaymentService {
public String pay(String orderNo) {
return "paid:" + orderNo;
}
}
public class RpcDispatchDemo {
private static final Map<String, Object> services = new HashMap<>();
public static void main(String[] args) throws Exception {
services.put("paymentService", new PaymentService());
Object result = invoke("paymentService", "pay", new Class<?>[]{String.class}, new Object[]{"PO1001"});
System.out.println(result);
}
static Object invoke(String serviceName, String methodName, Class<?>[] parameterTypes, Object[] args) throws Exception {
Object service = services.get(serviceName);
if (service == null) {
throw new IllegalArgumentException("service not found: " + serviceName);
}
Method method = service.getClass().getMethod(methodName, parameterTypes);
return method.invoke(service, args);
}
}生产环境不能直接让外部用户任意传类名、方法名然后反射调用。正确做法是:
- 只允许调用白名单服务。
- 方法必须通过注解或接口注册。
- 参数要校验和反序列化限制。
- 权限、审计、限流必须在调用前完成。
- 异常不能把内部类名、路径、SQL 等敏感信息直接返回给前端。
反射为什么慢
反射慢不是一句“因为动态”就结束,要拆开看:
- 普通调用在编译期和 JIT 优化阶段更容易内联、去虚拟化、做逃逸分析。
- 反射调用需要经过
Method、Field这类元信息对象,多了一层间接调用。 Method.invoke需要做访问权限、参数个数、参数类型、装箱拆箱等检查。- 反射通常破坏静态类型信息,编译器更难提前发现错误。
但也不要过度恐惧反射。框架通常会缓存反射元数据:
private static final Map<Class<?>, Field[]> FIELD_CACHE = new ConcurrentHashMap<>();
static Field[] getCachedFields(Class<?> type) {
return FIELD_CACHE.computeIfAbsent(type, Class::getDeclaredFields);
}对于业务系统,真正的性能瓶颈往往是数据库、网络、锁竞争、序列化、大对象分配。反射在框架启动阶段使用很多,运行期通常通过缓存、字节码生成、MethodHandle 等方式降低成本。
MethodHandle 和 VarHandle
JDK 7 引入 MethodHandle,它比传统反射更接近 JVM 方法调用模型,适合动态语言、框架、底层库做高性能动态调用。
JDK 9 引入 VarHandle,可以用来访问变量,并支持 volatile、CAS 等访问模式。它在一些场景下替代了 Unsafe 的部分能力。
对普通业务开发来说:
| 技术 | 常见使用者 | 适用场景 |
|---|---|---|
| 反射 | 业务框架、测试工具、ORM、JSON | 通用动态元信息读取和调用 |
| MethodHandle | 框架、动态语言、底层库 | 高性能动态方法调用 |
| VarHandle | JDK、并发库、底层框架 | 高性能变量访问、内存语义控制 |
面试时不需要把 MethodHandle 源码背下来,但要知道:传统反射灵活但开销更明显,框架为了性能可能会缓存反射结果、生成字节码、使用 MethodHandle。
商业项目怎么用
Spring IOC 和注解扫描
场景:后台管理系统有订单服务、库存服务、支付服务。
框架启动时扫描 classpath:
- 发现
@Service类,注册成 BeanDefinition。 - 创建 Bean 对象。
- 读取字段、构造方法、setter 上的注入信息。
- 从容器找依赖。
- 用反射或方法调用完成注入。
如果没有反射,所有依赖都要手写装配,项目一大就很难维护。
MyBatis 结果映射
场景:查询订单表:
select id, order_no, status from t_order where id = ?MyBatis 拿到结果集后,需要把列值写入 Java 对象:
public class Order {
private Long id;
private String orderNo;
private Integer status;
}它会根据 resultMap、字段名、setter、类型转换规则,把数据库列映射到对象属性。字段名、setter 名、类型不匹配,就会出现映射失败或值为 null。
Jackson JSON 反序列化
场景:前端传入:
{"userId": 1, "userName": "Tom"}后端接收:
public class UserCreateRequest {
private Long userId;
private String userName;
}Jackson 需要创建对象并给字段赋值。没有无参构造、字段名不一致、类型转换失败,都会导致反序列化异常。
RPC 和接口聚合
场景:网关收到请求后,根据接口名调用不同服务方法。框架会维护一个方法注册表,把请求名映射到具体 Method 或方法句柄。
这样新增接口时,只要加注解或实现接口,框架就能自动注册。
不这样会怎样
如果完全不用反射,框架能力会明显退化:
| 不使用反射 | 后果 |
|---|---|
| 不能运行时读取注解 | @Service、@RequestMapping、@Transactional 这类声明式能力难以实现 |
| 不能动态创建对象 | 配置化、插件化、序列化、ORM 都会变得笨重 |
| 不能动态读写字段 | JSON 映射、数据库结果映射需要大量手写代码 |
| 不能动态调用方法 | RPC、Controller 路由、测试框架难以通用化 |
但如果滥用反射,也会带来问题:
| 滥用反射 | 后果 |
|---|---|
| 业务代码到处反射调用 | 可读性差,编译期无法检查,重构容易漏 |
| 反射访问 private 字段 | 破坏封装,类内部约束可能被绕过 |
| 用户输入决定类名方法名 | 安全风险,可能调用不该调用的内部能力 |
| 高频循环里重复查 Method/Field | 性能下降 |
| Java 9+ 反射 JDK 内部类 | 模块访问失败,升级 JDK 后容易炸 |
常见坑
参数类型写包装类导致找不到方法
public int add(int a, int b) {
return a + b;
}错误:
clazz.getDeclaredMethod("add", Integer.class, Integer.class);正确:
clazz.getDeclaredMethod("add", int.class, int.class);只查当前类,漏掉父类字段
getDeclaredField 不查父类。如果字段在父类,需要沿着 getSuperclass() 遍历。
忘记处理 InvocationTargetException
反射调用业务方法时,真正异常在:
e.getTargetException()日志里只看外层异常,容易误判成反射 API 问题。
在线程热点路径重复查反射元信息
不要在每条数据处理时都:
clazz.getDeclaredFields()应把字段、方法、注解解析结果缓存起来。
让外部请求直接控制反射目标
危险示例:
Class<?> clazz = Class.forName(request.getClassName());
Method method = clazz.getDeclaredMethod(request.getMethodName());
method.invoke(clazz.getDeclaredConstructor().newInstance());这会把系统内部能力暴露给外部输入。生产必须白名单、鉴权、参数校验。
线上排查流程
flowchart TD
A["反射相关异常"] --> B["看异常类型"]
B --> C["ClassNotFoundException"]
B --> D["NoSuchMethodException"]
B --> E["IllegalAccessException"]
B --> F["InvocationTargetException"]
B --> G["InaccessibleObjectException"]
C --> H["检查类名、包名、依赖、classpath、类加载器"]
D --> I["检查方法名、参数类型、基本类型和包装类型"]
E --> J["检查访问权限和 setAccessible"]
F --> K["打印 getTargetException 真实业务异常"]
G --> L["检查 Java 9+ 模块开放和框架版本"]排查建议:
- 先看完整堆栈,不要只看第一行异常。
ClassNotFoundException重点查依赖是否打包、类名是否写错、插件类加载器是否隔离。NoSuchMethodException重点查参数类型,尤其是int.class和Integer.class。InvocationTargetException重点看getTargetException(),真实问题常常是业务代码空指针、SQL 异常、远程调用失败。- JDK 升级后出现反射访问失败,重点查模块系统和框架兼容版本。
- 性能问题先采样火焰图或 profiler,不要凭感觉认定是反射慢。
面试标准回答
反射是什么
反射是 Java 在运行时获取类结构并动态操作对象的能力。它可以读取类名、字段、方法、构造方法、注解,也可以动态创建对象、调用方法、读写字段。Spring、MyBatis、Jackson、JUnit、RPC 框架都大量使用反射,因为框架在编译期不知道用户会写哪些类,只能运行时扫描和处理。
Class 对象是什么
Class 对象是 JVM 加载某个类后暴露给 Java 代码的类元信息入口。它不是业务对象,而是描述这个类的结构信息。同一个类在同一个类加载器下通常只有一个 Class 对象;同名类如果由不同类加载器加载,JVM 会认为是不同类型。
Class.forName 和 loadClass 区别
Class.forName 默认会加载并初始化类,可能触发静态代码块;ClassLoader.loadClass 通常只加载类,不主动初始化。框架扫描阶段通常希望只读取类信息,不希望提前执行静态初始化,所以会更谨慎地控制是否初始化。
getFields 和 getDeclaredFields 区别
getFields 只获取 public 字段,并且包含父类 public 字段;getDeclaredFields 获取当前类声明的所有字段,包括 private,但不包含父类字段。框架要处理 private 字段时常用 getDeclaredFields,如果还要父类字段,需要自己沿继承链遍历。
setAccessible(true) 是什么
它表示关闭 Java 语言层面的访问检查,让反射可以访问 private、protected 等成员。但 Java 9 模块系统之后,强封装会让一些跨模块私有访问失败,可能抛 InaccessibleObjectException,这时要考虑公开 API、升级框架或通过 --add-opens 临时开放模块。
Method.invoke 为什么抛 InvocationTargetException
Method.invoke 自己可能因为访问权限、参数不匹配等反射问题失败;被调用的业务方法内部也可能抛异常。为了区分这两类异常,业务方法抛出的异常会被包在 InvocationTargetException 里,真实异常要通过 getTargetException() 获取。
反射为什么慢,怎么优化
反射调用多了元信息查找、访问检查、参数检查、装箱拆箱和间接调用,JIT 也更难像普通调用那样优化。优化方式包括缓存 Class、Field、Method、Constructor,避免热点循环重复解析;框架还可能使用字节码生成、MethodHandle、代码生成等方式降低运行期开销。
Spring 为什么能根据注解创建对象
Spring 启动时扫描 classpath,读取类上的 @Component、@Service 等注解,把符合条件的类注册成 BeanDefinition,然后通过构造方法或工厂方法创建对象,再读取字段、构造器、方法上的注入注解,完成依赖注入。反射提供了读取类结构和注解、创建对象、写字段或调用方法的基础能力。
