
1. 注解不是魔法先弄懂它到底能替你解决什么问题如果你在Java里待过一段时间肯定见过Override、Deprecated、Autowired这一堆带符号的东西。很多初学者第一次看到注解会觉得它像某种“魔法”明明只是加了一个符号方法就自动有事务了字段就能被自动注入了接口就成了Controller了。但真相是注解本身只是一个携带元数据的标记真正干活的从来不是注解而是背后读取它的代码。搞懂这一点你才算真正入了注解的门。我从实际项目里得到的体会是注解解决的问题有两个层面。第一层是让业务规则靠近代码比如一个字段该不该为空、一个方法有没有权限访问直接在声明处就能看出来不用翻一堆XML配置。第二层是让通用逻辑从重复代码里抽出来比如写日志、校验参数、控制事务不用在每个方法里手写一遍声明一个注解就行。这篇内容适合刚接触Java注解、想看懂Spring/MyBatis注解原理、以及准备Java面试时被问到“注解到底怎么生效”的人。1.1 注解和XML配置的取舍为什么你该用注解早些年做Java Web配置几乎全是XML。spring.xml里配数据源、配Bean、配事务拦截器web.xml里配Servlet和过滤器。配置多了以后问题很明显业务代码和配置分隔得太远。一个UserService到底注入到哪个Controller你得去翻配置文件一个方法是不是需要开启事务你也得去配置文件里找匹配规则。代码和规则分离带来了灵活性但也极大地增加了阅读成本。注解出现以后取舍变成了“代码即配置”。比如一个类加上ServiceSpring扫到它就把它当业务Bean一个方法加上TransactionalSpring就用代理给它包上事务。这样做的好处是声明和行为放在同一个位置几乎没有跨文件追踪的成本。坏处也很直接配置和代码耦合得更紧了想改行为得改代码重新编译。取舍原则我一般是这样把握的固定归属某段代码的规则用注解经常要全局调整、可能由运维改的规则用外部配置。比如数据库连接地址放配置文件而事务边界、角色权限用注解基本不会出大问题。有人会问注解不是也需要代码去解析吗为什么比XML更“轻”因为注解的解析逻辑通常由框架固定实现你只是通过声明来告诉框架“在这个点上执行固定逻辑”而XML配置更像一套DSL解析逻辑复杂、写错也不容易在编译期发现。Java注解能做到编译期检查比如Override有编译器盯着比XML字符串配置要安全得多。1.2 从 Override 说起一个最简单的注解是怎么生效的很多Java教程上来就讲自定义注解和反射其实完全没必要先掌握那些复杂的。看Override就够理解注解的基本运行模式了。class Parent { public void say() { System.out.println(parent); } } class Child extends Parent { Override public void say() { System.out.println(child); } }Override是JDK自带的一个注解它的作用不是改变方法行为而是告诉编译器“这个方法应该是重写父类方法的”。如果父类没有say()方法编译器会直接报错方法不会覆盖或实现超类型的方法。这个机制背后是Java编译器在编译阶段扫描注解并做校验属于SOURCE保留策略的典型例子——只在源码里起作用编译完就无所谓了。把这个例子放大看你能得到一个通用结论注解声明是契约处理器是契约的执行者。Override的处理器是编译器Transactional的处理器是Spring的声明式事务管理器NotNull的处理器是Bean Validation框架。随后你写自定义注解本质上就是“写契约 写处理器”两件事。2. 拆解JDK的注解基础内置注解和四个元注解想自定义注解必须先熟悉两个概念一个是JDK已经内置好的、你天天都在用的那些注解另一个是“注解的注解”也就是元注解元注解决定了你自己写的注解能标在哪里、能活多久。2.1 内置注解常见用法如果让面试官说Java注解大概率会从Override、Deprecated、SuppressWarnings、FunctionalInterface这几个入手。它们的共同特点是由编译器或虚拟机负责处理不需要你写解析逻辑。Override限制方法必须是重写父类或实现接口的方法防止方法签名写错导致静默变成新方法。Deprecated标记声明已过时在使用处会编译警告。实际项目里要废弃公共API时加上这个注解并写明since和forRemoval更规范。SuppressWarnings压制编译警告比如 unchecked、rawtypes。用它的时候要注意“最小化范围”别把整个类的警告都压掉。FunctionalInterface标记接口是函数式接口即只有一个抽象方法。常用于Lambda表达式场景编译器会检查是否符合约束。这些内置注解最大的意义是让你建立“注解自带约束”的意识。比如给一个普通方法加Override会编译报错这既是约束也是在开发早期就把错误暴露出来而不是等到运行期再踩坑。2.2 元注解控制注解行为Retention、Target、Documented、Inherited自定义注解的“地基”是这四个元注解Retention决定注解保留到哪个阶段。Target决定注解能标注在什么元素上比如类、方法、字段、参数。Documented标注后生成的Javadoc里会显示这个注解。Inherited标注后子类会继承父类上的注解但只能继承类上的注解不能继承方法或字段上的注解。Retention有三个值这是Java面试特别爱考的点我整理成一个表保留策略源码期class文件运行期反射典型应用RetentionPolicy.SOURCE有无不可见Override、SuppressWarnings纯编译器辅助RetentionPolicy.CLASS有有不可见字节码增强场景工具在编译期/类加载期读取RetentionPolicy.RUNTIME有有可见Transactional、Autowired、自定义校验注解看到这个表你就能理解热搜词里那句“class文件 override注解为什么会丢失”。答案很简单因为Override是SOURCE保留策略它的使命在编译阶段就结束了编译器校验完就直接把注解信息丢掉class文件里自然看不到。好多人反编译class文件找不到Override以为代码出了问题其实是保留策略的正常表现。如果你希望某个注解在运行期还能通过反射读取必须显式标成RUNTIME。2.3 给注解传参数注解为什么能像方法一样定义属性自定义注解里的属性并不是类里的字段而是类似于抽象方法的声明。比如public interface MyAnnotation { String value(); int count() default 1; }这里value()和count()看着像方法实际上是注解的元素。使用的时候MyAnnotation(value hello) public void test() {}如果注解只有一个名为value的元素可以省略value 直接写MyAnnotation(hello)。这是注解语法里最常见的简写方式。default可以让元素有默认值使用方不传也不会报错。有一点要提醒注解元素的类型必须是基本类型、String、Class、枚举、另一个注解或者以上类型的数组不能是普通对象。所以设计注解时要考虑清楚参数用枚举还是字符串。比如定义权限注解用枚举比用字符串更严谨能减少拼写错误public interface RequiredPermission { Permission value(); }3. 动手写一个自定义注解一个字段校验器的完整诞生过程纸上谈兵没意思下面带你从零写一个“非空校验注解”然后自己写解析逻辑把它跑起来。这个例子不依赖Spring你复制到任意Java项目里都能运行。3.1 定义自己的 NotEmpty 注解业务场景是用户注册对象里用户名和密码不能为空。以前的做法是在业务代码里写一堆if (user.getUsername() null || user.getUsername().isEmpty())如果字段多每个方法都得写一遍。用注解加反射校验器就能把这类规则从业务逻辑里抽出来。先定义注解放在一个annotation包下package com.example.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface NotEmpty { String message() default 字段不能为空; }说明一下设计意图。Target(ElementType.FIELD)限制它只能标字段防止有人随手标到方法上造成误用Retention(RetentionPolicy.RUNTIME)保证运行期反射能拿到message作为提示信息默认值方便使用方不传也能得到一个合理的报错提示。3.2 手写反射解析器把注解变成业务规则定义完注解后只完成了一半还必须写一个处理注解的类。这个类负责扫描对象的字段、读取NotEmpty、判断值是否为空。package com.example.util; import com.example.annotation.NotEmpty; import java.lang.reflect.Field; public class ValidationUtils { public static void validate(Object obj) throws IllegalAccessException { if (obj null) { throw new IllegalArgumentException(待校验对象不能为空); } Class? clazz obj.getClass(); Field[] fields clazz.getDeclaredFields(); for (Field field : fields) { NotEmpty annotation field.getAnnotation(NotEmpty.class); if (annotation ! null) { field.setAccessible(true); Object value field.get(obj); if (value null || value.toString().trim().isEmpty()) { throw new IllegalArgumentException(annotation.message()); } } } } }这里有几个实务细节值得展开。第一getDeclaredFields()拿到的是类自己声明的字段包括private字段getFields()只能拿public字段容易漏。第二field.setAccessible(true)是反射访问私有字段必须的步骤虽然JDK高版本对模块化限制更严格但普通项目里这套写法依然通用。第三校验逻辑用的是“有就校验、没有就跳过”所以加了注解的字段才受规则约束没有注解的字段完全不受影响。然后写一个实体类和测试入口package com.example.entity; import com.example.annotation.NotEmpty; public class User { NotEmpty(message 用户名不能为空) private String username; NotEmpty(message 密码不能为空) private String password; // getter和setter省略 }package com.example; import com.example.entity.User; import com.example.util.ValidationUtils; public class Main { public static void main(String[] args) throws IllegalAccessException { User user new User(); user.setPassword(123456); // username 没设置值触发校验失败 ValidationUtils.validate(user); } }跑一下就知道程序会在username为空时抛出IllegalArgumentException: 用户名不能为空。核心逻辑简单但完整体现了“注解 元数据 处理器”这一模型。你用同样的思路还可以设计长度校验注解、邮箱格式注解、枚举值校验注解本质没有区别。3.3 为什么先讲“运行时反射”方案而不是“编译期APT”有不少人一上来就去啃注解处理器结果被AbstractProcessor、RoundEnvironment、Elements这些API绕晕了。我的建议是先掌握运行时反射方案因为它的链路短、直观、容易调试。你可以在IDE里断点直接看到字段上的注解对象。运行时方案适合校验类、日志类、权限类的通用逻辑缺点是每次调用都涉及反射性能有一定损耗但在绝大多数业务场景下完全够用。而编译期注解处理器APT更高级适合做代码生成类的工作比如Lombok的Getter、Setter。它能在编译阶段扫描注解然后生成新的源码或class文件运行时没有反射开销。等到你能熟练处理反射运行时方案以后再进入下一节说的编译期技术会更有底气。4. 进入编译期注解处理器和代码生成技术反射不是注解的全部。很多框架的高性能设计是让注解在编译期就完成“任务”而不是等到运行期再用反射去碰。这一节说说编译期注解这个概念以及class文件里注解为什么会出现“丢”的情况。4.1 编译期注解与运行时注解的分水岭回到保留策略那张表SOURCE只在源码里有效CLASS会写入class文件但运行期拿不到RUNTIME会保留到运行期。当你看到“class文件里没有某注解”时先别慌去看它的Retention是哪种。招聘时我常给Java候选人出一道题把一个类反编译出来的字节码里没有Override正常吗很多人认为不正常。实际上正常得很Override是SOURCE级别注解编译器校验完就擦掉了。所以“注解为什么会丢失”这种说法本身是有问题的准确地说应该是“注解的生命周期本来就不包括运行期”。设计自己的注解时先想清楚这个注解需要被谁使用如果只在编写代码时作为标记比如一个提示性的注解用SOURCE。如果要在运行期通过反射读取用RUNTIME。如果要做字节码增强用CLASS比如某些框架在加载类时通过ASM扫描注解。4.2 用 AbstractProcessor 写一个编译期处理器编译期处理器在面试里是一个加分项。它的思路是自定义注解标在源码上Javac编译器编译时会扫描到然后触发你的处理器生成额外代码。下面是一个生成getter方法的极简处理器骨架package com.example.processor; import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.TypeElement; import java.util.Set; SupportedAnnotationTypes(com.example.annotation.GenerateGetter) SupportedSourceVersion(SourceVersion.RELEASE_8) public class GetterProcessor extends AbstractProcessor { Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { // 遍历标了 GenerateGetter 的类生成 getter 方法 return true; } }完整生成代码需要用到JavaFileObject接JavaPoet之类的工具核心过程和上面的骨架一致。注意process方法里要尽量只处理自己关心的注解避免和别处理器冲突。编译期处理器会把额外生成的代码在编译阶段合并进去所以运行期是零反射开销。4.3 框架是怎么靠编译期注解做代码增强的Lombok、MapStruct 等用Lombok的时候Getter和Setter理论上可以标在class上而且编译完的class文件里能看到生成的方法但源码里并没有。这就是编译期注解处理器干的活。Lombok把注解标成SOURCE再在javac编译阶段通过内部API直接修改语法树让你觉得“这个类自动有了getter/setter”。MapStruct也是一个典型。你写一个Mapper接口方法上用Mapping标注字段映射关系编译期处理器会生成实现类把字段拷贝逻辑固化进去。运行期直接调用生成的实现类就行比反射高效。这种模式很适合对性能敏感、又不想手写大量模板代码的场景。了解这些框架的底层原理以后再遇到“这个注解怎么起作用的”这类问题你基本都能从“保留策略 处理器时机”两个维度去回答比死记硬背强太多。4.4 class文件里注解会不会丢失为什么面试题总考它顺着前面的话题这里把“class文件注解丢失”总结成一套排查逻辑先查Retention如果是SOURCE源码里有但class文件里没有。如果是CLASSclass文件里有但运行时反射看不到。如果是RUNTIMEclass文件和运行时反射都能看到。另外还有一种情况Spring AOP基于动态代理为某个类生成代理子类时代理类不会继承方法上的注解注解或者说代理对象的方法注解看起来“丢了”。这在后面讲Spring事务时会更明显。遇到这种问题时不要简单以为“注解丢失了”而是要想“目标对象和代理对象不是同一个对象注解自然不在代理方法上”。5. 框架注解实战Spring Boot和MyBatis里那些让你又爱又恨的注解到了这一节才是很多Java开发最关心的Spring Boot、SSM项目里那些常用注解到底做了什么。我按实际使用频率从高到低挑几个逐个拆原理。5.1 SSM时代常用注解大盘点先从整体上看SSM常用注解可以分成几类组件注册类Controller、Service、Repository、Component依赖注入类Autowired、Resource、Qualifier请求映射类RequestMapping、GetMapping、PostMapping、RequestParam持久层类Mapper、Param、Select、Insert事务类Transactional它们共同的工作模式是Spring容器启动时扫描指定包路径下的类用ASM或反射读取类上的注解然后把类注册成Bean再根据Autowired等注入注解去建立依赖关系。Mapper则是MyBatis框架扫描后为接口生成动态代理实现Select里的SQL语句会被代理逻辑绑定到特定方法上。明白这个你基本就懂了SSM注解的核心。Spring Boot里还有一些组合注解比如RestController就是Controller加ResponseBody的组合目的是简化配置。组合注解在Java里是靠元注解实现的所以看源码时你会看到RestController上标着Controller。5.2 Transactional 事务注解为什么会失效事务注解是面试高频题也是线上问题重灾区。Transactional底层是基于Spring AOP代理实现的。Spring拿到一个被Transactional标注的方法后会生成一个代理对象在代理对象里开启事务、执行目标方法、提交或回滚。核心问题是你调用的到底是被代理的Bean还是原始对象。最常见的失效场景是同类内部调用Service public class OrderService { Transactional(rollbackFor Exception.class) public void createOrder() { // 事务操作 } public void doSomething() { // 这里直接调用 this.createOrder() this.createOrder(); } }当外部调用doSomething()时Spring给你的注入对象是代理对象代理对象调用doSomething()再走到this.createOrder()这里的this已经变成原始目标对象事务注解形同虚设。解决办法是让两个方法代理链路断开比如把创建订单逻辑移到另一个Bean里或者用((OrderService) AopContext.currentProxy()).createOrder()这类方式显式走代理。第二个坑是回滚配置。Transactional默认只有遇到运行时异常才回滚checked异常不会触发回滚。很多时候业务抛出的自定义异常如果继承Exception而不是RuntimeException事务是不会自动回滚的。这时你要写成Transactional(rollbackFor Exception.class)。我在实际项目中几乎都建议显式加这个属性而不是依赖默认值。第三个坑是事务方法不能被private、static修饰因为Spring AOP无法代理这类方法。你会发现注解标了方法执行了但事务根本没生效排查时非常容易忽略。5.3 Constraint 注解自己写Bean Validation校验注解Bean Validation框架比如spring-boot-starter-validation里最常见的是NotNull、Size、Pattern。如果你有一个“手机号格式”校验不想每次都写正则就可以自定义一个校验注解核心就是Constraint元注解。package com.example.annotation; import com.example.validator.PhoneValidator; import javax.validation.Constraint; import javax.validation.Payload; import java.lang.annotation.*; Documented Constraint(validatedBy PhoneValidator.class) Target({ElementType.FIELD, ElementType.PARAMETER}) Retention(RetentionPolicy.RUNTIME) public interface Phone { String message() default 手机号格式不正确; Class?[] groups() default {}; Class? extends Payload[] payload() default {}; }然后创建一个PhoneValidator实现ConstraintValidatorPhone, Stringpackage com.example.validator; import com.example.annotation.Phone; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; public class PhoneValidator implements ConstraintValidatorPhone, String { Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value null) { return true; } return value.matches(^1[3-9]\\d{9}$); } }使用时只需要在字段上加PhoneSpring Boot会自动触发校验。这个例子的关键在于框架帮你扫注解、调校验器你负责定义规则和注解定义。这与我们在第三节手写的校验器逻辑完全一致只是把处理器交给了标准框架去执行。5.4 log注解和行级权限注解设计思路热搜词里有一堆关于“log注解”和“行级权限Java”的搜索这其实是比较进阶的自定义注解玩法。简单说你可以用自定义注解 Spring AOP做一个统一操作日志切面。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OperationLog { String module() default ; String action() default ; }然后在Aspect切面里通过MethodInvocation获取目标方法的OperationLog注解读取module和action记录操作人、参数、耗时到日志表。同理可以做行级权限定义RequiresDataScope注解切面里根据当前用户的数据权限过滤查询条件。这样做的好处是业务方法里完全不出现权限判断代码逻辑很干净。不过要记得AOP注解只有在通过Spring代理调用时才有效类内部自调用同样会失效这个问题和事务注解一模一样。6. 踩坑记录和一条实战主线文章最后这部分我不打算再做知识堆叠而是把设计和排查注解相关问题时容易踩的坑集中说出来再给一条能落地的实战推进路线。6.1 反射读取注解常见的坑第一个坑getAnnotation和getDeclaredAnnotation混用。对类使用getAnnotation时Inherited能让子类“继承”到父类类上的注解但对接口、方法、字段无效。所以很多人在自定义注解上加了Inherited却发现实现类的方法上还是没有注解就是因为用错了场景。第二个坑Spring的动态代理导致注解反射不到。比如Controller方法上的自定义注解如果Controller被CGLIB代理你从代理类的方法上拿注解可能拿不到。解决思路是用AopUtils.getTargetClass获取目标类再反射或者用Spring提供的AnnotationUtils.findAnnotation工具方法。这个工具方法比手动反射更稳遇到代理时会做额外处理。第三个坑field.setAccessible(true)在Java 17等模块化版本下如果模块没有opens包可能抛InaccessibleObjectException。运行时反射方案要提前确认目标类所在模块是否开放反射权限。6.2 自定义注解的命名与可维护性设计写自定义注解时别只顾着功能实现命名和参数设计同样重要。我给自己定了几个小规则注解名用名词或形容词比如NotEmpty、OperationLog不要让开发者猜它是动作还是状态。所有非必须参数都提供默认值避免使用方被迫写一堆无意义参数。能用枚举的地方不用字符串枚举能让IDE给你提示减少魔法值。一个注解只负责一件事。想同时做日志和权限就拆成两个注解要让AOP切面各司其职。另外注解参数是编译期常量别把运行时的动态值塞进去。举一个错误例子MyAnnotation(value System.currentTimeMillis()) // 编译不通过6.3 一条可以让你直接上手的实践路线如果你刚开始接触Java注解我建议按这条主线循序渐进先写一个RUNTIME的自定义注解并用反射完成解析比如自己做字段校验器就是第三节的例子。再看Spring AOP把自定义注解和切面结合做一个方法级别的登录校验或日志注解。尝试迁移到SOURCE 注解处理器写一个小工具比如自动生成Builder、自动生成Mapper实现。最后去读Spring或者MyBatis源码看Transactional和Mapper链接到底是怎么实现的。等你自己动手做完一遍再看面试题“注解和反射的关系”“注解为什么不会影响方法逻辑”基本就不会犯迷糊了。至于我自己踩过最深的坑其实是把Transactional直接标在一个由private方法调用的另一个方法上功能上线后出现数据不一致查了两小时才意识到是代理失效。那次以后我养成了判断“当前调用链到底经过代理没有”的习惯也会提醒团队成员注解只是标签处理逻辑才是灵魂别让“这注解标了为啥没用”成为上线后的噩梦。