Java注解的底层原理
Java 注解的底层原理——完整讲解
一、从一个问题开始:注解到底是什么东西?
你在写 Java 代码的时候,一定见过这样的写法:
@Override
public String toString() {
return "hello";
}
或者在 Spring 框架里见过:
@Controller
public class UserController { ... }
你有没有想过一个问题——这些 @ 开头的东西,到底是什么?它不是注释(comment),因为注释是给人看的、会被编译器忽略。但注解(annotation)显然能影响程序的行为。那它的本质到底是什么?
答案是:注解的底层本质,是一个继承了 java.lang.annotation.Annotation 接口的特殊接口。
这句话信息量很大,我们需要逐层拆解才能真正理解它。接下来,我会从三个层面来讲清楚:
- 定义层面:你写
@interface的时候,编译器背后做了什么? - 运行时获取层面:你通过反射拿到注解实例时,JVM 背后做了什么?
- 前提条件:为什么有些注解运行时拿不到?
最后我们把三者串起来,形成一个完整的理解。
二、定义层面:@interface 背后的真相
2.1 你平时怎么定义一个注解?
假设你要定义一个自己的注解,叫 @MyAnnotation,你会这样写:
public @interface MyAnnotation {
String value();
int count() default 0;
}
然后你可以这样使用它:
@MyAnnotation(value = "hello", count = 3)
public class MyClass { ... }
这段代码你一定不陌生。但现在问题来了:
@interface这个关键字是什么意思?String value()和int count()看起来像方法声明,但它们到底是什么?- 为什么调用注解属性的时候用的是
value = "hello"这种赋值语法,而不是调用方法的语法?
2.2 编译器的秘密操作
当你写下 @interface MyAnnotation 并编译之后,编译器会自动帮你做一件事情:它会把你写的这个注解,转换成一个接口,并且让这个接口自动继承 java.lang.annotation.Annotation。
也就是说,上面那段注解定义代码,在编译之后,其实等价于下面这个东西:
public interface MyAnnotation extends java.lang.annotation.Annotation {
String value();
int count();
}
你看到了吗?@interface 本质上就是 interface。编译器只是用 @interface 这个语法糖,替你省去了 extends Annotation 这句话。
这就是第一个关键结论:注解本质上就是一个接口。
2.3 注解中的"属性"本质上是接口的抽象方法
再回头看注解里定义的 String value() 和 int count()。
在普通接口中,这种没有方法体的声明叫做抽象方法。而注解作为一个接口,它里面写的这些东西,本质上也是抽象方法。
但是在注解的使用场景中,我们习惯把它们叫做注解的"属性"或"成员"。这纯粹是一种叫法上的习惯。从 Java 语法的底层来看,它们就是接口中的抽象方法,只不过方法的返回值类型就是属性的类型,方法的名字就是属性的名字。
所以当你写:
@MyAnnotation(value = "hello", count = 3)
你表面上是在"给属性赋值",但从底层来理解,你其实是在告诉 JVM:当将来有人调用这个注解接口的 value() 方法时,应该返回 "hello";调用 count() 方法时,应该返回 3。
至于 default 0 这种写法,就是给抽象方法提供一个默认的返回值。如果使用注解时没有显式指定 count 的值,那么调用 count() 方法时就返回默认值 0。
2.4 为什么要设计成接口?
你可能会问:为什么 Java 要把注解设计成接口,而不是设计成一个类?
原因在于:接口只定义了"有哪些方法",而不定义"方法怎么实现"。 注解在定义的时候,只需要描述"我有哪些属性",至于这些属性的值是多少、怎么获取这些值,那是运行时才需要关心的事情。
接口的这种"只定义契约,不关心实现"的特性,恰好适合注解的需求。而具体的实现,Java 会交给后面要讲的动态代理来完成。
2.5 java.lang.annotation.Annotation 接口是什么?
所有注解都自动继承了 java.lang.annotation.Annotation 接口。那这个接口里有什么呢?
public interface Annotation {
boolean equals(Object obj);
int hashCode();
String toString();
Class<? extends Annotation> annotationType();
}
它定义了四个方法:equals、hashCode、toString、annotationType。
annotationType()返回注解的类型,比如MyAnnotation.class。- 其余三个是 Java 中所有对象都有的基本方法。
这意味着,所有的注解都拥有这四个方法,加上注解自身定义的属性方法。这构成了注解接口的完整方法列表。
2.6 小结:定义层面
到这里,关于注解在定义层面的原理,我们可以完整总结了:
你用
@interface定义注解时,编译器会自动让它继承java.lang.annotation.Annotation接口。注解中的每一个"属性",本质上就是接口中的一个抽象方法。注解本身就是一个接口,它只描述了"有哪些属性",不包含任何实现。
三、运行时获取层面:反射和动态代理的协作
现在我们知道了注解是一个接口。但问题来了:接口是不能直接创建实例的,那当你通过反射获取注解时,拿到的那个"注解对象"是从哪来的?
这就涉及到注解原理中最核心的部分——动态代理。
3.1 先看你怎么获取注解
假设你有一个类 MyClass,上面标注了 @MyAnnotation(value = "hello", count = 3)。在运行时,你可以通过反射来获取这个注解:
Class<?> clazz = MyClass.class;
MyAnnotation annotation = clazz.getAnnotation(MyAnnotation.class);
// 然后你可以这样读取注解的属性值
String v = annotation.value(); // 返回 "hello"
int c = annotation.count(); // 返回 3
看这段代码,annotation 是一个对象。你可以调用它的 value() 方法和 count() 方法来获取属性值。
但我们刚才说了,MyAnnotation 是一个接口。接口是不能用 new 来创建对象的。 那 getAnnotation() 返回的这个对象到底是什么?
答案:它是 JVM 通过动态代理机制自动生成的一个代理对象。
3.2 什么是动态代理?(为零基础铺垫)
如果你对"动态代理"这个概念不熟悉,我先用最简单的方式解释一下。
在 Java 中,有一种机制:你可以在运行时,动态地创建一个实现了某个接口的对象,而不需要事先写好这个接口的实现类。 这就是动态代理。
Java 提供了一个类叫 java.lang.reflect.Proxy,它有一个静态方法 newProxyInstance(),可以在运行时凭空创建一个对象。这个对象会实现你指定的接口。
但仅仅创建一个对象还不够——接口中有抽象方法,当你调用这个代理对象的方法时,总得有个地方来定义"这个方法应该返回什么"。这个"地方"就是 InvocationHandler(调用处理器)。
InvocationHandler 是一个接口,它只有一个方法:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
每当你调用代理对象的任何方法时,JVM 都不会去找什么"真正的实现",而是统一转发到这个 invoke() 方法里。在 invoke() 方法中,你可以自定义逻辑来决定返回什么值。
打个简单的代码示意:
// 假设有一个接口
public interface Greeting {
String sayHello();
}
// 用动态代理创建一个实现了 Greeting 接口的对象
Greeting proxy = (Greeting) Proxy.newProxyInstance(
Greeting.class.getClassLoader(),
new Class[]{Greeting.class},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
if (method.getName().equals("sayHello")) {
return "Hello from proxy!";
}
return null;
}
}
);
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 的值是 "hello",count 的值是 3"。
第二步:构建属性名到属性值的 Map
JVM 会把读取到的注解信息,组织成一个 Map 结构。对于我们这个例子,这个 Map 大概是这样的:
{
"value" -> "hello",
"count" -> 3
}
键(key)是注解的属性名(也就是接口中方法的名字),值(value)是使用注解时指定的属性值。
第三步:通过动态代理创建注解的代理对象
JVM 使用 Proxy.newProxyInstance() 动态地创建一个对象,这个对象实现了 MyAnnotation 接口。
与此同时,JVM 会创建一个 InvocationHandler(在 JDK 源码中,这个类的名字叫 AnnotationInvocationHandler),并将第二步中构建的 Map 交给它保管。
所以,这个 InvocationHandler 的内部,持有一个 Map:
// AnnotationInvocationHandler 内部的核心字段(简化示意)
private final Map<String, Object> memberValues;
// 对于我们的例子,memberValues = {"value" -> "hello", "count" -> 3}
第四步:调用注解方法时,从 Map 中取值
当你执行 annotation.value() 时,JVM 并不是在调用某个"真正的方法实现"。因为 MyAnnotation 是一个接口,它根本没有实现。
实际发生的事情是:调用被转发到了 AnnotationInvocationHandler 的 invoke() 方法。
在 invoke() 方法内部,逻辑非常直接:
- 获取被调用的方法名。在这个例子中,方法名是
"value"。 - 用这个方法名作为 key,去
memberValues这个 Map 中查找对应的值。 - 找到了
"hello",返回。
伪代码大致如下:
public Object invoke(Object proxy, Method method, Object[] args) {
String methodName = method.getName();
// 如果调用的是 equals、hashCode、toString、annotationType 等方法,
// 做特殊处理(这些是 Annotation 接口定义的方法)
// 如果调用的是注解自定义的属性方法,直接从 Map 中取值
Object result = memberValues.get(methodName);
return result;
}
这就是为什么 annotation.value() 返回 "hello",annotation.count() 返回 3。
3.4 实际验证:代理对象的真面目
你如果不信,可以自己写段代码验证一下:
@MyAnnotation(value = "hello", count = 3)
public class MyClass {}
public class Test {
public static void main(String[] args) {
MyAnnotation annotation = MyClass.class.getAnnotation(MyAnnotation.class);
// 打印注解对象的类名
System.out.println(annotation.getClass().getName());
// 打印注解对象是否是 Proxy 的实例
System.out.println(annotation instanceof java.lang.reflect.Proxy);
}
}
输出结果(类似于):
com.sun.proxy.$Proxy1
true
你会看到:
- 注解对象的类名是
$Proxy1,这是 JVM 动态生成的代理类的命名格式。 - 注解对象确实是
java.lang.reflect.Proxy的实例。
这证明了注解对象确实是通过动态代理生成的。
3.5 让我们再从源码角度看一眼 AnnotationInvocationHandler
在 JDK 的源码中(sun.reflect.annotation.AnnotationInvocationHandler),这个类的核心结构大致是这样的:
class AnnotationInvocationHandler implements InvocationHandler, Serializable {
private final Class<? extends Annotation> type; // 注解的类型,比如 MyAnnotation.class
private final Map<String, Object> memberValues; // 属性名 -> 属性值的映射
AnnotationInvocationHandler(Class<? extends Annotation> type, Map<String, Object> memberValues) {
this.type = type;
this.memberValues = memberValues;
}
public Object invoke(Object proxy, Method method, Object[] args) {
String member = method.getName();
// 处理 toString、hashCode、equals、annotationType 等特殊方法
switch (member) {
case "toString":
return toStringImpl();
case "hashCode":
return hashCodeImpl();
case "equals":
return equalsImpl(args[0]);
case "annotationType":
return type;
}
// 对于注解自定义的属性方法,直接从 map 中取值返回
Object result = memberValues.get(member);
// 如果值是数组类型,需要返回克隆副本以保证安全
if (result.getClass().isArray()) {
result = cloneArray(result);
}
return result;
}
}
看到这里,整个运行时的工作机制就非常清晰了:
type字段记录了"我代理的是哪个注解接口"。memberValues字段就是那个核心的 Map,存储着所有属性名和属性值的对应关系。invoke()方法在被调用时,先判断是不是toString、hashCode等特殊方法,如果不是,就说明调用的是注解自定义的属性方法,直接去 Map 里取值返回。
3.6 小结:运行时获取层面
到这里,我们可以完整总结注解在运行时的原理了:
当你通过反射调用
getAnnotation()获取注解实例时,JVM 并不是去找一个注解的"实现类"来创建对象。因为注解是接口,没有实现类。JVM 的做法是通过动态代理机制,在运行时动态生成一个实现了该注解接口的代理对象。这个代理对象的内部有一个InvocationHandler(具体是AnnotationInvocationHandler),它维护了一个 Map,Map 中存储的是注解属性名到属性值的映射。当你调用注解对象的任何属性方法时,调用会被转发到InvocationHandler的invoke()方法,invoke()方法从 Map 中根据方法名取出对应的值并返回。
四、前提条件:为什么 @Retention 必须是 RUNTIME?
4.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)
public @interface MyAnnotation {
String value();
int count() default 0;
}
这里的 @Retention(RetentionPolicy.RUNTIME) 就是在告诉 JVM:请把这个注解信息一直保留到运行时,让程序可以通过反射获取到它。
如果你把 RUNTIME 改成 SOURCE 或者 CLASS,那么下面这行代码:
MyAnnotation annotation = MyClass.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)
public @interface MyAnnotation {
String value();
int count() default 0;
}
你用 @interface 定义了一个注解。编译器在编译时,会把它变成一个继承了 java.lang.annotation.Annotation 的接口。value() 和 count() 变成了接口中的两个抽象方法。
第二阶段:使用注解标注目标
@MyAnnotation(value = "hello", count = 3)
public class MyClass { ... }
编译器在编译 MyClass 的时候,会把注解信息写入到 MyClass.class 文件的字节码中。因为 @Retention 是 RUNTIME,所以注解信息会被标记在字节码中的 RuntimeVisibleAnnotations 属性表里。记录的内容包括:注解类型是 MyAnnotation,value 的值是 "hello",count 的值是 3。
第三阶段:JVM 加载类
当 JVM 加载 MyClass 时,它会解析字节码,发现其中有 RuntimeVisibleAnnotations,就会把注解信息读取出来,保存在类的元数据中。
第四阶段:通过反射获取注解
MyAnnotation annotation = MyClass.class.getAnnotation(MyAnnotation.class);
调用 getAnnotation() 时,JVM 执行以下步骤:
- 从
MyClass的元数据中找到MyAnnotation的注解信息。 - 构建一个
Map<String, Object>:{"value" -> "hello", "count" -> 3}。 - 创建一个
AnnotationInvocationHandler实例,把 Map 传给它。 - 使用
Proxy.newProxyInstance()创建一个动态代理对象,这个对象实现了MyAnnotation接口,内部关联着上面的AnnotationInvocationHandler。 - 返回这个代理对象。
第五阶段:调用注解的属性方法
String v = annotation.value(); // 返回 "hello"
int c = annotation.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 中取出对应的值返回。
所以,注解运行时的原理就是:接口 + 动态代理 + 反射,通过代理对象返回注解的属性值。
更多推荐


所有评论(0)