1. 为什么反射和注解是Java进阶绕不过去的两道坎先说个真实的场景。某个项目里产品临时提了个需求用户每操作一次核心按钮后端要记录操作人、操作时间、操作内容还要区分哪些方法需要记录、哪些不需要。如果按最笨的办法在每个业务方法里手写一段日志逻辑新增需求还好说后期想去掉或者改规则就麻烦了。当时我做的就是写一个自定义注解标记到需要记录的方法上再用反射去读取这些注解、自动拼接参数信息最后统一交给日志服务处理。整个改造没动业务代码一行核心逻辑新方法想要日志加个注解即可。类似的需求面试问过、工作里做过几乎每个Java开发都会遇到。反射Reflection和注解Annotation之所以经常被放在一起讲是因为这两者在Java世界里天然是一对注解负责“标记”反射负责“读取标记并做出反应”。Spring的Component、Autowired、Transactional、GetMapping表面上看起来是“加个注解就有功能”背后的核心机制就是“框架通过反射扫描、读取、处理这些注解”。不把这两块弄明白你用Spring再熟练也只是停留在“会调用API”的层面一旦遇到注解不生效、自定义注解没反应、反射性能问题、动态代理失效这种疑难杂症基本就只能靠猜。这篇文章我会围绕反射和注解从底层原理讲到实战内容包括反射到底在反射什么、Class对象是怎么来的、反射拿到字段和方法之后能干什么、动态代理和反射的关系注解的整套工作机制、元注解、注解处理器的运行时机以及最实用的部分——自定义注解加反射做日志记录和权限校验。整个内容也是这些年我做Java项目总结下来的经验浓缩适合准备面试、正在做Java后端开发、或者想搞懂Spring底层逻辑的读者。看完之后你能做到的不只是“会用反射和注解”而是遇到相关场景时知道“为什么这样设计、出了问题怎么排查”。2. 反射机制的底层逻辑与Class对象解析2.1 反射到底在反射什么类加载的产物很多初学者听到“反射”这个词就发怵觉得像是什么高深魔法。其实用一句话就能说透反射就是Java在运行时“查看自己”的能力。Java程序从源码到运行要经过编译和类加载。编译期编译器把.java文件编译成.class字节码运行期JVM通过类加载器ClassLoader把.class文件加载进内存。加载完成后JVM会为每个类生成一个Class对象这个对象就是该类在运行时的“描述文件”包含了类的所有结构信息——类名、修饰符、父类、接口、字段、方法、构造器、注解等等。普通的代码调用比如User user new User(); user.getName();是在编译期就确定好类型和方法地址的这叫静态绑定。而反射做的事情是绕过编译期的类型检查在运行时通过Class对象去查询和操作这些信息Class? clazz Class.forName(com.example.User);然后通过clazz去创建实例、调用方法、读写字段。这就把“编译期确定”变成了“运行期动态”灵活度完全不同。我打个比方。普通代码相当于你拿着通讯录直接打电话给张三“喂张三帮我办件事”号码和人都写死在纸上。反射相当于你先查通讯录Class对象找到“张三”这个名字对应的条目然后根据条目上的号码去拨通电话。多了一层“查询和匹配”的过程但好处是如果通讯录里的号码变了类结构变了你还是能通过名字找到他。更极端的情况你甚至可以在编译期压根不知道“张三”这个人存在运行期拿到一个字符串“张三”就能动态找到并调用。2.2 Class对象的三种获取方式与适用场景要操作反射第一步是拿到Class对象。Java里提供了三种方式每一种都有自己适用的场景。第一种Class.forName(全限定类名)。这种方式的特点是只需要一个字符串就能在运行时动态加载类。最常见的场景是JDBC驱动加载Class.forName(com.mysql.cj.jdbc.Driver)就是通过这种方式让JVM加载驱动类触发驱动内部的静态初始化块完成注册。这种方式在写框架、做插件化开发时非常常用因为你可能不知道用户会传入哪个类名只能运行时根据配置去加载。第二种类名.class。这种方式不会触发类的静态初始化是获取Class对象最安全、最直接的方法。一般在代码里明确知道类型时使用比如User.class、String.class。很多人没注意到的是基本类型也有对应的Class对象int.class、boolean.class还有void.class也是合法的。第三种对象.getClass()。这是Object类自带的方法在已经持有对象实例时获取其真实类型。注意这里有个细节如果变量声明的是父类类型、实际指向的是子类对象那么getClass()返回的是运行时的真实类型子类而不是声明类型父类。这在多态场景下很常用比如ListString list new ArrayList();list.getClass()得到的会是ArrayList.class。从使用频率来说开发中最常见的还是第二种和第三种。但面试时经常被问的forName也一定不能漏而且要能说清楚它和类名.class的区别forName会执行静态初始化类名.class不会。刚才说的JDBC驱动加载本质上就是利用这个静态初始化特性。2.3 拿到Class对象之后能干的四类事情Class对象是反射操作的入口围绕它主要有四类常用操作我一个个说。创建实例。通过clazz.getDeclaredConstructor().newInstance()来创建对象替代new关键字。这里要特别注意从JDK 9开始Class.newInstance()被标记为废弃deprecated推荐使用getDeclaredConstructor().newInstance()。原因是后者更明确地表达了“获取构造器再实例化”的过程而且只有后者能拿到非public的构造器并设置可访问性之后创建实例。如果类没有无参构造器getDeclaredConstructor()会抛NoSuchMethodException所以需要先拿到对应参数的构造器。获取字段。getFields()返回所有public字段包括继承来的getDeclaredFields()返回本类声明的所有字段包括private但不含继承字段。字段操作最大的坑是如果是private字段必须先调用field.setAccessible(true)才能读写否则会抛IllegalAccessException。这个setAccessible实际上是在修改Java访问控制检查的开关因为安全检查本身有性能开销所以开启之后访问速度也更快。获取方法。和字段类似getMethods()获取本类及父类的所有public方法getDeclaredMethods()只获取本类声明的所有方法。拿到Method之后可以调用invoke(obj, args)来执行。方法反射调用有一个很隐蔽的问题如果是private方法或者protected方法也要先setAccessible(true)。而且invoke的第一个参数是“调用这个方法的对象”如果是静态方法第一参数传null即可。获取注解。这就是反射和注解结合的关键接口后面展开讲。先记住核心方法getAnnotation(Class)判断某个注解是否存在用isAnnotationPresent(Class)获取所有注解用getAnnotations()。要注意的是这三个方法默认只能看到Retention(RetentionPolicy.RUNTIME)的注解CLASS级别的注解在运行期是拿不到的。2.4 反射的性能开销真的那么可怕吗几乎所有讲反射的文章都会提到“反射性能差”但很少有人说清楚到底差在哪、差多少、什么时候需要在意。我第一次用反射做批量调用时做过一个粗略的测试直接调用方法一百万次和通过Method.invoke调用一百万次后者的耗时大约是前者的三到五倍。听起来差距很大但在绝大多数业务系统里一次方法调用本身就有网络IO、数据库查询、序列化等操作单次耗时动辄几毫秒甚至几十毫秒反射多出来的那零点几微秒几乎可以忽略不计。真正要小心的是两种场景一是高频循环中的反射调用比如在for循环里对同一条数据反复做反射操作二是反射获取字段或方法后没有做缓存每次都重复getDeclaredField、getDeclaredMethod。这两个操作本身是需要遍历类结构信息的比invoke更耗时反复调用纯属浪费。正确的做法是把反射拿到的Method、Field、Constructor缓存起来复用。比如Spring的BeanWrapperImpl、MyBatis的ResultSetHandler内部都是对反射元数据做了大量缓存。写代码时也可以模仿这种思路类加载时一次性把需要反射的元数据收集到一个Map里后续直接取用。另外补充一点JDK 8以后对反射性能已经有了明显优化尤其是方法调用时的“膨胀”inflation机制当某个反射方法被调用多次后JVM会把它从“解释执行”切换为“动态生成字节码并直接调用”这个优化后的反射调用速度已经很接近直接调用了。所以别谈到反射就慌关键是看你怎么用。3. 反射实战动态代理与框架级应用3.1 反射和动态代理是什么关系动态代理严格来说并不等于反射但它底层确实用到了反射机制。在Java里实现动态代理有两种主流方案JDK动态代理和CGLIB。JDK动态代理要求目标类必须实现接口它在运行时通过Proxy.newProxyInstance生成一个实现了指定接口的代理类代理类的方法调用会被转发到InvocationHandler的invoke方法上。看一段熟悉的代码public interface UserService { void addUser(String name); } public class UserServiceImpl implements UserService { Override public void addUser(String name) { System.out.println(添加用户 name); } } public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(调用前日志 method.getName()); Object result method.invoke(target, args); System.out.println(调用后日志 method.getName()); return result; } } // 使用 UserService userService new UserServiceImpl(); UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, new LogInvocationHandler(userService) ); proxy.addUser(张三);这里最关键的一行就是method.invoke(target, args)它通过反射调用目标对象的真实方法。整个链路是你调用代理对象的方法 - JDK生成的代理类把调用转发给InvocationHandler - handler拿到Method对象 - 通过反射调用真实对象的方法。Spring AOP的默认实现就是JDK动态代理如果目标类没有实现接口Spring会退而使用CGLIB来生成目标类的子类实现代理。3.2 注解与反射结合的最佳实践Spring的套路Spring框架是我见过注解和反射配合最密集、最典型的例子理解Spring的几个核心注解处理流程能帮你把这两个知识点串成一条线。以Autowired为例Spring容器启动时大概做了这几步扫描指定包下的所有类找出带有Component等注册注解的类通过反射创建实例放入容器创建完所有Bean之后Spring会遍历每个Bean的字段检查哪些字段上标了Autowired对于标注了的字段调用field.getType()获取字段类型再从容器中查找匹配的Bean找到后调用field.setAccessible(true)然后通过field.set(bean, dependencyBean)把依赖注入进去。再比如Transactional。Spring在创建Bean时发现类或方法上有Transactional就会为这个Bean生成代理对象。代理对象在执行方法前通过反射读取目标方法的Transactional注解获取事务的传播行为、隔离级别、回滚规则等参数然后开启事务、执行方法、根据执行结果决定提交还是回滚。整套流程里注解定义了“规则”反射负责“读取规则并执行”。理解了这个套路你再看Spring Boot的ConditionalOnProperty、ConfigurationProperties、MyBatis的Select、Insert甚至Lombok的Getter、Setter都是在同一个思维模型下工作的标注信息 处理机制 自动功能。你自己设计框架的时候只要能把这两块配合好也能写出“加个注解就生效”的高级功能。3.3 手写一个简易的“注解反射”框架接口耗时统计光说不练假把式我设计一个完整的示例给需要统计耗时的接口方法加个CostTime注解然后通过反射自动扫描并统计调用耗时模拟一个最简版的性能监控。自定义注解定义如下import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface CostTime { String name() default ; }模拟一个业务类并打上注解public class OrderService { CostTime(name createOrder) public void createOrder() throws InterruptedException { Thread.sleep(100); } CostTime(name cancelOrder) public void cancelOrder() throws InterruptedException { Thread.sleep(200); } public void noAnnotationMethod() { // 这个方法没加注解不应该被统计 } }核心处理类通过反射扫描方法上的注解并动态调用import java.lang.reflect.Method; public class CostTimeInvoker { public static void invokeWithCostTime(Object target, String methodName, Object... args) throws Exception { Method method resolveMethod(target.getClass(), methodName); CostTime costTime method.getAnnotation(CostTime.class); if (costTime null) { // 没有注解的方法直接调用不统计 method.invoke(target, args); return; } long start System.nanoTime(); try { method.invoke(target, args); } finally { long cost (System.nanoTime() - start) / 1_000_000; System.out.println(方法 [ costTime.name() ] 耗时 cost ms); } } private static Method resolveMethod(Class? clazz, String methodName) throws NoSuchMethodException { // 可以在这里加缓存优化 return clazz.getDeclaredMethod(methodName); } }测试运行public class Main { public static void main(String[] args) throws Exception { OrderService service new OrderService(); CostTimeInvoker.invokeWithCostTime(service, createOrder); CostTimeInvoker.invokeWithCostTime(service, cancelOrder); CostTimeInvoker.invokeWithCostTime(service, noAnnotationMethod); } }输出结果方法 [createOrder] 耗时 100 ms 方法 [cancelOrder] 耗时 200 ms注意noAnnotationMethod没有输出说明没有注解的方法不会被统计。这个例子虽然简单但已经具备了一个“注解驱动”框架的完整骨架注解定义规则、反射读取规则、程序执行动作。把这里的日志统计换成权限校验、参数校验、分布式锁、缓存处理就是各种中间件的雏形。4. 注解的完整机制从定义到运行时读取4.1 元注解注解的注解注解本身也是Java类型要定义一个标准的注解离不开四个元注解它们决定了这个注解的生命周期和作用范围。Retention用于指定注解的保留策略有三个取值RetentionPolicy.SOURCE表示注解只保留在源码中编译后就被丢弃RetentionPolicy.CLASS表示注解保留在class文件中但JVM加载类时不会保留这是默认值RetentionPolicy.RUNTIME表示注解会保留到运行期能够被反射读取。实战中凡是要在运行时通过反射读取的注解比如Spring的各种注解、MyBatis的Mapper注解都是用RUNTIME。SOURCE级别的典型代表是Override和Lombok的部分注解它们只在编译期起作用运行期完全不存在。Target指定注解可以应用在什么元素上常见的取值有ElementType.TYPE类、接口、枚举、ElementType.METHOD方法、ElementType.FIELD字段、ElementType.PARAMETER参数、ElementType.CONSTRUCTOR构造器等。如果一个注解想同时在类和方法上使用可以写成Target({ElementType.TYPE, ElementType.METHOD})。不要小看这个限制它能在编译期就拦截错误的用法比如把只能标在方法上的注解误标到类上。Documented表示注解是否被包含在Javadoc文档中Inherited表示注解是否可以被子类继承。Inherited有个容易被忽略的坑它只对类上的注解生效对方法上、字段上的注解不生效。也就是说父类方法上的注解子类重写方法后是默认不带过去的除非子类自己再加注解。4.2 注解的属性定义规则与默认值注解的“属性”和普通类的字段不同。注解里定义属性用的是“方法声明”的语法格式。比如public interface MyAnnotation { String value() default ; int order() default 0; String[] tags() default {}; }这里的value()、order()、tags()本质上就是注解的属性。使用注解时直接写MyAnnotation(value hello, order 1)。有一个特殊规则如果注解只有一个属性且名字叫value使用时可以省略属性名直接写MyAnnotation(hello)。这也是为什么Spring里的RequestMapping(/path)、GetMapping(/path)能直接写一个字符串的原因——它们都有名为value的主属性。注解属性的类型被严格限制只能是基本类型、String、Class、枚举、注解以及这些类型的数组。不能是任意自定义对象这是设计上的硬性限制。定义属性时所有属性都必须有默认值或者在使用时必须显式赋值否则编译报错。4.3 运行期读取注解从“标记”到“行为”的关键一跳注解本身没有“行为”它只是元数据。要把“标记”转换成“行为”必须有一段处理逻辑去读取它。按处理时机Java里有两种读取方式。第一种是运行时读取也是最常见的方式。通过反射的getAnnotation、getAnnotations、isAnnotationPresent方法来做。前面耗时统计的例子就是运行时读取。这种方式实现简单、灵活缺点是必须到运行时才知道注解有没有生效。第二种是编译期处理通过写注解处理器Annotation Processor实现。Java编译器在编译阶段会扫描源码中的注解并调用你实现的处理器。Lombok就是这个机制的代表——Getter、Setter之所以能自动生成方法就是因为在编译期通过处理器往抽象语法树AST里注入了新的方法节点。实现一个注解处理器通常要继承javax.annotation.processing.AbstractProcessor配合javax.lang.model包里的元素扫描API来遍历源码结构。这个编译期处理机制的优点是早发现问题、不占用运行时性能但调试起来比运行时读取麻烦得多。写普通业务代码时用到运行时读取的场景占据绝大多数编译期处理器更多用于做代码生成、开发框架和工具库。4.4 常用注解背后的设计思路很多人在用SpringBoot时背了一堆注解用法但没思考过它们为什么这么设计。我挑几个典型来说说。Component、Service、Repository、Controller这四个注解本质都是Component的“语义化别名”。Spring在扫描时只要发现Component或者被Component元注解标注的注解就会把这个类注册为Bean。区分命名并不是因为处理逻辑不同而是为了让开发者在代码里一眼看出类的职责。Configuration与Bean配合使用是在Java类里声明Bean的方式。Configuration标注的类Spring会通过CGLIB对类做增强保证Bean方法之间调用时返回的是同一Bean实例单例。这点在排查“怎么每次拿到的Bean都不是同一个”时特别重要。Transactional注解可以加在类上也可以加在方法上。加在类上相当于给所有public方法都加了这个注解。要注意它只对Spring管理的代理Bean生效——同一个类内部的方法间调用即使被调方法有Transactional事务也不会生效。原因是内部调用走的是this对象而不是代理对象。这点几乎每次面试都会考也是实际开发中事务失效最常见的原因之一。Valid配合Validated用于参数校验NotNull、Size、Pattern这些约束注解之所以能起作用是因为SpringMVC在进入Controller方法前会通过MethodValidationPostProcessor之类机制读取参数上的注解然后调用Hibernate Validator去校验。还是那句话注解负责声明规则框架负责读取并执行规则。5. 反射与注解高频问题排查实录5.1 自定义注解不生效的几种原因我曾经帮同事排查过一个“自定义注解加上了却完全没反应”的问题。方法上标了注解日志也没报错但就是不走预期逻辑。后来查了半小时发现Retention没设置默认值是CLASS。运行时反射根本读不到这个注解。这是最有迷惑性的原因之一代码不报错行为上却是静默的。排查这类问题我建议按顺序做三步检查。第一步确认注解的Retention是不是RUNTIME。不是的话任何运行期反射读取都会返回null。第二步确认Target是否包含实际标注的位置。比如只写了ElementType.FIELD但你标注在方法上编译期IDE可能不会拦截取决于配置但运行时读取方法注解永远得到null。第三步确认调用方是通过反射去读的而不是直接判断方法对象。很多新手直接method.getAnnotation这步没问题却在判断annotation null之后忘了处理逻辑这也是个小坑。5.2 反射调用私有方法或字段报IllegalAccessException这个问题非常典型。Java的访问控制是编译期检查和运行期检查双层保障的。setAccessible(true)的作用是“绕过运行期的访问控制检查”但有个前提所在模块需要允许这种操作。JDK 9引入模块系统之后如果目标类属于某个被封装得比较严的模块比如JDK内部的java.lang包即使调用setAccessible也可能抛InaccessibleObjectException。在普通业务项目里操作自己写的类或第三方库的类setAccessible(true)通常没有问题。但如果反射目标在JDK模块内部比如修改String的私有字段value就需要在启动JVM时加--add-opens参数。顺便说一句Java 17之后反射修改非公共成员的限制更严格了很多依靠setAccessible的框架都在调整方案但开发自己项目里的类不受影响。实操时的建议是能用public方法解决的问题绝不用反射访问私有成员。反射访问私有成员是典型的“能做但不该做”的示例破坏了封装性还容易因为JDK升级踩坑。5.3 泛型信息在反射中丢失怎么处理”Java泛型是编译期的“这个概念已经是老生常谈了。但你如果以为运行期完全拿不到泛型信息也不全对。JVM在类签名里专门保留了泛型信息可以通过getGenericParameterTypes、getGenericReturnType、getGenericSuperclass来获取。举个例子你想通过反射拿到字段ListUser userList中的User类型。普通的field.getType()返回的只是List.class因为是类型擦除。但调用field.getGenericType()返回的是ParameterizedType接着调用((ParameterizedType) genericType).getActualTypeArguments()[0]就能得到User.class。这就是很多ORM框架能把数据库结果自动映射到泛型对象的原因。这个知识点在面试里属于“加分项”工作里用到的地方也不少。比如写一个通用的JSON反序列化工具在没有TypeReference的情况下就需要通过getGenericSuperclass来捕获父类泛型参数。写代码时建议在获取泛型信息的地方加缓存不要每次都重新解析。5.4 Spring事务/切面不生效时的反射视角很多人遇到“加了Transactional事务不生效”时第一反应是去查事务管理器、数据源配置却忽略了最基础的问题这个Bean是不是代理对象。Spring AOP通过代理实现事务和切面功能。如果目标Bean没有走Spring代理比如在配置里用了new关键字创建对象或者通过Async的类没开启代理那么方法上的Transactional注解就只是一行注释没有任何作用。排查这个问题的技巧是打印Bean的class类型System.out.println(bean.getClass())如果类名里有$$这样的后缀说明是CGLIB代理类如果是一个Proxy开头或$Proxy的内部类说明是JDK动态代理类。如果打印出来是原始类名恭喜你找到问题了——Bean根本没有被代理。另外同一个类内部方法调用导致注解失效的问题也是代理机制决定的。createOrder()方法内部调用this.updateStock()即使updateStock()上有Transactional事务也不会生效。因为this是原始对象不是代理对象。绕过方式是把updateStock()放到另一个Spring Bean里或者用AopContext.currentProxy()拿到当前代理对象再调用后者需要在启动类上加上EnableAspectJAutoProxy(exposeProxy true)。5.5 注解处理器的排查与调试思路如果你写的注解处理器没有生效最头疼的是它不会像业务代码那样抛出异常而是静默地不执行。排查时第一步看Maven或Gradle配置是否正确引入了processor路径第二步检查编译输出目录里是否生成了相关文件第三步在处理器里加System.out.println或使用Messager.printMessage输出日志。还有一个常见的问题注解处理器里做的操作在IDE里能生效但命令行构建时就失效。这个大概率是构建工具没有把处理器类加入编译链导致的。IDEA的Annotation Processing选项默认可能是关闭的需要手动在设置里启用这也是Lombok在IDEA里偶尔失效的经典原因。6. 从面试视角重新审视反射与注解6.1 高频面试题的底层逻辑Java面试里反射和注解相关的题目看起来问法五花八门但底层逻辑高度一致。我梳理一下最常见的几类。“什么是反射反射的优缺点”——答案的核心在于运行时动态获取类的信息并操作对象。优点是灵活、是框架实现的基础缺点是性能开销、破坏封装性、安全问题。“Class.forName和ClassLoader.loadClass有什么区别”——Class.forName默认执行静态初始化ClassLoader.loadClass不会。面试官问这个其实是在考察你对类加载过程的理解深度。“动态代理的原理是什么JDK代理和CGLIB有什么区别”——核心答法是JDK代理基于接口通过Proxy生成代理类CGLIB基于继承生成目标类的子类。Spring在目标类实现接口时优先用JDK代理否则用CGLIB。SpringBoot 2.x之后默认强制使用CGLIB这点也可以顺带提一下。“自定义注解的步骤是什么注解如何生效”——答法就是本文第4节的内容定义注解、设置元注解、写处理逻辑。如果能把“注解本身不做事做事的是处理器”这个核心点说出来基本就能把面试官打动。“为什么Spring的Transactional有时不生效”——把代理机制、内部调用、代理对象这三个关键点串起来再加上“事务回滚默认只处理RuntimeException”这个细节就能答得比较完整。6.2 学习路线的建议从用到懂再到造聊了这么多回到学习这件事上。我觉得反射和注解的学习路径可以分三步走。第一步是会用。能在代码里通过反射获取Class对象、读取字段方法、调用方法能自定义注解并写一段反射读取逻辑。这一步的目标是“不看文档也能写出来”。第二步是懂原理。理解反射和类加载的关系理解动态代理和反射的关系理解Spring等框架是怎么用注解加反射来实现自动配置的。这一步可以多去调试Spring源码打断点跟一下Bean创建过程。第三步是透过现象看本质。尝试自己写一个小的代码生成工具、做一个简单的RPC框架、实现一个自己的切面注解。当你动手去“造框架”时很多零散的知识点会自己串起来。我个人的经验是反射和注解最难的地方不是API本身而是你的思维模型能不能从“写业务代码”切换到“写框架代码”。写业务代码时对象、类型都是确定的写框架代码时你面对的是“未知的类”你要做的就是通过反射去发现它、读取它、操作它。一旦完成这个思维切换反射和注解对你来说就不再是“高级技巧”而是工具箱里的常规武器。最后分享一个我在实际项目中经常用的调试技巧排查反射问题时先打印目标类的Class对象确认类加载器加载的是你预期的那份class文件排查注解问题时先打印注解的Retention策略再用isAnnotationPresent去验证注解是否存在。这两步排查做完大部分疑难杂症都能定位到问题原因。