Java 基础体系 · 第 10/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 反射与注解:Class、MethodHandle、元注解、处理器和边界
反射与注解经常同时出现在框架代码中,但它们解决的是不同问题:
Class描述运行时可见的类、接口、数组和基本类型;- 反射 API 根据
Class查找字段、方法、构造器,并在运行时访问它们; MethodHandle提供另一套更接近 JVM 调用模型的动态调用机制;- 注解是附着在声明或类型使用位置上的结构化元数据;
- 元注解决定注解本身可以放在哪里、保存到什么时候、是否可继承;
- 注解处理器在编译期读取注解并生成诊断信息或新源代码;
- “边界”则包括类型擦除、访问控制、模块封装、类加载器、编译期与运行期的边界。
这些概念可以组合成 IoC、ORM、序列化、路由、校验和 AOP 等框架能力,但组合之前必须先区分它们各自处于哪个阶段、拥有哪种权限,以及失败时由谁报告错误。
一、先区分四个时间点
Java 程序中的类型信息至少经历四个阶段:
- 源代码阶段:编译器能看到泛型参数、注解源码位置和类型结构。
- 编译阶段:注解处理器通过
javax.lang.model读取源码模型。 - 类文件阶段:编译器把部分信息写入 class 文件,例如运行时注解、泛型签名、方法参数名等。
- 运行阶段:类加载器定义类,
Class、反射和MethodHandle查询实际运行时结构。
一个信息是否存在,取决于它是否被保存,以及当前 API 是否能读取它。例如:
List<String> names = new ArrayList<>();
运行时通常只能知道变量使用了 List,不能从对象实例可靠地得知它是 List<String>。这是类型擦除造成的限制。
而下面的字段声明:
class Config {
List<String> names;
}
编译器可以把 List<String> 写入字段的泛型签名,因此反射可以通过 Field.getGenericType() 读取一个 ParameterizedType。这不是运行时对象保留了 String,而是 class 文件额外保存了声明签名。
因此,下面三件事不能混为一谈:
| 信息 | 典型读取方式 | 是否一定存在 |
|---|---|---|
| 运行时类及其成员 | Class、反射、MethodHandle |
类定义中存在 |
| 泛型声明签名 | Type、getGenericType() |
编译器写入且未被工具移除时 |
| 注解 | getAnnotation() 或注解处理器 |
取决于 RetentionPolicy 和处理阶段 |
二、Class<T>:运行时类型的入口
2.1 Class<T> 表示什么
Class<T> 是一个运行时对象,表示某个类型。它可以表示:
- 普通类;
- 接口;
- 枚举;
- 注解接口;
- 数组类型;
- 基本类型;
void。
例如:
Class<String> stringClass = String.class;
Class<int[]> arrayClass = int[].class;
Class<Integer> primitiveWrapper = int.class; // 实际类型是 Class<Integer>
Class<Void> voidClass = void.class;
int.class 的类型在 Java 源代码层面可视为 Class<Integer>,但它表示的是基本类型 int,不是包装类型 Integer:
System.out.println(int.class == Integer.class); // false
System.out.println(int.class.getName()); // int
System.out.println(Integer.class.getName()); // java.lang.Integer
基本类型没有对象实例,也没有可通过普通反射访问的成员方法。对 int.class.getDeclaredMethods() 查询结果为空,不表示 int 继承了某些方法,而是因为 int 不是类实例。
2.2 获取 Class 的几种方式
类字面量
Class<String> c1 = String.class;
这是编译期确定的类型引用。加载类和初始化类是不同概念。根据 Java 语言规范,使用类字面量获取 Class 通常不会因此初始化被引用的类。
对象实例
Object value = "hello";
Class<?> c2 = value.getClass();
getClass() 返回对象的实际运行时类,而不是变量声明类型:
CharSequence value = new StringBuilder("hello");
System.out.println(value.getClass()); // class java.lang.StringBuilder
这里 CharSequence 是静态类型,StringBuilder 是运行时类型。
按名称加载
Class<?> c3 = Class.forName("java.lang.String");
Class.forName(String) 会使用调用者关联的类加载器查找类,并初始化该类。需要避免初始化时,可以使用重载版本:
Class<?> c4 = Class.forName(
"com.example.Plugin",
false,
Thread.currentThread().getContextClassLoader()
);
ClassLoader.loadClass(String) 通常只负责加载和链接,不主动初始化目标类:
Class<?> c5 = Thread.currentThread()
.getContextClassLoader()
.loadClass("com.example.Plugin");
“加载”“链接”“初始化”是 JVM 生命周期中的不同阶段。框架如果只是扫描类型,不希望执行静态初始化代码,就不能无条件使用会触发初始化的路径。
2.3 Class 的类型参数不是运行时类型检查
下面的代码使用了无界通配符:
Class<?> type = String.class;
Class<?> 表示“某个未知类型的 Class”。它可以安全地保存任意类型的类对象,但不能直接把任意对象传给 type.cast 之外的泛型操作。
Object value = type.cast("abc"); // 运行时检查 value 是否是 String
Class.cast 的作用类似于:
String value = (String) object;
但它使用 Class 对象中的运行时类型进行检查:
static <T> T cast(Class<T> type, Object value) {
return type.cast(value);
}
调用:
String s = cast(String.class, "hello"); // 编译器推导 T 为 String
如果对象不匹配,会抛出 ClassCastException。
Class<T> 的泛型参数主要帮助编译器检查 API 使用方式,并不能让 JVM 在运行时保留被擦除的泛型参数:
Class<List> raw = List.class;
// 不存在 List<String>.class
Java 没有 List<String>.class 这种类字面量,因为 List<String> 不是一个独立的运行时类。
三、从 Class 查找成员:声明边界与继承边界
反射 API 中最容易误用的一组区别是 getMethod 与 getDeclaredMethod。
class Parent {
public void inherited() {}
}
class Child extends Parent {
private void own() {}
public void ownPublic() {}
}
查找行为如下:
Child.class.getMethod("inherited"); // 能找到继承的 public 方法
Child.class.getDeclaredMethod("own"); // 能找到 Child 自身声明的 private 方法
Child.class.getMethod("own"); // NoSuchMethodException
基本规则:
getMethod只查找public方法,并沿继承体系查找;getDeclaredMethod只查找当前Class自身声明的方法,不自动查找父类;getFields与getDeclaredFields、getConstructors与getDeclaredConstructors具有类似区别;getDeclaredMethods返回当前类声明的方法,包括不同访问级别,但不保证数组顺序;- 编译器或编译后工具可能生成桥接方法、合成方法,不能假设返回结果只有源代码中写出的方法。
例如泛型重写可能生成桥接方法:
class Parent<T> {
T get() {
return null;
}
}
class Child extends Parent<String> {
@Override
String get() {
return "child";
}
}
由于类型擦除,JVM 方法描述符需要兼容父类的 Object get(),编译器可能生成一个桥接方法,把 Object get() 转发到 String get()。反射扫描时可能看到 Method.isBridge() 或 Method.isSynthetic() 为 true 的方法。
因此框架按方法名扫描时,不能简单地认为:
Arrays.stream(type.getDeclaredMethods())
.filter(m -> m.getName().equals("get"))
所得结果就等于源代码中的一个 get。应根据框架语义决定是否过滤桥接方法和合成方法,同时处理重载。
3.1 反射调用的完整路径
下面是一个可以运行的例子:
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class ReflectionDemo {
static class Calculator {
public int add(int left, int right) {
return left + right;
}
}
public static void main(String[] args)
throws NoSuchMethodException, InvocationTargetException,
InstantiationException, IllegalAccessException {
Class<Calculator> type = Calculator.class;
Calculator calculator = type.getDeclaredConstructor().newInstance();
Method method = type.getMethod("add", int.class, int.class);
Object result = method.invoke(calculator, 2, 3);
System.out.println(result); // 5
}
}
执行过程是:
Calculator.class得到运行时类型描述;getDeclaredConstructor()查找无参构造器;newInstance()创建对象;getMethod()查找public int add(int, int);invoke()检查目标对象、参数数量和参数可转换性;- 返回值统一以
Object形式暴露,因此基本类型int会发生装箱; - 目标方法抛出的异常通常通过
InvocationTargetException包装。
如果目标方法本身抛出异常,诊断代码通常需要解包:
try {
method.invoke(calculator, 2, 3);
} catch (InvocationTargetException e) {
Throwable targetFailure = e.getCause();
targetFailure.printStackTrace();
}
InvocationTargetException 表示“反射调用过程已经找到并调用了目标方法,但目标方法内部失败”。它不同于:
NoSuchMethodException:查找阶段没有找到匹配方法;IllegalAccessException:访问权限不允许;IllegalArgumentException:目标对象或参数不匹配;ExceptionInInitializerError:类初始化失败等初始化问题。
四、访问控制:setAccessible 不是万能的越权开关
对于非公开成员,传统反射可能出现:
Method method = type.getDeclaredMethod("privateMethod");
method.setAccessible(true);
setAccessible(true) 的含义是请求抑制 Java 语言访问检查,但它受到模块系统和运行时封装约束。它不等于“无条件访问任意类的私有成员”。
Java 模块系统把访问分成两类重要边界:
- **导出(exports)**主要控制其他模块对公开类型的编译期和运行期访问;
- **开放(opens)**允许深度反射访问包中的非公开成员。
一个包可以导出给普通代码,但没有开放给深度反射;也可以不导出,却通过 opens 允许反射框架进行深度访问。
当模块不允许深度反射时,常见失败是 InaccessibleObjectException。解决方案不是在业务代码中无限重试,而是明确模块边界,例如:
--add-opens my.module/com.example.internal=framework.module
这类启动参数会削弱封装,适合迁移、诊断或明确授权的场景,不应被当成普遍的库设计方式。
MethodHandles.privateLookupIn 也要求调用者拥有相应模块权限;它不是绕过模块封装的替代方案。
五、MethodHandle:带有精确方法类型的动态调用
5.1 它与反射方法调用有什么不同
java.lang.reflect.Method 是对方法元数据和反射调用的抽象。java.lang.invoke.MethodHandle 是一个可组合、带有明确 MethodType 的调用点。
一个 MethodHandle 有固定的方法类型:
返回类型 (参数类型1, 参数类型2, ...)
例如:
(int, int)int
表示接收两个 int,返回一个 int。
方法句柄通过 MethodHandles.Lookup 获取。Lookup 代表调用者的访问权限,而不是一个全局超级权限对象。
5.2 查找并调用实例方法
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class MethodHandleDemo {
static class Calculator {
public int add(int left, int right) {
return left + right;
}
}
public static void main(String[] args) throws Throwable {
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle add = lookup.findVirtual(
Calculator.class,
"add",
MethodType.methodType(int.class, int.class, int.class)
);
Calculator calculator = new Calculator();
int result = (int) add.invokeExact(calculator, 2, 3);
System.out.println(result); // 5
System.out.println(add.type());
// (Calculator,int,int)int
}
}
findVirtual 的 MethodType 不包含接收者对象,但得到的句柄类型会把接收者作为第一个参数:
(Calculator, int, int)int
调用时必须把 calculator 作为第一个实际参数。
5.3 invokeExact 的关键规则
invokeExact 是签名多态方法。调用点的静态方法类型必须与句柄类型完全一致,包括:
- 参数数量;
- 参数类型;
- 返回类型;
- 基本类型与引用类型的区别。
下面的代码可能失败:
Object result = add.invokeExact(calculator, 2, 3);
原因不是结果不能装箱,而是调用点被编译成:
(Calculator,int,int)Object
而句柄类型是:
(Calculator,int,int)int
两者不完全相同。
正确写法是:
int result = (int) add.invokeExact(calculator, 2, 3);
如果希望使用更宽松的转换,可以使用:
Object result = add.invoke(calculator, 2, 3);
invoke 允许符合方法句柄转换规则的适配,例如装箱、拆箱和引用类型转换,但它仍然不是任意动态转换。需要明确控制签名时,可以先使用:
MethodHandle generic = add.asType(
MethodType.methodType(Object.class, Object.class, Object.class, Object.class)
);
不过这要求适配过程在运行时合法。调用句柄前,可以用:
System.out.println(add.type());
验证实际签名,而不是凭源代码直觉猜测。
5.4 静态方法、特殊调用与反射适配
静态方法使用:
MethodHandle handle = lookup.findStatic(
Utility.class,
"parse",
MethodType.methodType(int.class, String.class)
);
父类方法或特定实现调用可能涉及:
lookup.findSpecial(
Parent.class,
"method",
MethodType.methodType(void.class),
Child.class
);
findSpecial 的调用者和访问上下文要求更严格,不能用它随意调用任意私有或父类实现。
已有反射对象时,可以适配为方法句柄:
Method method = Calculator.class.getMethod(
"add", int.class, int.class
);
MethodHandle handle = MethodHandles.lookup().unreflect(method);
unreflect 会根据 Method 的访问权限和当前 Lookup 检查是否允许访问。它不会自动消除模块系统的限制。
5.5 绑定接收者和组合
方法句柄可以绑定参数:
MethodHandle bound = add.bindTo(new Calculator());
int result = (int) bound.invokeExact(2, 3);
绑定后类型从:
(Calculator,int,int)int
变为:
(int,int)int
还可以通过 insertArguments、dropArguments、filterArguments、filterReturnValue、guardWithTest 等方法进行组合。这类组合是方法句柄区别于普通反射调用的重要能力:方法句柄可以先构造调用路径,再反复执行该路径。
需要区分规范保证和性能经验:
MethodHandle的类型检查、适配规则和访问规则由 API 规范定义;- 某些 JVM 可能对稳定的方法句柄调用链进行优化;
- 不能把“可能被 JIT 优化”写成固定性能承诺;
- 动态生成大量不同签名或不同组合的句柄,仍可能增加编译、缓存和内存压力。
六、注解:附着在声明或类型使用位置的元数据
注解类型本质上是一种特殊接口:
public @interface Endpoint {
String path();
String method() default "GET";
}
使用时:
@Endpoint(path = "/users")
public void listUsers() {
}
注解元素必须是受限类型,包括:
- 基本类型;
String;Class或其数组;- 枚举;
- 注解;
- 上述类型的数组。
不能把任意对象、集合或动态计算结果放进注解:
// 非法
// String[] values() default new String[0]; // 默认值语法也不允许这样写
// List<String> values();
数组默认值的正确写法是:
String[] tags() default {};
注解元素如果没有默认值,使用注解时必须提供:
public @interface Required {
String value();
}
@Required("production")
class Service {
}
注解实例由编译器或运行时反射代理提供,不应使用 new Required()。其 equals、hashCode 和 toString 由注解契约定义,数组元素按内容比较,而不是简单按数组对象引用比较。
七、元注解:决定注解的语义边界
元注解是“用于声明注解类型的注解”。最核心的元注解包括 @Target、@Retention、@Documented、@Inherited 和 @Repeatable。
7.1 @Target:允许放在哪里
import java.lang.annotation.ElementType;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Endpoint {
String path();
}
ElementType 包括类、接口、方法、字段、构造器、参数、局部变量、模块、包、类型参数和类型使用等位置。
声明位置与类型使用位置不同:
class Box<@Marker T> {
@Marker
String field;
List<@Marker String> values;
}
若要标记 List<String> 中的 String,必须使用 ElementType.TYPE_USE;仅声明 FIELD 或 TYPE_PARAMETER 不足以覆盖这个位置。
类型使用注解可以通过反射中的 AnnotatedType 系列 API 读取,例如:
Field field = Example.class.getDeclaredField("values");
AnnotatedType type = field.getAnnotatedType();
这与 Field.getAnnotation(...) 读取字段声明上的注解不是同一条路径。
7.2 @Retention:保存到什么时候
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface Endpoint {
String path();
}
三种保留策略:
SOURCE:只在源代码中存在,编译后通常不可见;CLASS:写入 class 文件,但运行时反射不一定可见;RUNTIME:写入 class 文件,并允许运行时反射读取。
如果一个注解没有声明 @Retention,默认策略是 CLASS。因此下面的注解不能依赖运行时反射读取:
public @interface CompileOnly {
}
注解处理器工作在编译阶段,能够看到适合当前处理轮次的源模型;它不要求注解使用 RUNTIME。RUNTIME 是运行时反射需求,不是编译器处理需求。
7.3 @Documented:是否进入 API 文档
@Documented
public @interface PublicApi {
}
@Documented 只影响标准 Javadoc 是否把该注解作为被注解元素的一部分展示。它不改变运行时可见性,也不改变继承规则。
7.4 @Inherited:只对类注解有效
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Component {
}
@Component
class Base {
}
class Child extends Base {
}
对类使用 getAnnotation(Component.class) 时,子类可能继承父类的类注解。
但 @Inherited 有严格边界:
- 只作用于类;
- 不作用于接口实现;
- 不作用于方法;
- 不作用于字段;
- 不作用于构造器;
- 不会让覆盖方法自动继承父方法注解。
因此下面的假设是错误的:
interface Audited {
@Audit
void save();
}
class Service implements Audited {
// Service.save() 不会因为实现接口而自动获得 @Audit
}
框架如果需要“方法注解继承”,必须自行沿方法解析顺序查找,例如先查实现类方法,再查父类方法和接口方法,并处理重载、桥接方法和协变返回值。
7.5 @Repeatable:重复注解的容器机制
import java.lang.annotation.Repeatable;
@Repeatable(Endpoints.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Endpoint {
String path();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Endpoints {
Endpoint[] value();
}
使用:
@Endpoint(path = "/users")
@Endpoint(path = "/members")
void list() {
}
编译器会按重复注解规则保存容器信息。读取时:
Endpoint[] endpoints = method.getAnnotationsByType(Endpoint.class);
通常应使用 getAnnotationsByType,而不是只调用 getAnnotation(Endpoint.class)。后者不会把多个重复注解直接当成一个数组返回。
八、运行时读取注解:代理、继承与默认值
定义一个运行时注解:
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.METHOD)
public @interface Route {
String path();
String method() default "GET";
}
读取代码:
import java.lang.reflect.Method;
class UserController {
@Route(path = "/users")
public String users() {
return "ok";
}
}
public class AnnotationReadDemo {
public static void main(String[] args) throws Exception {
Method method = UserController.class.getMethod("users");
Route route = method.getAnnotation(Route.class);
System.out.println(route.path()); // /users
System.out.println(route.method()); // GET
}
}
运行时注解对象通常是由 JDK 生成的代理视图。读取一个带默认值的元素时,返回的是注解声明中的默认值;没有默认值的元素在编译阶段就必须提供。
要注意查找方法的区别:
method.getDeclaredAnnotation(Route.class);
method.getAnnotation(Route.class);
method.getDeclaredAnnotations();
method.getAnnotations();
它们在继承和查找范围上存在差异。对于方法注解,不应根据 @Inherited 推断父类方法注解会自动出现在子类方法上,因为 @Inherited 本身不适用于方法。
九、注解处理器:在编译期消费注解
9.1 注解处理器不是运行时反射
注解处理器实现:
javax.annotation.processing.Processor
通常通过继承:
javax.annotation.processing.AbstractProcessor
在编译阶段被 javac 调用。它读取的是 javax.lang.model 提供的符号模型,而不是已经加载并执行的业务类。
处理器可以:
- 检查注解使用是否满足约束;
- 发出编译错误或警告;
- 生成新的源文件、类文件或资源文件;
- 根据类型模型生成注册表、适配器和元数据。
处理器不能把运行中的业务对象当作输入,也不应通过 Class.forName 代替源码模型查询。此时目标类可能尚未生成,或者加载类会引入编译期副作用。
9.2 一个最小可运行处理器
注解:
package example.anno;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface GenerateMarker {
}
处理器:
package example.processor;
import example.anno.GenerateMarker;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import java.util.Set;
public class GenerateMarkerProcessor extends AbstractProcessor {
@Override
public synchronized void init(ProcessingEnvironment environment) {
super.init(environment);
}
@Override
public Set<String> getSupportedAnnotationTypes() {
return Set.of(GenerateMarker.class.getCanonicalName());
}
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
@Override
public boolean process(
Set<? extends TypeElement> annotations,
RoundEnvironment roundEnvironment) {
for (Element element :
roundEnvironment.getElementsAnnotatedWith(GenerateMarker.class)) {
if (!(element instanceof TypeElement type)) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@GenerateMarker 只能用于类型",
element
);
continue;
}
processingEnv.getMessager().printMessage(
Diagnostic.Kind.NOTE,
"发现类型: " + type.getQualifiedName(),
element
);
}
return true;
}
}
处理器需要通过 META-INF/services/javax.annotation.processing.Processor 注册,文件内容为:
example.processor.GenerateMarkerProcessor
被处理的源代码:
package example.app;
import example.anno.GenerateMarker;
@GenerateMarker
public class Demo {
}
使用 javac 时,处理器通常放在处理器路径上:
javac \
-processorpath build/processor-classes \
-cp build/processor-classes \
-d build/app-classes \
src/example/app/Demo.java
预期会看到类似编译器提示:
Note: 发现类型: example.app.Demo
这里的 Element 是源码声明或符号模型中的元素,TypeElement 表示类或接口声明。它不是 java.lang.Class,也没有要求目标类已经被 JVM 加载。
9.3 处理轮次和生成文件
注解处理可能有多轮:
- 第一轮读取用户源代码;
- 处理器生成新的源文件;
- 编译器编译新源文件;
- 后续轮次再次让处理器观察新产生的类型;
- 当没有新的源文件产生时进入最后一轮,
RoundEnvironment.processingOver()为true。
处理器不能在同一轮无限生成同名文件,否则会出现 FilerException 或重复类型错误。
生成源文件应使用 Filer:
JavaFileObject file = processingEnv.getFiler()
.createSourceFile("example.generated.GeneratedRegistry");
try (Writer writer = file.openWriter()) {
writer.write("""
package example.generated;
public final class GeneratedRegistry {
private GeneratedRegistry() {}
}
""");
}
处理器应把错误定位到具体元素:
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"缺少 path 属性",
element
);
这样编译器通常可以把错误显示在对应源代码位置,而不是只输出一个无法定位的文本。
9.4 处理器中的泛型边界
注解处理器读取泛型时,不能使用运行时反射的 Type 体系,而要使用:
TypeMirror:类型镜像;DeclaredType:参数化声明类型;TypeVariable:类型变量;WildcardType:通配符;ArrayType:数组类型;Types:类型关系判断;Elements:名称、包和声明查询。
例如判断某类型是否是 List 的子类型,不应直接比较字符串名称,也不能简单依赖 TypeMirror.toString()。应使用:
Types types = processingEnv.getTypeUtils();
boolean assignable = types.isAssignable(candidate, listType);
这使处理器能够尊重继承、泛型参数和编译器的类型规则。
十、注解处理器与运行时反射如何配合
两者可以形成“编译期生成、运行时执行”的链路:
flowchart LR
A[源代码与注解] --> B[javac]
B --> C[注解处理器]
C --> D[生成注册表/适配器]
B --> E[class 文件]
D --> E
E --> F[类加载器]
F --> G[Class 与运行时注解]
G --> H[反射或 MethodHandle 调用]
关键路径是:
- 源代码中的
@Route被处理器读取; - 处理器生成
GeneratedRoutes; javac把用户类和生成类编译为 class 文件;- 应用启动时加载生成的注册表;
- 注册表可以使用直接调用、反射或
MethodHandle调用目标方法。
这类设计可以把“发现错误”的时间提前到编译期。例如,处理器可以检查路由路径重复;运行时反射则适合插件、脚本、动态配置等编译时无法确定的对象。
但生成代码并不会自动解决所有边界:
- 生成代码仍然受模块访问规则约束;
- 运行时类加载器必须能找到生成类;
- 生成的成员调用必须匹配实际方法签名;
- 增量编译必须正确声明生成文件与输入文件的关系;
- 处理器生成的注册表可能与运行时部署的类版本不一致。
十一、泛型、类型擦除和反射的边界
11.1 为什么 List<String> 不能通过对象得到
考虑:
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
擦除后,两者运行时都主要表现为 ArrayList。因此:
strings.getClass() == integers.getClass(); // true
如果 JVM 根据对象实例区分 ArrayList<String> 和 ArrayList<Integer>,同一个类文件就需要为每种参数化生成不同运行时类型,这不是 Java 泛型的实现模型。
泛型参数仍可从声明中读取:
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
class Repository {
List<String> names;
}
Field field = Repository.class.getDeclaredField("names");
Type type = field.getGenericType();
if (type instanceof ParameterizedType parameterized) {
Type raw = parameterized.getRawType();
Type argument = parameterized.getActualTypeArguments()[0];
System.out.println(raw); // interface java.util.List
System.out.println(argument); // class java.lang.String
}
这里读取的是 Repository.names 的声明签名,而不是某个 List 对象实例的实际元素类型。
11.2 通配符边界不是运行时检查
List<? extends Number> numbers;
List<? super Integer> integers;
这些边界主要服务于编译器类型检查:
? extends Number:可以安全读取为Number,但不能安全写入任意Number;? super Integer:可以写入Integer,读取时只能安全视为Object。
反射读取泛型签名时可能看到 WildcardType,但运行时对象不会自动执行完整的泛型约束。下面的强制转换:
@SuppressWarnings("unchecked")
List<String> list = (List<String>) rawList;
通常只检查对象是否是 List,不会逐个检查已有元素是不是 String。真正访问元素时才可能抛出 ClassCastException。
11.3 类型变量的上界
class Box<T extends Number> {
T value;
}
擦除后,T 的擦除上界是 Number。反射读取字段时可能得到:
TypeVariable<?> variable = ...;
variable.getBounds(); // [Number]
如果没有显式上界:
class Box<T> {
T value;
}
类型变量的默认上界是 Object。
这解释了一个重要边界:Class<T> 能表达一个具体运行时类,但不能表达任意参数化类型。若需要保存 List<String> 这种类型信息,通常要使用 Type、自定义 TypeToken,或框架自己的类型描述对象。
十二、反射和 MethodHandle 的错误路径
动态调用的错误可以按阶段定位。
12.1 查找阶段
lookup.findVirtual(
Calculator.class,
"add",
MethodType.methodType(int.class, int.class, int.class)
);
如果名称、静态性、参数或返回类型不匹配,可能得到:
NoSuchMethodException;IllegalAccessException;NoSuchFieldException等对应查找异常。
这是“没有得到句柄”的失败。
12.2 适配阶段
handle.asType(expectedType);
如果两种方法类型之间不存在允许的转换,会抛出 WrongMethodTypeException。这是“句柄存在,但不能适配到要求的签名”。
12.3 调用阶段
handle.invokeExact(...);
调用点的静态签名与句柄类型不一致时,通常抛出 WrongMethodTypeException;参数对象不符合引用类型或绑定对象错误时,可能抛出 ClassCastException 或其他参数相关异常。
12.4 目标方法阶段
句柄已经正确调用,但目标方法自身抛出的异常会直接沿调用路径传播,而不像 Method.invoke 那样统一包装成 InvocationTargetException。这对异常处理和日志记录很重要。
十三、类加载器边界:名字相同不代表类型相同
JVM 中一个类的身份通常由:
类的二进制名称 + 定义它的类加载器
共同决定。
因此,如果两个不同类加载器分别加载同名类:
com.example.Plugin
它们可能是两个不同的运行时类型。即使类文件字节完全相同,也可能出现:
ClassCastException: com.example.Plugin cannot be cast to com.example.Plugin
这不是字符串名称矛盾,而是类加载器身份不同。
框架使用线程上下文类加载器加载插件时,应明确:
- 插件接口由哪个类加载器定义;
- 插件实现由哪个类加载器加载;
- SPI 配置由哪个加载器搜索;
- 反射得到的
Class<?>是否与接口的Class<?>属于兼容加载器体系。
Class.forName 使用的加载器和 Thread.currentThread().getContextClassLoader() 可能不同。容器、应用服务器和插件系统中,这个差异经常决定资源和类型是否可见。
十四、模块边界与服务加载
Java 模块系统还影响注解处理器和运行时插件。
编译期处理器需要被编译器发现,通常通过处理器路径和服务注册文件完成;模块化处理器也可以通过模块声明提供 Processor 服务。
运行时插件若使用服务加载机制,需要:
- 服务接口所在模块导出接口包;
- 服务实现模块声明
provides ... with ...; - 使用方声明
uses ...; - 模块路径或类路径能够找到实现。
反射扫描“所有类”并不是 Java 模块系统提供的通用能力。类加载器只知道已经可见或被请求加载的类;要发现某个包下的全部类,通常需要构建工具、文件系统、模块层、JAR 索引或显式注册信息配合。把“扫描 classpath”误认为 JVM 原生能力,会导致部署到模块路径、容器或原生镜像环境时失败。
十五、反射、方法句柄与框架代理的关系
以事务或 AOP 代理为例,调用链可能是:
客户端
-> 代理对象
-> 前置逻辑
-> 目标方法
-> 后置逻辑/事务提交
代理可以使用反射调用目标方法,也可以预先把目标方法适配成 MethodHandle。两者的边界不同:
- 反射适合按名称、注解和
Method元数据进行发现; MethodHandle适合在发现后缓存一个有明确签名的调用路径;- 注解处理器适合把固定的发现结果在编译期生成;
- 直接调用或生成字节码适合静态结构非常明确的场景。
代理必须正确处理以下情况:
- 目标方法是接口方法还是实现类方法;
- 方法是否是桥接方法;
- 返回类型是否协变;
- 异常是否被代理层包装;
- 目标对象是否来自另一个类加载器;
- 目标方法所属包是否允许当前模块访问;
- 注解是在接口方法、父类方法还是实现方法上声明。
例如,接口方法上的事务注解不会因为实现类方法被调用就自动通过 Method.getAnnotation 出现。框架需要定义自己的方法解析策略,而不能把注解继承规则交给 @Inherited。
十六、常见误解与对应诊断
误解一:getDeclaredMethods() 能看到所有可调用方法
它只返回当前类声明的方法,不自动包含父类和接口方法。要分析完整可调用 API,需要沿继承体系和接口体系遍历,并处理重载、默认方法、桥接方法和合成方法。
误解二:setAccessible(true) 可以访问所有私有成员
模块封装、运行时权限和成员实际存在性仍然有效。遇到 InaccessibleObjectException,先检查模块是否开放目标包,而不是只检查 Java 访问修饰符。
误解三:@Inherited 能让方法注解向下传递
它只影响类注解,并且不覆盖接口实现、方法覆盖和字段。方法级继承必须由框架自行定义。
误解四:注解处理器需要 RetentionPolicy.RUNTIME
处理器在编译期读取注解。若只需要生成代码或诊断,可以使用 SOURCE;只有运行时反射需要 RUNTIME。
误解五:invokeExact 会自动帮忙装箱和转换
它要求调用点签名与句柄类型完全一致。出现 WrongMethodTypeException 时,首先打印:
System.out.println(handle.type());
然后检查调用表达式的静态返回类型和参数类型。
误解六:反射能得到对象真实的泛型参数
对象实例通常只保留原始运行时类。getGenericType() 读取的是字段、方法或父类声明签名,不是集合当前内容的元素类型。
误解七:扫描注解等于扫描所有类
运行时只能读取已加载或可加载的类;包扫描是框架或部署工具构建的发现机制,不是 Class API 自动提供的全局索引。
十七、如何选择机制
可以按“信息何时确定”来选择:
| 需求 | 更合适的机制 |
|---|---|
| 编译期校验注解参数 | 注解处理器 |
| 根据类名加载插件 | ClassLoader、Class.forName |
| 根据注解发现方法 | 反射 |
| 发现后反复执行固定签名调用 | 缓存 MethodHandle |
| 编译期生成无反射注册表 | 注解处理器 + 生成代码 |
| 读取参数化声明类型 | Type、ParameterizedType、TypeVariable |
| 访问模块内非公开成员 | 明确的模块 opens、合适的 Lookup |
| 运行时高灵活性配置 | 反射或方法句柄,但必须处理签名和失败路径 |
这里没有“反射一定慢、方法句柄一定快”的规范结论。实际行为取决于调用频率、句柄是否稳定、适配层数量、JIT 优化、类加载器和缓存策略。更可靠的工程判断是:
- 先确定发现发生在编译期还是运行期;
- 再确定调用签名是否固定;
- 再确定模块和类加载器是否允许访问;
- 最后通过基准测试验证具体 JVM、具体版本和具体负载。
十八、边界检查清单
一个依赖反射或注解的组件,至少应明确回答这些问题:
- 目标信息存在于源码、class 文件还是运行时对象中?
- 注解使用了什么
RetentionPolicy? @Target是否覆盖了实际使用位置,尤其是TYPE_USE?- 是否错误地依赖了
@Inherited? - 反射查询是否需要声明成员,还是需要继承成员?
- 是否会遇到桥接方法、合成方法和重载?
- 目标包是否被模块导出或开放?
- 使用的
Class是否来自正确的类加载器? - 泛型信息来自声明签名,还是被误认为来自对象实例?
MethodHandle的方法类型与调用点静态签名是否一致?- 目标方法异常是否需要解包,还是会直接传播?
- 注解处理器是否正确处理多轮编译和重复生成?
- 生成代码、目标类和运行时模块是否来自同一版本?
反射和注解真正的难点不在于记住几个 API 名称,而在于理解信息和权限的边界:Class 只能描述运行时类型,反射只能访问当前可见结构,MethodHandle 必须满足精确调用类型,元注解决定注解的作用范围,处理器只处在编译期,而泛型参数和方法注解也不会凭空跨越类型擦除、继承、模块和类加载器边界。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 日期时间:Instant、LocalDateTime、ZoneId、Duration 和序列化
- 下一篇:Java Stream API:惰性、Collector、并行流、性能和使用边界
- 延伸:Java 泛型完整基础:类型擦除、通配符、PECS、边界和反射
- 延伸:Spring Framework 核心:IoC、Bean 生命周期、AOP、事件和事务代理
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论