Java 注解的底层原理——完整讲解


一、从一个问题开始:注解到底是什么东西?

你在写 Java 代码的时候,一定见过这样的写法:

@Override
public String toString() {
    return "hello";
}

或者在 Spring 框架里见过:

@Controller
public class UserController { ... }

你有没有想过一个问题——这些 @ 开头的东西,到底是什么?它不是注释(comment),因为注释是给人看的、会被编译器忽略。但注解(annotation)显然能影响程序的行为。那它的本质到底是什么?

答案是:注解的底层本质,是一个继承了 java.lang.annotation.Annotation 接口的特殊接口。

这句话信息量很大,我们需要逐层拆解才能真正理解它。接下来,我会从三个层面来讲清楚:

  1. 定义层面:你写 @interface 的时候,编译器背后做了什么?
  2. 运行时获取层面:你通过反射拿到注解实例时,JVM 背后做了什么?
  3. 前提条件:为什么有些注解运行时拿不到?

最后我们把三者串起来,形成一个完整的理解。


二、定义层面:@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 怎么用动态代理来创建注解实例?

现在把动态代理的知识套用到注解上来。

我们知道了:

  1. 注解 MyAnnotation 本质上是一个接口。
  2. 接口不能直接创建实例。
  3. 但是动态代理可以在运行时创建一个实现了某接口的代理对象。

所以,当你调用 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() 方法内部,逻辑非常直接:

  1. 获取被调用的方法名。在这个例子中,方法名是 "value"。
  2. 用这个方法名作为 key,去 memberValues 这个 Map 中查找对应的值。
  3. 找到了 "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

你会看到:

  1. 注解对象的类名是 $Proxy1,这是 JVM 动态生成的代理类的命名格式。
  2. 注解对象确实是 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;
    }
}

看到这里,整个运行时的工作机制就非常清晰了:

  1. type 字段记录了"我代理的是哪个注解接口"。
  2. memberValues 字段就是那个核心的 Map,存储着所有属性名和属性值的对应关系。
  3. 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 需要做以下事情:

  1. 从字节码中读取注解信息。
  2. 构建属性名到属性值的 Map。
  3. 通过动态代理创建代理对象。

第一步就需要注解信息存在于运行时环境中。 如果注解的 @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 执行以下步骤:

  1. 从 MyClass 的元数据中找到 MyAnnotation 的注解信息。
  2. 构建一个 Map<String, Object>:{"value" -> "hello", "count" -> 3}。
  3. 创建一个 AnnotationInvocationHandler 实例,把 Map 传给它。
  4. 使用 Proxy.newProxyInstance() 创建一个动态代理对象,这个对象实现了 MyAnnotation 接口,内部关联着上面的 AnnotationInvocationHandler。
  5. 返回这个代理对象。

第五阶段:调用注解的属性方法

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 接口的特殊接口。

经过前面的详细讲解,这句话的每一个字你都应该能理解了:

  1. “接口”——注解不是类,不是枚举,它就是一个接口。用 @interface 定义注解时,编译器自动让它继承 Annotation 接口。注解中的"属性"就是接口的抽象方法。

  2. “动态代理”——既然注解是接口,它就无法直接实例化。当你通过反射获取注解时,JVM 使用动态代理技术,在运行时创建一个实现了该注解接口的代理对象。代理对象的 InvocationHandler(即 AnnotationInvocationHandler)内部维护了一个 Map,存储了属性名到属性值的映射。

  3. “反射”——反射是获取注解的唯一途径。通过 getAnnotation() 触发 JVM 创建代理对象并返回。而且只有 @Retention(RUNTIME) 的注解才能在运行时通过反射获取到。

  4. “从 Map 中取值”——调用注解对象的属性方法时,调用会被代理机制拦截,转发到 invoke() 方法中。invoke() 方法的核心逻辑就是根据方法名从 Map 中取出对应的值返回。

所以,注解运行时的原理就是:接口 + 动态代理 + 反射,通过代理对象返回注解的属性值。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐