Java 注解完全指南:从 "这是什么" 到 "自己写一个"

19 阅读10分钟

注解是 Java 里最容易被误解的特性之一。很多人天天用 @Override@Autowired,但说不清它到底做了什么。本文用最直觉的方式讲清楚注解的本质,然后手把手写一个自定义注解。

目录

注解的本质:便利贴,不是魔法

先看一段代码:

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

@Override 做了什么?答案是:什么都没做

它不会修改 toString() 的行为,不会注入任何逻辑,不会在运行时产生任何副作用。它只是告诉编译器:"我声明这个方法是重写父类的,如果我拼错了方法名,请报错。"

这就是注解的本质——元数据标记。它是贴在代码上的便利贴,本身没有任何行为。真正干活的是读这个便利贴的人(编译器、框架、注解处理器)。

注解 = 便利贴(只是标记)
处理器 = 读便利贴的人(真正干活)

没有处理器的注解,就是一张没人看的便利贴——贴了也白贴。

注解的三部曲:定义、贴、处理

任何注解的使用都分三步:

① 定义注解    →  设计便利贴的格式(有哪些字段要填)
② 贴注解      →  把便利贴贴到代码上(填好字段值)
③ 处理注解    →  有人来读便利贴,根据内容做事

用一个生活类比:

① 设计一种便利贴:「紧急任务」,字段有 [负责人] [截止日期]
② 在某个文件夹上贴一张:「紧急任务:负责人=张三,截止日期=周五」
③ 项目经理每天扫一遍所有便利贴,把紧急任务排到日程表里

如果没有第③步的项目经理,便利贴就只是一张纸。

内置注解:你每天都在用的那几个

Java 自带了几个注解,它们的处理器是编译器本身:

// 1. @Override — 编译器检查:这个方法是否真的重写了父类方法
@Override
public String toString() { return "hello"; }
// 如果写成 tostring()(小写 s),编译器直接报错

// 2. @Deprecated — 编译器警告:这个方法已过时,别用了
@Deprecated
public void oldMethod() { }
// 调用 oldMethod() 时,IDE 会画删除线,编译器会 warning

// 3. @SuppressWarnings — 告诉编译器:别给我报这个警告
@SuppressWarnings("unchecked")
List<String> list = (List<String>) rawList;
// 没有这个注解,编译器会警告 unchecked cast

// 4. @FunctionalInterface — 编译器检查:这个接口是否只有一个抽象方法
@FunctionalInterface
public interface Runnable {
    void run();
}

这几个注解的处理器都是 javac 编译器,不需要你写任何处理逻辑。

元注解:给注解加注解

定义自己的注解时,需要用"元注解"来描述这个注解的行为。元注解就是"注解的注解"。

两个最重要的元注解:

@Target — 这个注解能贴在哪

@Target(ElementType.TYPE)            // 只能贴在类/接口上
@Target(ElementType.METHOD)          // 只能贴在方法上
@Target(ElementType.FIELD)           // 只能贴在字段上
@Target({ElementType.TYPE, ElementType.METHOD})  // 类和方法都行

@Retention — 这个注解活多久

这是最关键的,决定了注解在什么阶段可以被读取:

@Retention(RetentionPolicy.SOURCE)   // 只在源码中存在,编译后就没了
@Retention(RetentionPolicy.CLASS)    // 保留到 .class 文件,但运行时读不到
@Retention(RetentionPolicy.RUNTIME)  // 运行时也能读到(通过反射)
RetentionPolicy存活阶段谁来处理典型例子
SOURCE源码 → ❌编译后消失编译器@Override@SuppressWarnings
CLASS源码 → .class文件 → ❌运行时消失APT 注解处理器@WidgetAnnotation(编译时生成代码)
RUNTIME源码 → .class文件 → 运行时反射@Autowired@JavascriptInterface

选哪个取决于你的处理器在什么时候工作:

  • 编译器检查 → SOURCE
  • 编译时生成代码 → CLASS
  • 运行时动态处理 → RUNTIME

实战:手写一个路由注解

假设我们在做一个 Web 框架,想实现类似 Spring 的路由映射:

@Route("/api/users")
public class UserController {
    
    @Route("/list")
    public String listUsers() {
        return "user list";
    }
    
    @Route("/detail")
    public String getUser() {
        return "user detail";
    }
}

框架自动把 URL 路径映射到对应的方法,不需要手动注册。

第一步:定义注解

import java.lang.annotation.*;

@Target({ElementType.TYPE, ElementType.METHOD})  // 可以贴在类和方法上
@Retention(RetentionPolicy.RUNTIME)              // 运行时可读(因为要用反射)
public @interface Route {
    String value();  // 路径,如 "/api/users"
}

就这么几行。@interface 是定义注解的关键字,value() 是注解的属性。当注解只有一个属性且叫 value 时,使用时可以省略属性名:@Route("/api/users") 等价于 @Route(value = "/api/users")

第二步:贴注解

@Route("/api/users")
public class UserController {
    
    @Route("/list")
    public String listUsers() {
        return "[{\"name\": \"Alice\"}, {\"name\": \"Bob\"}]";
    }
    
    @Route("/detail")
    public String getUser() {
        return "{\"name\": \"Alice\", \"age\": 30}";
    }
}

@Route("/api/orders")
public class OrderController {
    
    @Route("/list")
    public String listOrders() {
        return "[{\"id\": 1001}]";
    }
}

到这一步,代码的行为没有任何变化。@Route 只是贴了个标签。

第三步:运行时处理

这是关键——写一个路由注册器,通过反射读取注解,构建 URL → 方法的映射表:

import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

public class Router {
    // URL → 处理方法的映射表
    private Map<String, RouteHandler> routeMap = new HashMap<>();
    
    /**
     * 扫描一个 Controller 类,读取 @Route 注解,注册路由
     */
    public void register(Class<?> controllerClass) throws Exception {
        // ① 读取类上的 @Route 注解
        Route classRoute = controllerClass.getAnnotation(Route.class);
        String basePath = (classRoute != null) ? classRoute.value() : "";
        
        // ② 创建 Controller 实例
        Object instance = controllerClass.getDeclaredConstructor().newInstance();
        
        // ③ 遍历所有方法,找带 @Route 的
        for (Method method : controllerClass.getDeclaredMethods()) {
            Route methodRoute = method.getAnnotation(Route.class);
            if (methodRoute != null) {
                String fullPath = basePath + methodRoute.value();
                routeMap.put(fullPath, new RouteHandler(instance, method));
                System.out.println("注册路由: " + fullPath 
                    + " → " + controllerClass.getSimpleName() 
                    + "." + method.getName() + "()");
            }
        }
    }
    
    /**
     * 处理请求:根据 URL 找到对应方法并执行
     */
    public String handleRequest(String url) throws Exception {
        RouteHandler handler = routeMap.get(url);
        if (handler == null) {
            return "404 Not Found: " + url;
        }
        return (String) handler.method.invoke(handler.instance);
    }
    
    private static class RouteHandler {
        Object instance;
        Method method;
        RouteHandler(Object instance, Method method) {
            this.instance = instance;
            this.method = method;
        }
    }
}

使用:

public class App {
    public static void main(String[] args) throws Exception {
        Router router = new Router();
        
        // 注册 Controller
        router.register(UserController.class);
        router.register(OrderController.class);
        
        // 模拟请求
        System.out.println(router.handleRequest("/api/users/list"));
        System.out.println(router.handleRequest("/api/users/detail"));
        System.out.println(router.handleRequest("/api/orders/list"));
        System.out.println(router.handleRequest("/api/unknown"));
    }
}

输出:

注册路由: /api/users/list → UserController.listUsers()
注册路由: /api/users/detail → UserController.getUser()
注册路由: /api/orders/list → OrderController.listOrders()
[{"name": "Alice"}, {"name": "Bob"}]
{"name": "Alice", "age": 30}
[{"id": 1001}]
404 Not Found: /api/unknown

整个过程:

  1. @Route 注解本身什么都没做,只是在类和方法上贴了路径信息
  2. Router.register() 通过反射读取这些注解,构建了 URL → 方法的映射表
  3. 请求来了,查表找到方法,反射调用

注解只是数据,反射才是引擎。

进阶:编译时处理(APT)

上面的例子用的是运行时反射,还有一种更高效的方式:编译时处理(APT,Annotation Processing Tool)。

区别:

维度运行时反射编译时 APT
处理时机程序运行时javac 编译时
性能开销每次启动都要扫描编译时一次性生成代码,运行时零开销
RetentionRUNTIMECLASS 或 SOURCE
产物无(直接执行逻辑)生成新的 .java 文件
典型框架Spring(@Autowired)Dagger(@Inject)、ButterKnife(@BindView)

APT 的思路是:编译时扫描注解,自动生成 Java 源码文件,这些文件和你手写的代码一起编译。运行时直接用生成的代码,不需要反射。

编译时:
  你的代码(带注解)
       │
       ▼
  APT 注解处理器扫描注解
       │
       ▼
  自动生成 Java 文件(如 Router_Generated.java)
       │
       ▼
  一起编译成 .class

运行时:
  直接调用 Router_Generated 的方法,零反射

比如把上面的路由例子改成 APT 方式,处理器会在编译时生成:

// 编译时自动生成的文件,不需要手写
public class Router_Generated {
    public static void registerAll(Router router) {
        router.addRoute("/api/users/list", 
            new UserController()::listUsers);
        router.addRoute("/api/users/detail", 
            new UserController()::getUser);
        router.addRoute("/api/orders/list", 
            new OrderController()::listOrders);
    }
}

运行时直接调用 Router_Generated.registerAll(router),不需要反射扫描,启动更快。

Android 开发中大量使用 APT,因为移动端对启动速度敏感,反射太慢。

注解 vs 装饰器:跨语言对比

如果你熟悉 Python/TypeScript,可能会把注解和装饰器搞混。它们语法相似,但本质不同:

# Python 装饰器:运行时包装函数,改变了行为
@login_required
def dashboard(request):
    return render("dashboard.html")

# 等价于:
dashboard = login_required(dashboard)
# dashboard 已经不是原来的函数了,被包了一层
// Java 注解:只是贴标签,不改变任何行为
@Route("/dashboard")
public String dashboard() {
    return render("dashboard.html");
}
// dashboard() 还是原来的 dashboard(),没有任何变化
维度Java 注解Python/TS 装饰器
本质元数据标记(便利贴)高阶函数(包装器)
是否改变行为❌ 不改变✅ 直接改变
谁来处理需要额外的处理器(反射/APT)装饰器函数本身就是处理器
处理时机编译时或运行时运行时(类/函数定义时)
能否访问函数体❌ 不能✅ 可以包装、替换、增强

TypeScript 的装饰器 + reflect-metadata 是最接近 Java 注解的:

// TypeScript:装饰器贴标签 + reflect-metadata 存储 + 框架读取
// 这个组合和 Java 注解的工作方式几乎一样

@Controller('/api/users')          // 贴标签
class UserController {
    @Get('/list')                  // 贴标签
    listUsers() { return [] }
}

// NestJS 框架在启动时通过 Reflect.getMetadata() 读取标签
// 构建路由表——和我们上面写的 Router 一模一样

真实案例拆解

Spring 的 @Autowired 怎么实现的

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;  // 自动注入,不需要 new
}

背后的三部曲:

① 定义:@Autowired 是 Spring 定义的注解,Retention = RUNTIME
② 贴:开发者在字段上贴 @Autowired
③ 处理:Spring 容器启动时——
   - 扫描所有 Bean 类
   - 对每个 Bean,遍历所有字段
   - 发现带 @Autowired 的字段
   - 通过反射 field.set(bean, 依赖对象) 注入

核心处理逻辑(简化版):

// Spring 内部的处理器(简化)
for (Field field : bean.getClass().getDeclaredFields()) {
    if (field.isAnnotationPresent(Autowired.class)) {
        Object dependency = container.getBean(field.getType());
        field.setAccessible(true);
        field.set(bean, dependency);  // 反射注入
    }
}

@Autowired 本身没有注入能力,是 Spring 的 AutowiredAnnotationBeanPostProcessor 在运行时通过反射完成的注入。

Android 的 @WidgetAnnotation 怎么实现的

快应用框架中,每个原生组件(text、image、div)都用注解标记:

// ① 定义注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)    // 注意:CLASS,不是 RUNTIME
public @interface WidgetAnnotation {
    String name();                   // 组件名,如 "text"
    String[] methods() default {};   // 暴露给 JS 的方法
    TypeAnnotation[] types() default {};
}

// ② 贴注解
@WidgetAnnotation(
    name = "text",
    methods = { "animate", "getBoundingClientRect", "focus" }
)
public class Text extends AbstractText<TextLayoutView> { ... }

// ③ 编译时处理(APT)
// AnnotationProcessor 在编译时扫描所有 @WidgetAnnotation
// 自动生成组件注册表:
//   "text"  → Text.class
//   "image" → Image.class
//   "div"   → Div.class
// 运行时 ComponentFactory 直接查表,不需要反射

这里用 CLASS 而不是 RUNTIME,因为 Android 对启动速度要求高,编译时生成代码比运行时反射快得多。

总结

注解 = 元数据标记
     = 贴在代码上的便利贴
     = 本身不执行任何逻辑

注解的价值 = 处理器的价值
  编译器处理 → @Override(检查方法签名)
  APT 处理  → @WidgetAnnotation(编译时生成注册表)
  反射处理  → @Autowired(运行时依赖注入)

没有处理器的注解 = 没人看的便利贴 = 废纸

记住三部曲:定义处理。前两步是声明式的(说"是什么"),第三步是命令式的(做"怎么办")。注解的所有"魔法"都藏在第三步里。


如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区讨论。