资讯动态

Java反射与注解全解析:从底层原理到工程实战

发布时间:2026/9/20 3:47:13 来源:尧图企业网站定制
有一次我在一个老项目里加自定义注解做接口权限校验按网上的教程在方法上标好注解又写了拦截器去解析结果怎么都不生效。排查了半天才发现注解上的RetentionPolicy写的是SOURCE编译完的字节码里压根就没有这个注解拦截器用反射去getAnnotation永远返回null。那次之后我意识到很多Java开发者对反射和注解的理解停留在会用但不知道为什么的层面面试能背出几个术语但一遇到注解为什么不生效这种问题就抓瞎。这篇博文我就把这两个高级主题一次性讲透——从JVM层面的底层原理到手写一个200行左右的ORM映射器再到实际工程中高频踩坑的典型案例最后附上面试答题框架。无论你是准备跳槽刷题还是想在项目里真正用好注解和反射这篇都可以直接收藏反复看。1. 反射和注解这对搭档到底是怎么配合的1.1 先从一次面试问答说起面试官问谈谈你对反射和注解的理解很多人的回答是这样的反射是运行时获取类的信息注解是给代码打标签。这个说法没毛病但太浅了而且忽略了两者之间非常紧密的关系。我自己的理解是注解是被动数据反射是主动读取器。注解本身什么活都不干它就是贴在类、方法、字段上的一段元数据。这堆数据如果不被某种机制读取和解析那它跟注释没区别甚至比注释更占地方。而在运行时读取注解的核心手段就是反射。可以打一个生活化的比方注解像商品外包装上的标签反射像收银台的扫码枪。标签自己不产生价值但它记录了一批关键信息扫码枪扫上去信息才会被系统识别并触发后续动作——结账、库存扣减等等。没有扫码枪的标签只是一张贴纸没有反射的注解只是一段死数据。1.2 一个注解从编写到生效完整链路是什么样的我们来看一个完整的迷你测试框架模拟JUnit的运行方式。首先定义一个注解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 MyTest { int order() default 0; }然后在测试类上标注它public class StringUtilTest { MyTest(order 1) public void testIsBlank() { System.out.println(执行 testIsBlank); } MyTest(order 2) public void testJoin() { System.out.println(执行 testJoin); } }最后的运行器用反射把带注解的方法找出来并执行import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Comparator; import java.util.List; public class MyTestRunner { public static void main(String[] args) throws Exception { Class? clazz StringUtilTest.class; ListMethod testMethods new ArrayList(); for (Method method : clazz.getDeclaredMethods()) { if (method.isAnnotationPresent(MyTest.class)) { testMethods.add(method); } } testMethods.sort(Comparator.comparingInt( m - m.getAnnotation(MyTest.class).order())); Object instance clazz.getDeclaredConstructor().newInstance(); for (Method method : testMethods) { method.invoke(instance); System.out.println(方法 method.getName() 执行成功); } } }这个例子把整条链路展示得很清楚定义注解 → 标注注解 → 反射获取注解 → 根据注解元数据执行逻辑。注意体会中间那一层method.invoke(instance)本身也是反射也就是说注解和反射经常是层层嵌套使用的——你用反射去读注解读完之后还要用反射去调用真正的方法。理解了这层关系再看网上那些把反射和注解拆开讲的教程总觉得隔着一层纱。它们从来就是配合使用的尤其在一个框架内部几乎不会单独出现。2. 反射机制底层拆解Class对象、核心API与性能疑问2.1 Class对象到底是什么三种获取方式怎么选反射的一切起点是Class对象。JVM在类加载阶段会在堆里为每一个类创建一个java.lang.Class实例里面保存了这个类的完整结构信息——类名、修饰符、父类、接口、字段、方法、构造器以及之后要重点说的注解。获取Class对象有三种方式选择逻辑完全不同// 方式一Class.forName Class? clazz1 Class.forName(com.example.User); // 方式二实例.getClass User user new User(); Class? clazz2 user.getClass(); // 方式三类字面量 Class? clazz3 User.class;关键差别在于初始化时机。Class.forName会触发类的静态初始化也就是执行static块User.class和getClass不会。这就是为什么早期JDBC驱动加载要写Class.forName(com.mysql.cj.jdbc.Driver)——驱动在静态块里注册了自己必须触发初始化。而如果你的目的是拿到Class做元数据解析不希望类被初始化用.class更安全。还有一个高频考点这三种方式拿到的Class对象是同一个吗是。Class对象在堆中只有一个不管是哪个途径拿到的比较都是true。2.2 字段、方法、构造器的反射操作实战反射的日常操作集中在三类字段Field、方法Method、构造器Constructor。写一个完整例子说明它们的核心用法import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; public class Person { private String name; private Integer age; private Person(String name) { this.name name; } private void sayHello(String who) { System.out.println(name 对 who 说你好); } public static void main(String[] args) throws Exception { Class? clazz Person.class; // 1. 获取私有构造器并调用 Constructor? constructor clazz.getDeclaredConstructor(String.class); constructor.setAccessible(true); Object person constructor.newInstance(张三); // 2. 获取私有字段并赋值 Field ageField clazz.getDeclaredField(age); ageField.setAccessible(true); ageField.set(person, 18); // 3. 获取私有方法并调用 Method method clazz.getDeclaredMethod(sayHello, String.class); method.setAccessible(true); method.invoke(person, 李四); } }这里有几个细节必须强调都是面试和实战喜欢考的getFields()只返回public的字段包括继承来的getDeclaredFields()返回当前类自己声明的所有字段不管修饰符是什么但不包括继承的。方法同理getMethods()返回public方法含继承getDeclaredMethods()返回当前类的所有方法。Java 9之后setAccessible(true)的行为和模块化有关系强封装模块可能抛InaccessibleObjectException这一点在给Java 17项目做基于反射的工具时经常踩。2.3 反射性能问题真的有那么慢吗说反射慢慢在哪要能讲清楚。第一反射调用在运行时需要动态解析比如Method.invoke要做参数类型匹配、方法可见性检查第二基本类型参数要装箱成Object多一层开销第三JIT的很多优化比如方法内联对反射调用点很难生效。我的实测经验是反射调用比直接调用慢一个数量级是常态但远没到不能用的地步。真正落地时有三板斧缓存反射对象。Class.getDeclaredField、getMethod本身代价高拿到之后缓存到Map里反复用。Spring的ReflectionUtils内部就是这么干的。能跳过权限检查就跳过。setAccessible(true)之后JDK可以跳过语言访问检查性能有明显提升。高频场景换机制。如果是方法级别且调用频率极高考虑MethodHandle或者直接生成字节码。CGLIB、ASM、ByteBuddy这些字节码增强库本质上是把动态逻辑编译成新的类而不是每次都走反射解析所以性能远好于反射。用一张表总结不同方式的应用场景方案性能使用成本典型场景直接调用最高低正常业务代码反射缓存setAccessible中等中框架初始化、注解处理、字段映射MethodHandle较高较高动态语言风格调用、序列化框架字节码生成高高Spring AOP、MyBatis Mapper、Mock工具3. 注解的本质与生命周期为什么你的注解会不生效3.1 RetentionPolicy三兄弟决定注解的去留很多人自定义注解不生效90%是栽在保留策略上。Retention就三个值但它们的差别是决定性的保留策略源码期字节码期运行期典型用途能否被运行时反射读取SOURCE存在丢弃无Lombok、编译器代码生成不能CLASS存在存在无部分字节码增强框架不能RUNTIME存在存在存在Spring等框架、自定义业务注解能我一直推荐一个排查思路如果你的注解定义里没有Retention(RetentionPolicy.RUNTIME)先去补上再谈别的。Lombok的Getter是SOURCE因为它根本不需要活到运行期javac在编译时看一遍注解、把代码生成完就扔掉了但Spring的Component、Autowired是RUNTIME因为容器要在运行期找到它们。有个地方容易混淆Deprecated是RUNTIMEOverride是SOURCE。原因是Java编译器只在编译期检查Override不需要它出现在字节码里而Deprecated虽然源码期和字节码期都有标记但部分工具和运行时确实需要通过反射或字节码读取它来做不同处理。3.2 Target、Inherited这些元注解的隐藏问题Target指定注解能贴在哪里写错位置编译直接报错。一个容易忽略的点Spring框架里经常看到Target同时包含TYPE和METHOD说明这个注解要么用在类上、要么用在方法上但不会同时生效。比如Transactional贴在类上表示类内所有public方法都生效贴在方法上表示只针对当前方法覆盖类级别的配置。Inherited则是一个比较坑的元注解。它的含义是当一个类标了带Inherited的注解它的子类会被认为也标了这个注解。但有两个限制第一只对类上的注解生效对方法、字段上的注解无效第二如果子类自己标了同一个注解父类注解就不会再被扫描到。很多人在写抽象父类加注解、子类继承时踩坑就是没搞清楚这两个限制。3.3 注解属性为什么长得这么受限定义注解时属性类型只能用8种基本类型、String、Class、枚举、注解以及这些类型的数组。不能用Object不能用包装类型之外的任意对象。原因也不难理解注解的属性值在编译期就必须确定它要能写入字节码的常量池而只有上述类型能保证这一点。如果需要给注解传一个比较复杂的对象常规做法是传Class?让读取端通过反射去实例化或者传一个枚举值再在处理器里根据枚举做策略分发。4. 实战手写一个200行左右的ORM映射器4.1 定需求不引入任何依赖纯JDBC加注解加反射市面上各种ORM框架的核心能力之一就是把实体类和数据库表对应起来。我们要写的就是一个极简映射器做到两件事根据实体类上的注解解析出表名和字段映射根据实体对象动态拼接出INSERT语句。为什么不直接用SELECT *然后手动映射因为一旦表结构变化、字段多了之后手写映射的维护成本会爆炸。用注解描述映射关系用反射读取注解正好把前面讲的东西全部串起来。4.2 定义注解Table、Columnimport 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 Table { String name(); }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.FIELD) public interface Column { String name(); boolean id() default false; }注意这里我把主键标识直接放进Column的id属性里让代码更紧凑。实际框架一般会单独定义Id但语义是一样的。4.3 解析实体类从Class对象中读取映射关系实体类Table(name t_user) public class User { Column(name id, id true) private Long id; Column(name username) private String username; Column(name age) private Integer age; }核心的元数据解析器import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class EntityMapper { private static final MapClass?, EntityMeta CACHE new ConcurrentHashMap(); public static EntityMeta resolve(Class? clazz) { return CACHE.computeIfAbsent(clazz, c - { Table table c.getAnnotation(Table.class); if (table null) { throw new IllegalStateException(缺少 Table 注解: c.getName()); } EntityMeta meta new EntityMeta(table.name()); for (Field field : c.getDeclaredFields()) { Column column field.getAnnotation(Column.class); if (column ! null) { field.setAccessible(true); meta.addMapping(field.getName(), column.name(), column.id()); } } return meta; }); } public static T String buildInsertSql(ClassT clazz, T instance) throws Exception { EntityMeta meta resolve(clazz); StringBuilder columns new StringBuilder(); StringBuilder values new StringBuilder(); for (String fieldName : meta.getFieldColumnMap().keySet()) { Field field clazz.getDeclaredField(fieldName); field.setAccessible(true); Object value field.get(instance); if (value null) { continue; } if (columns.length() 0) { columns.append(, ); values.append(, ); } columns.append(meta.getColumnName(fieldName)); values.append().append(value).append(); } return INSERT INTO meta.getTableName() ( columns ) VALUES ( values ); } }元数据实体import java.util.HashMap; import java.util.Map; public class EntityMeta { private final String tableName; private String idField; private final MapString, String fieldColumnMap new HashMap(); private final MapString, String columnFieldMap new HashMap(); public EntityMeta(String tableName) { this.tableName tableName; } public void addMapping(String fieldName, String columnName, boolean id) { fieldColumnMap.put(fieldName, columnName); columnFieldMap.put(columnName, fieldName); if (id) { this.idField fieldName; } } public String getTableName() { return tableName; } public String getIdField() { return idField; } public String getColumnName(String fieldName) { return fieldColumnMap.get(fieldName); } public MapString, String getFieldColumnMap() { return fieldColumnMap; } }测试一下public class OrmDemo { public static void main(String[] args) throws Exception { User user new User(); user.setUsername(zhangsan); user.setAge(18); String sql EntityMapper.buildInsertSql(User.class, user); System.out.println(sql); // 输出: INSERT INTO t_user (username, age) VALUES (zhangsan, 18) } }注意两点这是实际生产必须考虑的第一这里为了演示直接拼接字符串会引入SQL注入风险真实项目务必改成PreparedStatement按参数传递第二User类要提供无参构造器和getter/setter反射在newInstance和field.get/set时都依赖它们。4.4 为什么框架要缓存反射元数据看上面的代码resolve方法里用了ConcurrentHashMap做缓存。为什么必须缓存因为反射解析字段、方法的过程成本很高如果每次执行SQL都扫一遍getDeclaredFields大数据量场景下性能损耗会非常明显。MyBatis、Hibernate这些框架内部也是同样的思路启动时把Mapper接口、实体类的映射关系解析好放进内存里的一个Map后续查询直接走缓存。你可以搜一下MapperRegistry这个类看看本质就是反射解析一次缓存反复用。这是一个值得借鉴的设计习惯不只是ORM框架任何用到反射的工具类都应该优先考虑缓存。5. 从注解和反射延伸出去的高频工程场景5.1 Spring家族的注解机制连成一条线看Spring是注解和反射的最佳练兵场。Autowired字段注入的底层逻辑是AutowiredAnnotationBeanPostProcessor类在容器创建Bean时通过getDeclaredFields扫出所有带Autowired的字段拿到字段类型后从容器找到对应的Bean用反射直接复制给字段。整个过程不需要任何getter/setter。Transactional的逻辑则走了另一条路线——AOP代理。它先用注解定义事务边界然后Spring判断目标类是否需要代理对象如果方法没有走代理而是被同类内部直接调用事务就不会生效。这就是为什么面试官总爱问同类内部调用Transactional为什么不生效——本质是注解只是声明真正干活的是代理机制。ConditionalOnMissingBean这类条件注解也很有代表性。它是Spring Boot自动配置的核心逻辑本质上就是在加载配置类时通过反射读取注解上的条件信息再结合当前容器已有的Bean定义做判断决定这个配置类要不要生效。理解之后你会发现很多Spring Boot的魔法底层都是注解反射一个处理器的组合。5.2 CORS配置里反射origin到底是怎么回事热词里有一条cors 配置错误(反射 origin credentialstrue)这里的反射origin和Java反射机制虽然不完全是一个概念但思路高度一致——把请求头里的Origin原样反射回显到响应头里。直接写一段完整的配置代码这是正确的处理方式import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; import java.util.Collections; Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); // 允许任意来源但配合凭证时必须用 Pattern而不能用 allowedOrigins(*) config.setAllowedOriginPatterns(Collections.singletonList(*)); config.setAllowCredentials(true); config.addAllowedMethod(*); config.addAllowedHeader(*); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }这里的核心知识点是浏览器规范当Access-Control-Allow-Credentials: true时服务端不允许返回Access-Control-Allow-Origin: *必须返回具体的来源地址。如果配置里写成allowedOrigins(*)加allowCredentials(true)浏览器会直接拦截这个响应。解决方案是上面代码里的setAllowedOriginPatterns。它的行为就是反射origin——读取请求带来的Origin头校验通过后原样回写从而既保留通配能力又满足凭证请求的规范要求。这个坑在前后端分离项目里出现频率极高值得记下来。5.3 Lombok编译期注解处理的代表热词里还有一条java: you arent using a compiler supported by lombok这是Lombok和JDK版本不匹配时的报错。Lombok的本质是一个编译期注解处理器通过javac内部API在语法树上追加代码。它的注解保留策略是SOURCE编译完就不存在了所以运行时反射是找不到Getter这些注解的。这就引出一个容易混淆的问题既然Lombok的注解运行期拿不到那IDE里怎么会有点击跳转的setter/getter方法因为IDEA也内置了Lombok插件它在编辑阶段模拟了编译器将要去做的代码生成。接下来是热词里的EqualsAndHashCode。这个注解解决的是equals和hashCode方法生成问题但它的文档里藏着一个坑默认callSuper false。如果实体类继承了父类且父类也有业务字段那么生成的equals只会比较子类字段父类字段哪怕不同两个对象也可能判定为相等。有继承关系的场景记得显式写callSuper true。关于IDEA里快速生成方法注解那属于开发工具的Live Template配置本质是在IDE层写一套模板跟Java的注解机制不是一回事但配合使用时能明显提升注释效率。我自己的做法是配置一个方法模板自动插入带param和return的JavaDoc再用IDEA的固定后缀补全快速生成测试注解代码规范度会高不少。5.4 Transactional不生效的常见原因汇总结合事务注解这个热词把最常见的几个失效场景列出来绝大多数人都至少踩过其中一个同类内部调用this.methodB()走的是当前对象不是Spring代理对象所以methodB上的事务配置被忽略。解决方法是拆成两个Bean互相调用或者使用AopContext.currentProxy()。异常被吞方法内try-catch把异常吃掉事务管理器根本不知道出错自然不会回滚。至少要让异常抛出到代理层。受检异常不触发回滚Spring默认只对RuntimeException和Error回滚受检异常需要显式指定rollbackFor Exception.class。方法或类非publicSpring的AOP代理默认只拦截public方法。private方法上的Transactional形同虚设。这里再一次印证了前面说的核心观点注解本身只是一个标记是代理机制在读取标记并做处理。注解不背锅不生效是机制没走到。6. 面试考点拆解反射与注解的答题思路6.1 原理类问题反射是怎么知道一个类有哪些方法的这类问题的答题框架建议按五步走每一步都能再展开类加载阶段JVM读入.class字节码在堆中生成唯一的Class对象。类的所有结构信息被解析为元数据存放在方法区中。元数据保存Class对象内部持有字段表、方法表、构造器表、注解表等结构。反射APIgetDeclaredFields、getDeclaredMethods这些方法的本质就是读取元数据表并用Field、Method对象包装起来。访问控制setAccessible本质是尝试抑制语言层面的访问检查能否成功取决于模块化限制和安全策略。调用链路Method.invoke最终会把调用转发到真正的目标方法这个过程涉及参数衔接和返回值的包装。按照这个框架回答面试官基本没有继续追深的必要了因为每层都覆盖到了。6.2 应用类问题项目里怎么用注解和反射解决实际问题随手举几个我在真实项目里做过的方案一是接口入参校验。自定义NotBlank注解标记在DTO字段上在Controller入口或过滤器里用一个通用组件反射遍历字段发现空值就直接返回参数错误。相比javax.validation业务自定义注解可以附加更复杂的策略比如关联字段校验、角色差异校验。二是操作日志。自定义OpLog(module user, action update)用Spring AOP拦截方法反射读取注解值拼日志内容再结合方法参数生成详细的变更前后快照。全程无侵入服务类代码干干净净。三是通用字典翻译。在返回实体字段上标Dict(type province)序列化时框架通过反射拿到字典编码替换成字典名称前端不需要再做一次字段映射。答题的时候说清楚三件事就可以了你定义了什么样的注解、注解用反射被谁读取、这个方案解决了一个什么实际问题。6.3 进阶类问题泛型擦除后反射如何拿到真正的类型这是一个含金量更高的拓展点。Java的泛型是编译期擦除的运行时ListString和ListInteger的Class对象完全一样。但有一个例外字段、方法返回值和参数上的泛型信息会以Signature属性的形式保留在字节码里反射可以通过getGenericType系列方法把它取回来。用一个例子演示import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.List; public class GenericDemo { public ListString getNames() { return List.of(a, b); } public static void main(String[] args) throws Exception { Method method GenericDemo.class.getMethod(getNames); Type genericReturnType method.getGenericReturnType(); if (genericReturnType instanceof ParameterizedType) { ParameterizedType pt (ParameterizedType) genericReturnType; Type actualType pt.getActualTypeArguments()[0]; System.out.println(actualType); // 输出 class java.lang.String } } }Gson、Jackson这些JSON库就是靠这个能力实现TypeTokenT反序列化的。你在写通用泛型工具的时候这个技巧几乎是必用项。最后说一点个人感受。反射和注解真正值钱的地方不在于你背了多少个API而在于你愿意花时间去读框架源码。找到一段Autowired的解析链路跟着AutowiredAnnotationBeanPostProcessor走一遍再走一遍postProcessBeforeInitialization你会发现之前困扰你很久的很多为什么全都在那几十行代码里。考试可以有标准答案但工程里的高手从来都是顺着链路读源代码读出来的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价