跳到主要内容
约 10 分钟3 张卡

Java 反射

Java 反射让程序在运行时检查类型、访问成员,并在类型确定后创建对象或调用方法。本文从 Class 对象入手,依次讲解成员查找、访问权限和调用成本,再说明方法句柄的类型要求。

1. 面试核心​

先记住这几条,再读正文
  • 运行时检查与调用。 反射通过 Class 在运行时读取字段、方法、构造器和注解,并创建对象或调用方法。卡 · Class 对象来源
  • 成员查找范围。 getDeclaredMethods() 返回本类声明的方法,getMethods() 返回公开方法并包含继承的公开方法。
  • 目标异常。 被调用方法抛出异常时,Method.invoke 会将它包装在 InvocationTargetException 中。
  • 实现基线。 Java 21 的核心反射基于方法句柄实现。卡 · MethodHandle
  • 方法句柄类型。 invokeExact 要求调用点的参数及返回类型与句柄完全一致。卡 · 方法句柄的返回类型

2. Class 对象来源​

Class<?> 表示一个运行时类型,是读取类型信息的入口。根据类型是否已知,可以选择以下Class 对象来源:

Class<String> literal = String.class;

String value = "Java";
Class<?> fromObject = value.getClass();

Class<?> loaded = Class.forName("com.example.UserService");
  • 类字面量适合编译期已知类型。
  • getClass() 取得对象的实际运行时类。
  • Class.forName 根据类名加载类型,默认还会触发类初始化。

只需要加载但暂不初始化时,可以显式指定初始化参数和类加载器:

Class<?> type = Class.forName(
"com.example.UserService",
false,
applicationClassLoader
);

插件、序列化和依赖注入框架经常只拿到类名或配置。框架编写时还不知道用户会提供哪个类型,因此要等运行时取得 Class,再发现成员并调用。

3. 成员元数据​

拿到 Class 后,就能查询描述这个类型的信息,这些信息称为元数据。它们包括成员声明、父类型、接口和注解。下面分别读取当前类声明的字段和方法:

Class<User> type = User.class;

for (Field field : type.getDeclaredFields()) {
System.out.println(field.getName() + ": " + field.getType());
}

for (Method method : type.getDeclaredMethods()) {
System.out.println(method.getName());
}

getDeclaredMethods() 返回当前类声明的方法,包括非公开方法,但不自动包含继承方法。getMethods() 返回公开方法,并包含满足规则的继承成员。

成员发现和成员访问有不同的检查规则。即使能查到某个方法,调用它时仍要满足 Java 访问控制和模块开放要求。

3.1 泛型声明信息​

反射还能读取方法声明中保留的泛型信息。class 文件的 Signature 属性保存这些声明,getGenericReturnType() 等 API 会读取它们:

Method method = UserRepository.class.getMethod("findAll");

System.out.println(method.getReturnType()); // interface java.util.List
System.out.println(method.getGenericReturnType()); // java.util.List<com.example.User>

示例中,getReturnType() 只得到 List,而 getGenericReturnType() 能读到声明中的 User。同样,getGenericParameterTypes() 可以读取参数的泛型声明。

普通 List<String> 实例的运行时类型并不单独保存 String 这个类型实参,详见 Java 泛型。

4. 对象创建与方法调用​

反射调用需要先找到匹配的构造器或方法。下面按类名创建服务对象,再查找接收 String 参数的 greet 方法:

Class<?> type = Class.forName("com.example.GreetingService");

Constructor<?> constructor = type.getConstructor();
Object service = constructor.newInstance();

Method greet = type.getMethod("greet", String.class);
Object result = greet.invoke(service, "Lin");

System.out.println(result);

constructor.newInstance() 创建对象,greet.invoke(service, "Lin") 再对这个对象调用方法。这里的 service 称为接收者,也就是执行实例方法的对象。

反射调用会在运行时检查接收者、参数数量和参数类型。结果以 Object 返回,基本类型参数和返回值还会涉及装箱或拆箱。

被调用方法抛出的异常会包装在 InvocationTargetException 中:

try {
greet.invoke(service, "Lin");
} catch (InvocationTargetException exception) {
Throwable cause = exception.getCause();
// 根据框架边界处理 cause
}

getCause() 可以取出被调用方法抛出的实际异常。框架应保留这个原因及其堆栈,才能帮助开发者定位业务失败的位置。

4.1 非公开成员与模块边界​

getDeclaredMethod 能发现非公开方法,但调用仍受访问检查限制。trySetAccessible() 会尝试关闭该成员的访问检查,并通过返回值说明是否成功:

Method method = type.getDeclaredMethod("internalOperation");

if (!method.trySetAccessible()) {
throw new IllegalStateException("member is not accessible");
}

跨模块访问私有成员,需要目标包向调用模块开放。启动参数 --add-opens 可以显式开放对应的包。访问 JDK 内部实现同样受到这些限制,因此库和应用应优先使用公开 API。

私有成员属于实现细节,名称或签名在升级时可以改变。所以,依赖私有成员的框架需要验证 JDK 和依赖库升级后的兼容性。

5. MethodHandle​

MethodHandle 是一个可调用的对象,用来表示方法、构造器或字段操作。它称为方法句柄,自身带有描述参数和返回类型的 MethodType。因此,取得句柄后,调用方就有了一个类型明确的调用入口。

MethodHandles.Lookup lookup = MethodHandles.lookup();

MethodHandle greet = lookup.findVirtual(
GreetingService.class,
"greet",
MethodType.methodType(String.class, String.class)
);

GreetingService service = new GreetingService();
String result = (String) greet.invokeExact(service, "Lin");

示例中,findVirtual 查找实例方法,并在创建句柄时检查权限。得到的句柄类型为 (GreetingService, String)String,第一个参数是接收者。包含这段调用的方法需要声明 throws Throwable,或者捕获调用抛出的异常。

取得句柄后可以反复调用,还可以生成适配后的新句柄。比如,bindTo 固定接收者,asType 调整参数或返回类型。参数插入和组合操作还能把多个调用步骤连在一起。

5.1 invokeExact 与 invoke​

句柄的调用方式决定了是否允许类型适配,主要有精确调用和适配调用两种:

  • invokeExact 要求调用点类型与句柄类型完全匹配。
  • invoke 按 asType 规则适配参数和返回类型。

两者都具有签名多态语义:编译器会根据这次调用中表达式的静态类型,记录参数和返回类型。因此,把参数声明成 Object 会改变调用点类型,即使实际对象正确,invokeExact 也会因类型不匹配失败。

5.2 MethodHandle 的适用场景​

需要动态确定目标、又希望保留明确类型时,可以考虑方法句柄。它常用于以下四类调用场景:

  • 动态语言运行时可以用句柄连接 invokedynamic 调用点,也就是运行时确定目标的调用入口。
  • 框架需要组织多个调用步骤时,可以用句柄绑定接收者、适配参数或组合操作。
  • 成员发现完成后,可以缓存带类型的句柄,供高频调用复用。
  • 通过 LambdaMetafactory 等底层 API 创建函数式接口实例时,可以用句柄指定实际调用的方法。

如果类型在编译期已知,普通方法调用最清楚。

6. 反射的调用成本​

分析反射性能时,要把成员查找与实际调用分开看。相对于直接调用,反射还涉及以下几类额外工作:

  • 查找阶段要按名称搜索成员,并匹配参数类型。
  • 调用阶段需要检查访问权限和实参类型。
  • 通用调用接口使用 Object[] 传递参数,还涉及装箱、拆箱和结果转换。
  • 间接调用让编译器更难确定目标,因此增加内联等优化的难度。
  • 目标方法抛出异常时需要包装,框架层还会增加调用调度。

查找和调用应当分开。框架启动时解析 Method 或 MethodHandle 并缓存,能够避免每次请求重复扫描:

private final Method handlerMethod;

RequestHandler(Method handlerMethod) {
this.handlerMethod = handlerMethod;
}

缓存可省去重复查找,调用阶段仍有类型检查与适配成本。

6.1 JDK 版本与调用性能​

Java 17 的核心反射使用本地调用和生成的访问器。Java 21 的核心反射则使用方法句柄实现。即时编译器(JIT)对常量 Method、Constructor 和 MethodHandle 的优化能力,也会随版本和调用形态变化。

JIT 会在运行时优化代码。调用目标越稳定,它越容易识别和优化实际执行的方法。因此,反射和句柄之间没有跨 JDK、跨调用方式的固定性能倍数。

低频扫描只占启动成本的一部分,应先测量它在启动耗时中的比例。高频序列化、路由和表达式执行则要单独测量。可以在目标 JDK 上使用 Java 微基准工具 JMH,模拟实际调用方式验证收益。

7. 使用边界​

反射适合框架发现和通用工具,不适合把普通业务代码改成字符串驱动:

// 类型在编译期已经确定时,没有必要反射
orderService.create(order);

直接调用保留编译检查、清晰的调用关系和 IDE 重构支持。反射按名称查找成员,方法名、参数或权限错误会推迟到运行时。因此,框架应在启动时校验签名、缓存解析结果,并给出明确的错误消息。

框架还需处理以下运行时边界:

  • 类型身份与类加载器有关,因此插件之间需要明确隔离关系和类卸载方式。
  • 跨模块调用要检查目标包的开放关系和成员访问权限。
  • 泛型擦除后,框架需要根据声明信息校验并转换参数。
  • 缓存反射对象会持有对应类型,需要避免意外保留本应卸载的类加载器。
  • Native Image 等构建方式需要提前知道可达代码,因此要为动态反射目标提供配置。

8. 方法句柄的返回类型​

invokeExact 连返回类型也要求匹配,因为调用点类型同时包含参数和返回值。下面用 String.length() 的句柄,比较返回 int 与返回 Object 的差异:

MethodHandle length = MethodHandles.lookup().findVirtual(
String.class, "length", MethodType.methodType(int.class)
);
int size = (int) length.invokeExact("Java"); // 4
// Object wrong = length.invokeExact("Java"); // WrongMethodTypeException
Object boxed = length.invoke("Java"); // Integer,值为 4

上述调用需放在声明 throws Throwable 的方法中。句柄类型为 (String)int,显式转换 (int) 让精确调用的返回类型与它一致。注释中的调用点返回 Object,因此会抛出 WrongMethodTypeException。最后一行改用 invoke,允许把 int 装箱成 Integer 后作为 Object 返回。

方法句柄直接传播目标方法抛出的异常,省去了 InvocationTargetException 包装。访问权限在创建句柄时检查,所以把私有方法句柄交给其他代码,也交出了该方法的调用能力。

9. 常见问题​

9.1 反射能访问 private 成员吗?​

可以发现本类私有方法,能否调用则取决于访问权限。getDeclaredMethod 完成发现后,还要根据调用者和模块开放关系检查 trySetAccessible() 的结果。私有方法属于实现细节,升级后仍需验证兼容性。

9.2 Class.forName 与 ClassLoader.loadClass 有什么区别?​

9.3 反射调用还会检查类型吗?​

9.4 Method 与 MethodHandle 的主要区别是什么?​

10 · 面试题卡片​

10.1 什么是反射,常见使用场景有哪些​

出现公司:美团、菜鸟、京东

考察重点

  • 说明 Class、Field、Method 和 Constructor 分别描述什么信息。
  • 解释框架为什么需要运行时发现,以及哪些错误会推迟到运行时。
  • 说明框架怎样处理访问权限、目标异常和成员缓存。

相关内容:Class 对象来源、成员元数据、对象创建与方法调用、使用边界。

参考回答

反射通过 Class 在运行时检查类型和成员,并创建对象或调用方法。

Field 描述字段,Method 描述方法,Constructor 描述构造器,框架据此操作运行时才确定的类型。

依赖注入可以用反射创建对象并注入依赖。对象关系映射(ORM)框架则用它把数据库记录映射到对象字段。序列化、测试、注解路由、动态代理和插件框架也会使用这种发现能力。

代价是部分类型与权限错误推迟到运行时,框架还要处理模块边界、参数转换和异常包装。启动时校验并缓存结果,可以提前发现错误并减少重复查找。普通业务的类型已知时,直接调用更清楚。

10.2 反射调用为什么可能比直接调用开销更高​

出现公司:数字马力

考察重点

  • 解释成员查找、访问检查、参数适配和间接调用各自增加的工作。
  • 说明缓存 Method 或 MethodHandle 能省去哪些重复工作。
  • 解释 JDK 版本与调用目标的稳定程度怎样影响测量结果。

相关内容:MethodHandle、反射的调用成本。

参考回答

反射比直接调用多出运行时检查和参数适配,反复按名称查找成员还会增加查找成本。

通用接口需要转换 Object 参数、装箱拆箱,并包装目标异常。间接调用也让 JIT 更难确定和优化目标方法。

启动时缓存 Method 或 MethodHandle,可以省去重复查找,但调用阶段仍有适配成本。MethodHandle 带有精确类型,并支持绑定和组合,稳定的调用目标更有利于 JVM 优化。

Java 17 的核心反射使用本地调用和生成的访问器,Java 21 则基于方法句柄实现。因此,实际成本要结合 JDK、调用频率和目标稳定性,用符合实际场景的基准测试判断。

10.3 invokeExact 为什么连返回类型也要完全匹配?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释调用点为什么包含返回类型,以及 invoke 怎样通过适配完成装箱。

相关内容:方法句柄的返回类型。

参考回答

invokeExact 要求完整调用点类型与句柄类型一致,参数和返回类型都参与比较。

例如,String.length() 的句柄 length 类型为 (String)int,调用时写成 (int) length.invokeExact("Java"),返回类型才是所需的 int。

用 Object 接收时,调用点返回类型变成了 Object,与句柄不匹配,因而抛出 WrongMethodTypeException。invoke 允许按 asType 规则适配,可以把返回的 int 装箱后交给 Object 变量。

11. 参考资料​