Java 注解、反射与运行时元数据
下面三段内容描述的是同一个 @Route,但它们处于不同阶段:
源码声明
@Route("order.create")
public String create(String body) { ... }
class 文件
RuntimeVisibleAnnotations:
example.Route(value="order.create")
运行时对象
Method method = OrderHandler.class.getDeclaredMethod("create", String.class);
Route route = method.getDeclaredAnnotation(Route.class);源码决定怎样写,编译器决定哪些信息进入 class 文件,JVM 加载类型后才会产生 Class<?>、Method 和注解对象。反射程序还要确定候选类型、查询规则与访问权限;只有完成校验并建立调用能力后,order.create 才能成为一条可执行路由。
这条链覆盖了 Java 框架中大量常见机制:JSON 映射、测试发现、依赖注入、ORM、序列化、插件扩展和动态代理。各框架的约定不同,底层面对的仍是同一组问题:元数据保存在哪里,当前查询能看见什么,谁有权操作目标成员,失败发生在哪个阶段。
注解从源码进入 class 文件
注解接口声明了一组结构化元素。下面的 @Route 允许标记方法,在运行时可读,并允许同一方法声明多条路由:
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Routes.class)
public @interface Route {
String value();
boolean authenticated() default true;
}
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Routes {
Route[] value();
}value() 和 authenticated() 是注解元素。元素类型只能是规范允许的基本类型、String、Class、枚举、注解,或这些类型的一维数组;不能用任意业务对象,也不能把 null 当作默认值。标记注解没有元素,单元素注解通常把唯一元素命名为 value,普通注解则可以包含多项配置。JLS 17 的注解接口规则给出了元素类型、默认值、重复注解和预定义元注解的完整约束。
默认值属于注解接口,不会复制进每个使用点的 class 文件。旧类若没有显式填写 authenticated,运行时读取的是当前 Route 接口提供的默认值。因此修改默认值会改变旧制品在新解释器下的结果;新增没有默认值的元素,则可能使读取旧注解时抛出 IncompleteAnnotationException。
Target 决定注解写在哪里
@Target 限定语法位置。常见位置可以分为声明和类型使用两类:
声明注解 declaration annotation
TYPE class OrderHandler
FIELD String id
METHOD String create(...)
PARAMETER String body
CONSTRUCTOR OrderHandler(...)
RECORD_COMPONENT record Order(String id)
ANNOTATION_TYPE @interface Route
类型使用注解 type-use annotation
TYPE_USE List<@NonBlank String>
@Readonly Order load()
String @NonEmpty [] values
TYPE_PARAMETER class Box<@Marker T>@Route 标在方法声明上,使用 Method.getDeclaredAnnotation 查询。若 @Readonly 标在返回类型的使用位置,入口则是 Method.getAnnotatedReturnType()。两者在源码上都以 @ 开头,却进入不同的 classfile 属性,也由不同的反射对象表示。JLS 17 的注解语法与出现位置定义了这种区别。
@Target 省略时,注解可用于多种声明位置,但不能因此用于所有 type context。公共注解最好明确写出解释器真正支持的位置。目标范围太宽,调用方可能在框架从未读取的位置写出合法源码,最后得到一份无人处理的声明。
Retention 决定信息保留到哪一步
三种保留策略对应三种消费阶段:
| 策略 | 编译后的状态 | 典型消费者 |
|---|---|---|
SOURCE | 编译器可以丢弃,不要求进入 class 文件 | 源码检查器、编译器警告 |
CLASS | 写入 class 文件,JVM 不要求运行时反射可见;也是默认策略 | 字节码工具、构建期索引 |
RUNTIME | 写入 class 文件并供运行时反射读取 | 框架启动扫描、运行时映射 |
对于 declaration annotation,运行时可见信息通常位于 RuntimeVisibleAnnotations,CLASS 保留的信息通常位于 RuntimeInvisibleAnnotations;JVMS 17 的运行时可见注解属性定义了 classfile 结构。TYPE_USE 使用独立的 type annotation 属性,并额外记录目标类型位置;其结构见JVMS 17 的 RuntimeVisibleTypeAnnotations。
可以先看产物,再检查框架:
javac --release 17 -d out Route.java Routes.java OrderHandler.java
javap -v -p -classpath out example.OrderHandler预期在目标方法块中看到:
RuntimeVisibleAnnotations:
...Route(...)若源码存在而 class 文件没有相应属性,检查实际编译输入、Retention 和构建产物;属性存在但反射查询为空,再检查注解位置、查询 API、加载的类版本与类加载器。此时扩大扫描包不能修复编译产物缺失。
Repeatable 和 Inherited 改变查询结果
@Repeatable(Routes.class) 让编译器使用容器表示同类型的多次声明。解释器需要调用 getDeclaredAnnotationsByType(Route.class) 才能按 API 规则展开直接存在和间接存在的注解。容器的 value() 必须返回 Route[],其 Target 与 Retention 也要满足被包含注解的兼容要求。
@Inherited 的范围很窄。它只影响类声明上的注解查询,并沿超类链查找;接口上的注解、方法覆盖、字段和构造器都不会因此自动继承。框架若要合并接口方法、父类方法和实现方法,需要自己定义优先级、冲突与重复规则。Java 反射对“直接存在、间接存在、存在、关联”四种关系的定义可查阅 AnnotatedElement。
@Documented 影响生成文档时是否把注解视为被标记元素公共契约的一部分,不会改变运行时行为。注解从声明变成实际逻辑,始终需要一个解释它的编译器、构建工具或运行时程序。
反射把已加载类型映射成运行时对象
核心反射围绕 Class<?> 展开。一个对象通过 getClass() 提供实际运行时类,已知类型可以用类字面量,按名称加载则使用类加载 API:
Class<?> a = order.getClass();
Class<Order> b = Order.class;
Class<?> c = Class.forName("example.Order");Class 也表示接口、注解接口、数组、枚举、记录、基本类型和 void。它不是源码文件的抽象语法树,而是 JVM 当前已加载类型的运行时表示。Class API列出了类型判断、成员查询、模块、包、记录组件、嵌套类型等入口。
同一个二进制名称由不同 defining ClassLoader 定义时,会形成不同的运行时类型。类型身份由名称与定义它的加载器共同决定;pluginA 的 example.Order 不能直接强制转换为 pluginB 的同名类。JVMS 17 的类加载与创建规则规定了这种身份关系。框架若缓存 Class、Method 或 MethodHandle,也会通过这些对象继续引用对应加载器;插件卸载时必须释放整份缓存。
Class、成员和声明类型是三层对象
运行时查询可以先画成一棵对象树:
Class<?> OrderHandler
├─ Field 字段声明与动态读写
├─ Method 方法声明与动态调用
│ ├─ Parameter 形式参数
│ ├─ Class<?> 擦除后的参数/返回类型
│ ├─ Type 声明中的泛型类型
│ └─ AnnotatedType 类型使用及其注解
├─ Constructor<?> 构造器声明与动态创建
├─ RecordComponent record 组件元数据
├─ Class<?> 父类、接口、嵌套类型
└─ Annotation declaration annotationjava.lang.reflect 包说明给出了这些接口和类的职责。日常使用时先确定需要的是哪个层次:
Method method = OrderHandler.class
.getDeclaredMethod("create", java.util.List.class);
Class<?> erased = method.getParameterTypes()[0];
java.lang.reflect.Type generic = method.getGenericParameterTypes()[0];
java.lang.reflect.AnnotatedType annotated =
method.getAnnotatedParameterTypes()[0];假设源码参数为 List<@NonBlank String>,Class<?> 只能看到擦除后的 List.class,Type 可以表示 List<String> 这样的声明泛型结构,AnnotatedType 则负责表示 String 这个类型使用位置上的注解。
Type 只是公共上层接口,实际结果可能是 Class、ParameterizedType、TypeVariable、GenericArrayType 或 WildcardType。读取程序必须按接口分支处理,不能把每个结果都强转成 ParameterizedType。Type API列出了这些实现形态。
public 查询和 declared 查询范围不同
常用成对 API 不是“详细程度”开关,而是不同成员集合:
| 查询 | 返回范围 |
|---|---|
getMethods() | 当前类型和父类型中可访问的 public 方法 |
getDeclaredMethods() | 当前类型自己声明的方法,不限访问修饰符,不包含继承方法 |
getFields() | 当前类型和父类型中的 public 字段 |
getDeclaredFields() | 当前类型自己声明的字段 |
getConstructors() | 当前类的 public 构造器;构造器不继承 |
getDeclaredConstructors() | 当前类声明的全部构造器 |
getAnnotations() | 按 AnnotatedElement 规则返回存在的声明注解 |
getDeclaredAnnotations() | 只返回直接存在的声明注解 |
数组返回顺序不应成为业务语义。建立注册表时,要用路由键、声明类名与 JVM 方法描述符等明确字段排序和判重。只按方法名识别成员,会把重载方法合并成同一个目标。
编译器可能生成 bridge 或 synthetic 成员。泛型覆盖、内部类、枚举和语言特性都会使运行产物包含源码中没有直接写出的成员;扩展点若只接受用户声明的方法,需要检查 isBridge() 与 isSynthetic()。过滤规则必须贴合用途,不能把所有 synthetic 成员一概视作无效 classfile。
参数对象也依赖编译产物。Executable.getParameters() 总能返回参数位置,但源码参数名只有在 class 文件携带对应信息时才可用;先检查 Parameter.isNamePresent(),再决定是否采用名称绑定。Parameter API说明了参数名和 synthetic/implicit 参数的判断入口。生产契约更适合使用显式注解或稳定位置,不能默默把 arg0 当作真实业务名。
取得 Class 与初始化 Class 是两件事
加载、链接和初始化是不同阶段。类字面量、数组类创建和部分元数据查询不会因为“看见 Class”就执行目标类的静态初始化;主动使用静态字段、调用静态方法、创建实例,以及带初始化语义的 Class.forName 等操作会触发初始化。JLS 17 的类与接口初始化条件列出了准确触发点。
扫描程序如果通过 Class.forName(name) 批量取得候选,会使用默认的初始化行为。只需要检查元数据时,可以显式选择不初始化:
Class<?> candidate = Class.forName(
"example.OrderHandler",
false,
pluginClassLoader);这里仍可能发生类加载、验证或依赖解析相关工作,只是没有主动执行 OrderHandler 的类初始化方法。后续读取某个非编译期常量静态字段、调用静态方法或创建对象时,初始化仍会发生。候选发现阶段出现数据库连接、线程启动或配置读取,首先检查是否误触发了静态初始化。
核心反射没有“枚举整个 classpath 中所有带某注解类型”的标准方法。候选集合通常来自显式注册、模块服务、构建期索引或边界明确的资源扫描器。先限制候选,再查询类型;不要让远程参数直接提供类名和方法名。
元数据经过校验才成为调用能力
反射可以找到一个成员,实际读取、写入、构造或调用还要通过语言访问与模块访问检查。public、protected、package-private、private 只是第一层;成员所在包是否由模块 exports,是否向调用模块 opens,以及反射代码以哪个调用者身份运行,也会改变结果。
canAccess(receiver) 检查当前反射对象能否访问指定目标;trySetAccessible() 尝试关闭语言访问检查,成功返回 true,模块边界不允许时返回 false;setAccessible(true) 无法启用时抛出 InaccessibleObjectException。它们都不能普遍绕过强封装。AccessibleObject API列出了模块、导出、开放与调用者之间的条件。
Field value = String.class.getDeclaredField("value");
System.out.println(value.trySetAccessible()); // 常规 Java 17 启动参数下为 false
value.setAccessible(true); // 抛出 InaccessibleObjectException长期修复应提供公开且窄的适配接口,或者由目标模块向明确的消费模块开放所需包。--add-opens java.base/java.lang=ALL-UNNAMED 会把授权扩大到所有 classpath 代码,也会让本应失败的访问探针变成成功;它适合受控迁移诊断,不适合作为每个服务的默认“反射修复”。
Method 与 MethodHandle 的调用契约
Method.invoke(receiver, args...) 会检查访问权限、接收者、参数数量与转换规则。它允许部分拆箱、装箱和基本类型扩大转换,不允许需要窄化的转换;目标方法实际抛出的异常会包装在 InvocationTargetException 中,根因由 getCause() 取得。Method API给出了调用与异常契约。
MethodHandles.Lookup 把查找者身份与查找权限放在一个对象中。publicLookup()、当前调用类的 lookup() 与经授权的 privateLookupIn() 能力不同;私有 lookup 仍受模块可读性和目标包开放关系约束。MethodHandles.Lookup API定义了 lookup modes 与成员解析规则。
MethodHandle target = MethodHandles.lookup()
.unreflect(method)
.bindTo(handler);
String result = (String) target.invokeExact(body);invokeExact 的调用点类型必须与句柄类型完全一致,否则先得到 WrongMethodTypeException。目标方法抛出的异常直接从句柄调用传播,不再套一层 InvocationTargetException。这一区别会影响日志解包和错误分类,但不表示 MethodHandle 天然比 Method 更适合所有调用。
JDK 动态代理建立的是另一种调用入口:给定接口集合、定义代理类的加载器与 InvocationHandler,运行时生成代理对象;每次接口方法调用交给 handler 处理。Proxy API说明了接口限制、代理类可见性与 Object 方法分派。它不能代理一个没有接口的任意具体类,也不会自动读取业务注解;这些仍由上层解释器决定。
启动期编译一份不可变计划
以注解路由为例,可靠的运行路径可以拆成两个阶段:
启动或插件装载期
controlled candidates
→ query annotation
→ normalize descriptor
→ validate key and signature
→ check access
→ link Method / MethodHandle
→ immutable Map<String, Plan>
→ atomic publish
请求期
routeKey → current snapshot → Plan.invoke(payload)计划对象保存已经验证的稳定键、源码成员和执行能力:
record Plan(String key, Method source, MethodHandle target) {}
static Map<String, Plan> compilePlans(List<Object> candidates)
throws IllegalAccessException {
Map<String, Plan> plans = new LinkedHashMap<>();
MethodHandles.Lookup lookup = MethodHandles.lookup();
for (Object candidate : candidates) {
Method[] declared = candidate.getClass().getDeclaredMethods();
Arrays.sort(declared, Comparator
.comparing(Method::getName)
.thenComparing(Method::toGenericString));
for (Method method : declared) {
if (method.isBridge() || method.isSynthetic()) {
continue;
}
for (Route route : method.getDeclaredAnnotationsByType(Route.class)) {
validate(route, method);
MethodHandle target = lookup.unreflect(method).bindTo(candidate);
Plan old = plans.putIfAbsent(
route.value(), new Plan(route.value(), method, target));
if (old != null) {
throw new IllegalArgumentException(
"duplicate route: " + route.value());
}
}
}
}
return Map.copyOf(plans);
}真实实现应使用 JVM descriptor 或等价的参数/返回类型序列作为稳定签名,而不是依赖 toGenericString() 的展示文本;上例排序只为突出“显式排序后再校验”。全部候选成功后才发布新 Map,任一重复键、坏签名或访问失败都会让旧快照继续生效。请求线程不会读到半张新表。
外部输入只能选择已经注册的 routeKey。如果允许客户端提交类名、成员名和参数,再由服务反射执行,配置就变成了任意代码选择能力。可执行 Method、私有 Lookup 和 MethodHandle 本身也属于能力对象,应由组件容器持有,不进入用户可写缓存或长生命周期全局 Map。
运行时解释、构建期生成和显式注册
同一份声明可以在不同阶段被解释:
| 方式 | 适用条件 | 主要成本 |
|---|---|---|
| 显式注册 | 扩展点少、权限高、关系需要直接导航 | 注册代码增加,动态组合较少 |
| 运行时反射 | 候选有限,部署后仍需按模块或配置组合 | 启动查询、模块访问配置、错误暴露较晚 |
| 构建期索引或代码生成 | 候选在编译时已知,希望前移签名错误并减少启动查询 | 处理器、增量编译和多模块索引合并更复杂 |
Java 的标准构建期入口是 annotation processing。处理器在编译环境中读取语言模型并生成文件或诊断信息,面对的不是运行时 Class<?>;javax.annotation.processing 包说明定义了处理器发现、轮次和支持注解等基础契约。构建期生成与运行时反射可以组合,例如构建索引提供候选,启动期再校验当前模块访问与实际配置。
缓存的所有者应与类型加载器同寿命。插件容器卸载时,先停止插件任务并注销监听器,再释放计划快照、Class、Method、MethodHandle 和代理实例等引用。若宿主静态 Map 仍持有旧句柄,旧 ClassLoader 和它定义的全部类型仍然可达,替换插件后可能持续增加 Metaspace 占用。
固定 JDK 实验与分层回查
配套实验把 CLASS/RUNTIME 保留、重复注解、继承查询、候选限制、不可变计划、MethodHandle 调用和模块强封装放在同一份可执行程序中。可直接取得 Docker 入口 run-docker.sh 和本机 JDK 入口 run.sh。
在 Linux 与 Docker 中执行
环境要求:Linux 主机、Bash、Docker Engine,以及当前用户访问 Docker daemon 的权限。容器使用固定摘要的 Temurin JDK 17,以宿主普通 UID/GID 执行;网络、额外 Linux capabilities 和写入源码目录的能力均被关闭。镜像标签与用法可查阅 Eclipse Temurin 官方镜像,补丁版本来源可在 Adoptium Temurin 发布页核对。
从仓库根目录运行:
bash docs/.vuepress/public/examples/backend-development/annotations-reflection-plan/run-docker.sh脚本内部固定:
eclipse-temurin:17.0.20_8-jdk@sha256:a27c79d44326d5f689668df5fedfee487652066d2a91e172747056cc7fbee6fc预期先打印实际 java、javac 和 javap 的版本,再得到:
class-retention=RuntimeInvisibleAnnotations
runtime-retention=RuntimeVisibleAnnotations
repeatable-routes=2
inherited-component=true
controlled-candidates=1
plan-snapshot=Map.copyOf
method-handle-keyed-invoke=true
duplicate=rejected-before-invoke
bad-signature=rejected-before-invoke
illegal-access=try-false+set-throws
PASSduplicate 与 bad-signature 在内部进程中都应以状态 2 退出,并保持 invocation-count=0;外层脚本捕获并验证这些预期失败,最终成功状态仍为 0。illegal-access 必须得到 trySetAccessible=false 和 InaccessibleObjectException。如果它意外成功,先检查宿主或运行器是否注入 JAVA_TOOL_OPTIONS、JDK_JAVA_OPTIONS、_JAVA_OPTIONS 或 --add-opens。
需要在已有 JDK 17 上直接运行时:
export JDK17_HOME=/opt/jdk-17
bash docs/.vuepress/public/examples/backend-development/annotations-reflection-plan/run.shJDK17_HOME 指向包含 bin/java、bin/javac 和 bin/javap 的 JDK 根目录。脚本验证三项工具都属于 Java 17,以 --release 17 -Xlint:all -Werror 编译,并在 mktemp 创建的临时目录中运行;退出时自动清理。
需要单独观察负例时,可以先让脚本完成编译断言,再按 README 的底层命令执行对应 mode。不要在生产 JVM 上为了复现访问成功而临时扩大 --add-opens,独立实验容器更容易控制结论。
按发生阶段判断失败
| 现象 | 阶段 | 首先检查 | 下一步 |
|---|---|---|---|
javap 没有目标注解属性 | metadata | 实际 class、Retention、Target、编译输入 | 修正声明并重新编译,比较产物 |
| class 有属性,反射查询为空 | query | declaration/type-use API、直接/继承查询、实际加载器 | 打印 Class 的名称、模块和加载器,再缩小查询 |
| 候选类型根本没出现 | discovery | 注册表、模块服务、索引或扫描边界 | 修复候选来源;不要改访问权限 |
| 出现重复键或坏签名 | validate | 合并顺序、重载、bridge/synthetic、参数/返回类型 | 在发布前拒绝整份新计划 |
trySetAccessible=false | access | 修饰符、调用模块、目标模块的 exports/opens | 使用公开适配入口或最小定向开放 |
IllegalAccessException | link/invoke | Lookup 身份、接收者、成员与模块关系 | 在启动期建立并验证调用能力 |
InvocationTargetException | target | getCause() 指向的目标异常 | 按业务失败处理,不归类为扫描失败 |
WrongMethodTypeException | invoke | MethodHandle 类型与调用点类型 | 打印 target.type(),修正适配器 |
| 插件替换后旧类仍存活 | lifecycle | 静态 Map、线程、监听器、Method/Handle/Proxy 引用 | 释放整个插件容器后再观察回收趋势 |
一次故障只从最早失败的阶段开始处理。class 文件没有运行时属性时,访问权限还没有参与;候选没有进入列表时,MethodHandle 也尚未建立;计划已经调用后目标代码抛错,则需要保留目标异常,而不是把它改写成“反射失败”。
权威资料与规范地址
以下地址分别对应正文中的语言规则、classfile 属性、类加载、核心反射、模块访问、动态代理、注解处理与实验环境,可用于继续查阅原始定义:
| 主题 | 官方地址 |
|---|---|
| 注解接口、默认值、重复注解与元注解 | JLS 17 §9.6 |
| 注解语法与出现位置 | JLS 17 §9.7 |
| declaration annotation 的运行时可见属性 | JVMS 17 §4.7.16 |
| type annotation 的运行时可见属性 | JVMS 17 §4.7.20 |
| 类与接口初始化条件 | JLS 17 §12.4.1 |
| 类加载与运行时类型身份 | JVMS 17 §5.3 |
| 类型对象与成员查询 | Java SE 17 Class |
| 核心反射对象地图 | Java SE 17 java.lang.reflect |
| 注解查询关系 | Java SE 17 AnnotatedElement |
| 声明类型与泛型元数据 | Java SE 17 Type |
| 参数名与参数元数据 | Java SE 17 Parameter |
| 反射访问与模块强封装 | Java SE 17 AccessibleObject |
| 方法查询、转换与调用异常 | Java SE 17 Method |
| MethodHandle 查找能力 | Java SE 17 MethodHandles.Lookup |
| JDK 动态代理 | Java SE 17 Proxy |
| 构建期注解处理 | Java SE 17 javax.annotation.processing |
| 实验镜像 | Docker Hub:Eclipse Temurin |
| Temurin 发布与下载 | Adoptium |
