Java 注解的底层原理——完整讲解一、从一个问题开始注解到底是什么东西你在写 Java 代码的时候一定见过这样的写法OverridepublicStringtoString(){returnhello;}或者在 Spring 框架里见过ControllerpublicclassUserController{...}你有没有想过一个问题——这些开头的东西到底是什么它不是注释comment因为注释是给人看的、会被编译器忽略。但注解annotation显然能影响程序的行为。那它的本质到底是什么答案是注解的底层本质是一个继承了java.lang.annotation.Annotation接口的特殊接口。这句话信息量很大我们需要逐层拆解才能真正理解它。接下来我会从三个层面来讲清楚定义层面你写interface的时候编译器背后做了什么运行时获取层面你通过反射拿到注解实例时JVM 背后做了什么前提条件为什么有些注解运行时拿不到最后我们把三者串起来形成一个完整的理解。二、定义层面interface背后的真相2.1 你平时怎么定义一个注解假设你要定义一个自己的注解叫MyAnnotation你会这样写publicinterfaceMyAnnotation{Stringvalue();intcount()default0;}然后你可以这样使用它MyAnnotation(valuehello,count3)publicclassMyClass{...}这段代码你一定不陌生。但现在问题来了interface这个关键字是什么意思String value()和int count()看起来像方法声明但它们到底是什么为什么调用注解属性的时候用的是value hello这种赋值语法而不是调用方法的语法2.2 编译器的秘密操作当你写下interface MyAnnotation并编译之后编译器会自动帮你做一件事情它会把你写的这个注解转换成一个接口并且让这个接口自动继承java.lang.annotation.Annotation。也就是说上面那段注解定义代码在编译之后其实等价于下面这个东西publicinterfaceMyAnnotationextendsjava.lang.annotation.Annotation{Stringvalue();intcount();}你看到了吗interface本质上就是interface。编译器只是用interface这个语法糖替你省去了extends Annotation这句话。这就是第一个关键结论注解本质上就是一个接口。2.3 注解中的属性本质上是接口的抽象方法再回头看注解里定义的String value()和int count()。在普通接口中这种没有方法体的声明叫做抽象方法。而注解作为一个接口它里面写的这些东西本质上也是抽象方法。但是在注解的使用场景中我们习惯把它们叫做注解的属性或成员。这纯粹是一种叫法上的习惯。从 Java 语法的底层来看它们就是接口中的抽象方法只不过方法的返回值类型就是属性的类型方法的名字就是属性的名字。所以当你写MyAnnotation(valuehello,count3)你表面上是在给属性赋值但从底层来理解你其实是在告诉 JVM当将来有人调用这个注解接口的value()方法时应该返回hello调用count()方法时应该返回3。至于default 0这种写法就是给抽象方法提供一个默认的返回值。如果使用注解时没有显式指定count的值那么调用count()方法时就返回默认值0。2.4 为什么要设计成接口你可能会问为什么 Java 要把注解设计成接口而不是设计成一个类原因在于接口只定义了有哪些方法而不定义方法怎么实现。注解在定义的时候只需要描述我有哪些属性至于这些属性的值是多少、怎么获取这些值那是运行时才需要关心的事情。接口的这种只定义契约不关心实现的特性恰好适合注解的需求。而具体的实现Java 会交给后面要讲的动态代理来完成。2.5java.lang.annotation.Annotation接口是什么所有注解都自动继承了java.lang.annotation.Annotation接口。那这个接口里有什么呢publicinterfaceAnnotation{booleanequals(Objectobj);inthashCode();StringtoString();Class?extendsAnnotationannotationType();}它定义了四个方法equals、hashCode、toString、annotationType。annotationType()返回注解的类型比如MyAnnotation.class。其余三个是 Java 中所有对象都有的基本方法。这意味着所有的注解都拥有这四个方法加上注解自身定义的属性方法。这构成了注解接口的完整方法列表。2.6 小结定义层面到这里关于注解在定义层面的原理我们可以完整总结了你用interface定义注解时编译器会自动让它继承java.lang.annotation.Annotation接口。注解中的每一个属性本质上就是接口中的一个抽象方法。注解本身就是一个接口它只描述了有哪些属性不包含任何实现。三、运行时获取层面反射和动态代理的协作现在我们知道了注解是一个接口。但问题来了接口是不能直接创建实例的那当你通过反射获取注解时拿到的那个注解对象是从哪来的这就涉及到注解原理中最核心的部分——动态代理。3.1 先看你怎么获取注解假设你有一个类MyClass上面标注了MyAnnotation(value hello, count 3)。在运行时你可以通过反射来获取这个注解Class?clazzMyClass.class;MyAnnotationannotationclazz.getAnnotation(MyAnnotation.class);// 然后你可以这样读取注解的属性值Stringvannotation.value();// 返回 hellointcannotation.count();// 返回 3看这段代码annotation是一个对象。你可以调用它的value()方法和count()方法来获取属性值。但我们刚才说了MyAnnotation是一个接口。接口是不能用new来创建对象的。那getAnnotation()返回的这个对象到底是什么答案它是 JVM 通过动态代理机制自动生成的一个代理对象。3.2 什么是动态代理为零基础铺垫如果你对动态代理这个概念不熟悉我先用最简单的方式解释一下。在 Java 中有一种机制你可以在运行时动态地创建一个实现了某个接口的对象而不需要事先写好这个接口的实现类。这就是动态代理。Java 提供了一个类叫java.lang.reflect.Proxy它有一个静态方法newProxyInstance()可以在运行时凭空创建一个对象。这个对象会实现你指定的接口。但仅仅创建一个对象还不够——接口中有抽象方法当你调用这个代理对象的方法时总得有个地方来定义这个方法应该返回什么。这个地方就是InvocationHandler调用处理器。InvocationHandler是一个接口它只有一个方法publicObjectinvoke(Objectproxy,Methodmethod,Object[]args)throwsThrowable;每当你调用代理对象的任何方法时JVM 都不会去找什么真正的实现而是统一转发到这个invoke()方法里。在invoke()方法中你可以自定义逻辑来决定返回什么值。打个简单的代码示意// 假设有一个接口publicinterfaceGreeting{StringsayHello();}// 用动态代理创建一个实现了 Greeting 接口的对象Greetingproxy(Greeting)Proxy.newProxyInstance(Greeting.class.getClassLoader(),newClass[]{Greeting.class},newInvocationHandler(){OverridepublicObjectinvoke(Objectproxy,Methodmethod,Object[]args){if(method.getName().equals(sayHello)){returnHello from proxy!;}returnnull;}});System.out.println(proxy.sayHello());// 输出: Hello from proxy!在这个例子中Greeting是一个接口我们没有写任何实现类。Proxy.newProxyInstance()在运行时创建了一个实现了Greeting接口的代理对象。当调用proxy.sayHello()时JVM 会把这个调用转发到InvocationHandler的invoke()方法里。在invoke()里我们判断被调用的方法名是sayHello就返回Hello from proxy!。理解了动态代理的基本原理后我们就可以理解注解在运行时的工作机制了。3.3 JVM 怎么用动态代理来创建注解实例现在把动态代理的知识套用到注解上来。我们知道了注解MyAnnotation本质上是一个接口。接口不能直接创建实例。但是动态代理可以在运行时创建一个实现了某接口的代理对象。所以当你调用clazz.getAnnotation(MyAnnotation.class)时JVM 的内部流程大致是这样的第一步检查类的字节码找到注解信息当你用MyAnnotation(value hello, count 3)标注一个类时编译器会把这些信息写入到这个类的.class文件中。具体来说注解的类型、属性名、属性值都会被记录在字节码文件的一个特殊区域中称为RuntimeVisibleAnnotations属性表。当 JVM 加载这个类的时候它会解析字节码读取其中的注解信息知道这个类上有一个MyAnnotation注解value的值是hellocount的值是3。第二步构建属性名到属性值的 MapJVM 会把读取到的注解信息组织成一个Map结构。对于我们这个例子这个 Map 大概是这样的{ value - hello, count - 3 }键key是注解的属性名也就是接口中方法的名字值value是使用注解时指定的属性值。第三步通过动态代理创建注解的代理对象JVM 使用Proxy.newProxyInstance()动态地创建一个对象这个对象实现了MyAnnotation接口。与此同时JVM 会创建一个InvocationHandler在 JDK 源码中这个类的名字叫AnnotationInvocationHandler并将第二步中构建的 Map 交给它保管。所以这个InvocationHandler的内部持有一个 Map// AnnotationInvocationHandler 内部的核心字段简化示意privatefinalMapString,ObjectmemberValues;// 对于我们的例子memberValues {value - hello, count - 3}第四步调用注解方法时从 Map 中取值当你执行annotation.value()时JVM 并不是在调用某个真正的方法实现。因为MyAnnotation是一个接口它根本没有实现。实际发生的事情是调用被转发到了AnnotationInvocationHandler的invoke()方法。在invoke()方法内部逻辑非常直接获取被调用的方法名。在这个例子中方法名是value。用这个方法名作为 key去memberValues这个 Map 中查找对应的值。找到了hello返回。伪代码大致如下publicObjectinvoke(Objectproxy,Methodmethod,Object[]args){StringmethodNamemethod.getName();// 如果调用的是 equals、hashCode、toString、annotationType 等方法// 做特殊处理这些是 Annotation 接口定义的方法// 如果调用的是注解自定义的属性方法直接从 Map 中取值ObjectresultmemberValues.get(methodName);returnresult;}这就是为什么annotation.value()返回helloannotation.count()返回3。3.4 实际验证代理对象的真面目你如果不信可以自己写段代码验证一下MyAnnotation(valuehello,count3)publicclassMyClass{}publicclassTest{publicstaticvoidmain(String[]args){MyAnnotationannotationMyClass.class.getAnnotation(MyAnnotation.class);// 打印注解对象的类名System.out.println(annotation.getClass().getName());// 打印注解对象是否是 Proxy 的实例System.out.println(annotationinstanceofjava.lang.reflect.Proxy);}}输出结果类似于com.sun.proxy.$Proxy1 true你会看到注解对象的类名是$Proxy1这是 JVM 动态生成的代理类的命名格式。注解对象确实是java.lang.reflect.Proxy的实例。这证明了注解对象确实是通过动态代理生成的。3.5 让我们再从源码角度看一眼 AnnotationInvocationHandler在 JDK 的源码中sun.reflect.annotation.AnnotationInvocationHandler这个类的核心结构大致是这样的classAnnotationInvocationHandlerimplementsInvocationHandler,Serializable{privatefinalClass?extendsAnnotationtype;// 注解的类型比如 MyAnnotation.classprivatefinalMapString,ObjectmemberValues;// 属性名 - 属性值的映射AnnotationInvocationHandler(Class?extendsAnnotationtype,MapString,ObjectmemberValues){this.typetype;this.memberValuesmemberValues;}publicObjectinvoke(Objectproxy,Methodmethod,Object[]args){Stringmembermethod.getName();// 处理 toString、hashCode、equals、annotationType 等特殊方法switch(member){casetoString:returntoStringImpl();casehashCode:returnhashCodeImpl();caseequals:returnequalsImpl(args[0]);caseannotationType:returntype;}// 对于注解自定义的属性方法直接从 map 中取值返回ObjectresultmemberValues.get(member);// 如果值是数组类型需要返回克隆副本以保证安全if(result.getClass().isArray()){resultcloneArray(result);}returnresult;}}看到这里整个运行时的工作机制就非常清晰了type字段记录了我代理的是哪个注解接口。memberValues字段就是那个核心的 Map存储着所有属性名和属性值的对应关系。invoke()方法在被调用时先判断是不是toString、hashCode等特殊方法如果不是就说明调用的是注解自定义的属性方法直接去 Map 里取值返回。3.6 小结运行时获取层面到这里我们可以完整总结注解在运行时的原理了当你通过反射调用getAnnotation()获取注解实例时JVM 并不是去找一个注解的实现类来创建对象。因为注解是接口没有实现类。JVM 的做法是通过动态代理机制在运行时动态生成一个实现了该注解接口的代理对象。这个代理对象的内部有一个InvocationHandler具体是AnnotationInvocationHandler它维护了一个 MapMap 中存储的是注解属性名到属性值的映射。当你调用注解对象的任何属性方法时调用会被转发到InvocationHandler的invoke()方法invoke()方法从 Map 中根据方法名取出对应的值并返回。四、前提条件为什么 Retention 必须是 RUNTIME4.1 注解的三种保留策略Java 中每个注解都可以通过Retention元注解来指定它的保留策略Retention Policy。保留策略决定了注解信息在哪个阶段还能被访问到。Retention有三个可选值保留策略含义RetentionPolicy.SOURCE注解只保留在源代码中编译时就被丢弃。编译后的 .class 文件中不会有这个注解的任何信息。RetentionPolicy.CLASS注解保留在 .class 文件中但 JVM 在运行时不会加载它。这是默认值。RetentionPolicy.RUNTIME注解保留在 .class 文件中并且 JVM 在运行时会加载它使得程序可以通过反射获取到注解信息。4.2 三种策略的适用场景SOURCE源码级别这种注解只在源代码阶段有意义编译完就没了。典型的例子是Override。Override的作用是告诉编译器我这个方法是要重写父类方法的如果我拼错了方法名你要报错。这个工作在编译阶段就完成了编译完之后Override这个注解就没有任何用处了所以不需要保留到 .class 文件中。CLASS字节码级别这种注解会被记录到 .class 文件中但 JVM 运行时不会主动去读取它。它主要用于一些字节码处理工具在编译后、运行前的阶段进行处理。比如一些代码分析工具、编译时注解处理器等。RUNTIME运行时级别这种注解不仅会被记录到 .class 文件中而且 JVM 运行时会把它加载到内存中使得程序可以通过反射 API如getAnnotation()来获取它。4.3 为什么运行时获取注解需要 RUNTIME现在把这个知识和前面讲的原理对接起来。我们说过当调用getAnnotation()时JVM 需要做以下事情从字节码中读取注解信息。构建属性名到属性值的 Map。通过动态代理创建代理对象。第一步就需要注解信息存在于运行时环境中。如果注解的Retention是SOURCE那么编译后注解信息就丢失了.class 文件中根本没有JVM 自然无法读取。如果是CLASS虽然 .class 文件中有但 JVM 的类加载器不会把注解信息加载到内存中的运行时数据结构里反射也就无法访问到。只有RUNTIME策略JVM 才会在加载类的时候将注解信息一起加载到内存中并且允许反射 API 去访问它们。所以这就是为什么如果你想在运行时通过反射获取注解就必须把注解的Retention设为RUNTIME。举个例子Retention(RetentionPolicy.RUNTIME)Target(ElementType.TYPE)publicinterfaceMyAnnotation{Stringvalue();intcount()default0;}这里的Retention(RetentionPolicy.RUNTIME)就是在告诉 JVM请把这个注解信息一直保留到运行时让程序可以通过反射获取到它。如果你把RUNTIME改成SOURCE或者CLASS那么下面这行代码MyAnnotationannotationMyClass.class.getAnnotation(MyAnnotation.class);将会返回null因为 JVM 在运行时找不到这个注解的信息。4.4 小结前提条件注解的Retention必须是RUNTIME才能在运行时通过反射获取到注解实例。因为只有RUNTIME策略JVM 才会在类加载时将注解信息保留在内存中供反射 API 访问。如果是SOURCE或CLASS注解信息要么在编译时就丢弃了要么虽然存在于 .class 文件中但不会被 JVM 加载到运行时环境中。五、完整串联注解运行时原理的全景图现在我们把三个层面串联起来形成一个完整的理解。5.1 从定义到使用的完整生命周期第一阶段编写注解定义Retention(RetentionPolicy.RUNTIME)Target(ElementType.TYPE)publicinterfaceMyAnnotation{Stringvalue();intcount()default0;}你用interface定义了一个注解。编译器在编译时会把它变成一个继承了java.lang.annotation.Annotation的接口。value()和count()变成了接口中的两个抽象方法。第二阶段使用注解标注目标MyAnnotation(valuehello,count3)publicclassMyClass{...}编译器在编译MyClass的时候会把注解信息写入到MyClass.class文件的字节码中。因为Retention是RUNTIME所以注解信息会被标记在字节码中的RuntimeVisibleAnnotations属性表里。记录的内容包括注解类型是MyAnnotationvalue的值是hellocount的值是3。第三阶段JVM 加载类当 JVM 加载MyClass时它会解析字节码发现其中有RuntimeVisibleAnnotations就会把注解信息读取出来保存在类的元数据中。第四阶段通过反射获取注解MyAnnotationannotationMyClass.class.getAnnotation(MyAnnotation.class);调用getAnnotation()时JVM 执行以下步骤从MyClass的元数据中找到MyAnnotation的注解信息。构建一个MapString, Object{value - hello, count - 3}。创建一个AnnotationInvocationHandler实例把 Map 传给它。使用Proxy.newProxyInstance()创建一个动态代理对象这个对象实现了MyAnnotation接口内部关联着上面的AnnotationInvocationHandler。返回这个代理对象。第五阶段调用注解的属性方法Stringvannotation.value();// 返回 hellointcannotation.count();// 返回 3调用value()时代理机制将调用转发到AnnotationInvocationHandler.invoke()方法。invoke()方法根据方法名value从 Map 中取出hello并返回。count()同理。5.2 用一张逻辑链条来记忆interface 定义注解 ↓ 编译器处理 注解变成一个 interface继承 Annotation ↓ 使用注解标注类/方法/字段 注解信息写入 .class 文件的字节码中需要 Retention(RUNTIME) ↓ JVM 加载类 读取字节码中的注解信息保存在类的元数据中 ↓ 反射调用 getAnnotation() JVM 构建属性 Map → 创建 AnnotationInvocationHandler → 动态代理生成代理对象 ↓ 调用注解方法 代理对象拦截调用 → invoke() 方法 → 从 Map 中按方法名取值 → 返回5.3 三个核心技术的协作注解在运行时的原理可以归纳为三个核心技术的协作技术在注解原理中的角色接口注解本身就是接口定义了有哪些属性抽象方法。它提供了一种契约规定了注解对象应该有哪些方法可以调用。动态代理既然注解是接口就需要一种方式在运行时创建接口的实例。动态代理恰好可以做到这一点——在运行时凭空生成一个实现了注解接口的代理对象。反射反射是触发整个流程的入口。通过反射的getAnnotation()方法程序才能获取到注解信息JVM 才会去执行动态代理创建代理对象的逻辑。同时注解信息本身也是通过反射机制从类的元数据中读取出来的。三者缺一不可没有接口就没有注解的定义形式。没有动态代理就无法在运行时创建注解接口的实例。没有反射就无法在运行时触发获取注解的流程。六、常见疑问解答6.1 既然注解是接口能不能自己写一个类来实现它理论上可以但没有任何意义也不是 Java 注解机制的设计初衷。Java 注解的工作完全依赖于 JVM 内部的动态代理机制你手动实现注解接口创建的对象不会被 JVM 的注解系统所识别。6.2 每次调用 getAnnotation() 都会创建新的代理对象吗不一定。JVM 实现可能会对注解对象进行缓存。也就是说第一次调用getAnnotation()时创建代理对象后续调用可能直接返回缓存的同一个对象。具体的缓存策略取决于 JDK 的实现版本。6.3 注解的属性值是什么时候确定的在编译期。当你写MyAnnotation(value hello)时hello这个值在编译时就被写入了字节码。它是一个常量值运行时不能修改虽然理论上可以通过反射修改 Map 中的值但这属于 hack 行为不是正常用法。注解的属性值也因此有类型限制——只能是基本类型、String、Class、枚举、其他注解以及这些类型的一维数组。这些类型的共同特点是它们的值可以在编译期确定并且可以被写入字节码。6.4 Spring 框架中的注解也是这个原理吗是的。Spring 中的Controller、Service、Autowired等注解底层也都是接口。Spring 在启动时会通过反射扫描类上的注解拿到的注解对象也是 JVM 动态代理生成的代理对象。Spring 拿到注解后根据注解的类型和属性值执行相应的逻辑比如将类注册为 Bean、执行依赖注入等。整个过程的底层原理是一样的。七、最终总结让我们回到最开始的那个总结注解的底层本质是一个继承了java.lang.annotation.Annotation接口的特殊接口。经过前面的详细讲解这句话的每一个字你都应该能理解了“接口”——注解不是类不是枚举它就是一个接口。用interface定义注解时编译器自动让它继承Annotation接口。注解中的属性就是接口的抽象方法。“动态代理”——既然注解是接口它就无法直接实例化。当你通过反射获取注解时JVM 使用动态代理技术在运行时创建一个实现了该注解接口的代理对象。代理对象的InvocationHandler即AnnotationInvocationHandler内部维护了一个 Map存储了属性名到属性值的映射。“反射”——反射是获取注解的唯一途径。通过getAnnotation()触发 JVM 创建代理对象并返回。而且只有Retention(RUNTIME)的注解才能在运行时通过反射获取到。“从 Map 中取值”——调用注解对象的属性方法时调用会被代理机制拦截转发到invoke()方法中。invoke()方法的核心逻辑就是根据方法名从 Map 中取出对应的值返回。所以注解运行时的原理就是接口 动态代理 反射通过代理对象返回注解的属性值。