1
0
0

Java 注解与反射:自定义注解、参数校验与运行时取元数据

2026-10-23
2026-10-23

📝 笔记摘要:Java 注解与反射,代码里的元数据和运行时入口

注解是写在代码上的元数据,编译器看得见、运行时也能读出来。反射是运行时去查看类、创建对象、调用方法的能力。两者凑在一起,才有了 Spring 的依赖注入、MyBatis 的 Mapper 代理、JUnit 的断言。

这篇分八节:注解是什么、内置注解、元注解与自定义注解、注解在框架里的用法,然后是反射的获取 Class、操作成员、创建对象与调用方法、读泛型与性能代价。Java 基础笔记第 32 节提过反射的皮毛,这里是把它拆开讲透,泛型部分接 Java 泛型笔记。

上篇 注解
  1. 注解是什么 2. 内置注解
  3. 元注解与自定义注解 4. 注解在框架里的用法

下篇 反射
  5. 反射:获取 Class 6. 反射:操作成员
  7. 反射:创建对象与调用方法 8. 反射:读取泛型与性能代价

1. 注解是什么

注解(Annotation)是加在类、方法、字段等元素上的标签,它本身不做任何事,只是把信息挂上去。真正干活的是读这些标签的代码——编译器、框架、或者你自己写的工具。

注解分两类:标记注解只打个标记(@Override、@Deprecated),元注解给别的注解加配置(@Target、@Retention)。

// 注解写在元素前面,括号里是「元素」
@Override
public String toString() {
    return "User";
}

// 自定义注解长这样
@MyAnnotation(name = "user", count = 3)
public class User {
}

注解的信息在运行期能不能读到,全看 @Retention。这是注解最该先搞清楚的一件事。

注解自己不执行任何逻辑
  写了 @Transactional 而没人去读它,它就是个普通注释
  真正干活的是 Spring 扫描到它之后织进去的那段代理代码
能用注解就用注解,但别指望它替代逻辑
  注解适合表达「声明」,不适合表达「流程」
注解的属性必须有默认值才能省
  int count() default 1;,没写 default 的属性使用时必须显式指定

2. 内置注解

JDK 自带几个最常用的:

// 1. @Override:标记这是重写父类的方法
@Override
public String toString() {
    return "User";
}

// 2. @Deprecated:标记已废弃
@Deprecated(forRemoval = true)
public void oldApi() {
}

// 3. @SuppressWarnings:压制编译警告
@SuppressWarnings("unchecked")
List<String> list = (List<String>) rawList;

// 4. @FunctionalInterface:检查接口是不是只有一个抽象方法
@FunctionalInterface
interface Calc {
    int apply(int a, int b);
}

// 5. @SafeVarargs:泛型可变参数的安全标记
@SafeVarargs
static <T> List<T> of(T... items) {
    return Arrays.asList(items);
}

@Override 是编译器帮你查错的工具:方法名拼错、参数类型不对,编译就报 method does not override or implement a method from a supertype,而不是「新增了一个同名方法,逻辑全错」。

@Deprecated 从 Java 9 开始带两个属性:since 写废弃版本,forRemoval = true 表示将来会被删掉,IDE 会给更强烈的提示。

@Override 在实现接口方法上也要写
  Java 6 起支持,写上它编译器才帮你校验签名
@Deprecated 只是标记,不会阻止你调用
  它会让编译器在调用处出警告,新代码别碰带这个标记的方法
@SuppressWarnings 是止血带不是创可贴
  压掉警告之前先想清楚为什么不安全,多数情况是类型设计出了问题
@FunctionalInterface 值得每个自定义接口都加
  它让「不小心加了第二个抽象方法」在编译期暴露

3. 元注解与自定义注解

元注解是写在注解上的注解,用来定义自定义注解的行为。四个必须知道:

// @Target:能加在哪些元素上
@Target({ElementType.METHOD, ElementType.FIELD})
// @Retention:保留到什么时候
@Retention(RetentionPolicy.RUNTIME)
// @Documented:出现在 javadoc 里
@Documented
// @Inherited:子类能继承父类上的这个注解
@Inherited
// @Repeatable:同一个元素上可以重复标注(Java 8 起)
@Repeatable(Tags.class)

public @interface MyAnnotation {

    // 注解的属性:必须有返回值,可以有默认值
    String name() default "";

    int count() default 1;

    Class<?>[] types() default {};
}

@Retention 三个值决定注解的寿命:

注解的三个生命周期 SOURCE、CLASS、RUNTIME 对比,以及自定义注解常用的四个元注解说明

RetentionPolicy.SOURCE     // 只在源码里,编译就丢,编译器能看见
RetentionPolicy.CLASS      // 写进 class 文件,运行时不读(默认值)
RetentionPolicy.RUNTIME    // 写进 class 文件并且运行时能读

自己写完注解还得写个处理器去读它,注解自己不做事:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int qps() default 10;
}

// 处理器:用反射读出标注了注解的方法
public class RateLimitHandler {
    public static void handle(Object target) throws Exception {
        for (Method m : target.getClass().getDeclaredMethods()) {
            RateLimit limit = m.getAnnotation(RateLimit.class);
            if (limit != null) {
                System.out.println(m.getName() + " 限流 " + limit.qps() + " QPS");
            }
        }
    }
}

要反射读到,自定义注解必须写 @Retention(RUNTIME)
  漏了就是 CLASS 默认值,运行期 getAnnotation 返回 null
  这个坑排查起来很费时间,注解定义时顺手写上
@Target 不写就是「什么都能加」
  加了 METHOD 之后再往类上加会编译报错
@Inherited 只对类有效,而且只认直接父类
  接口上的注解不会被实现类继承,方法上的注解也不参与继承
@Documented 只影响 javadoc,不影响运行时
  自定义注解默认不带它,要出现在文档里得显式加

4. 注解在框架里的用法

日常写业务代码,注解用得最多的就是三类:参数校验、JSON 序列化控制、Controller 分流。

// 1. 参数校验:DTO 上加约束,Controller 入参前加 @Valid
public class StorePageDTO {
    @NotNull(message = "请输入店铺id")
    private Long entityId;

    @Size(max = 64, message = "名称最长 64 个字符")
    private String name;

    @Pattern(regexp = "^\\d+$", message = "只能是数字")
    private String phone;

    @Email(message = "邮箱格式不对")
    private String email;
}

public PageResult<StoreVO> listPage(@Valid @RequestBody StorePageDTO dto) {
    return PageResult.ok(storeService.getStoreListPage(dto));
}

约束注解的包名有版本差异:Spring Boot 3 用 jakarta.validation,Spring Boot 2 用 javax.validation,升级时会整片报「找不到符号」,改的是包名不是用法。

常见约束有:@NotNull(不为 null)、@NotEmpty(不为 null 且长度不为 0)、@NotBlank(去掉首尾空格后不为空)、@Size(长度区间)、@Min / @Max(数值区间)、@Pattern(正则)、@Email、@Past / @Future(日期)。

// 2. JSON 序列化:Jackson 的注解,控制字段名和日期格式
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date businessTime;

@JsonProperty("user_name")
private String userName;

@JsonIgnore                       // 序列化时忽略
private String password;

// 3. Controller 分流
@RestController                    // 等于 @Controller + @ResponseBody,只返回 JSON
public class StoreController {
}

@Controller                            // 可以返回视图,需要视图解析器
public class PageController {
}

@Valid 不加,约束注解一个都不生效
  校验是在参数绑定阶段做的,没触发就不会执行
嵌套对象要加 @Valid
  只加外层参数上的 @Valid,内部字段的约束不会被检查,内部对象上也要加
@NotNull 和 @NotEmpty 不是一回事
  @NotEmpty 对字符串查的是长度,对集合查的是元素个数
  @NotBlank 会先去掉首尾空格再判空,只用它就行
@RestController 不能返回页面
  它没有视图解析器,返回 HTML 字符串会当 JSON 吐出去
@JsonFormat 的 pattern 里别写错空格
  写成 yyyy-MM-dd 少一个空格,格式化出来的就是空字符串
正则里的反斜杠在 Java 字符串里要转义两次
  \\d 才是传给正则的 \d

5. 反射:获取 Class

反射的核心是 Class 对象——每个类在 JVM 里都对应一个 Class 实例,它就是这个类的「运行时身份证」。拿到它,就能查这个类的所有信息。

获取 Class 有四条路,日常用第一条就够:

public class ClassWays {
    public static void main(String[] args) throws Exception {
        User user = new User();

        // 1. 对象.getClass():从实例拿
        Class<?> c1 = user.getClass();

        // 2. 类名.class:静态拿,不需要实例
        Class<?> c2 = User.class;

        // 3. Class.forName("全限定名"):按名字拿,会触发类的初始化
        Class<?> c3 = Class.forName("com.example.demo.User");

        // 4. 基本类型的 Class:走包装类的 TYPE 属性
        Class<?> c4 = int.class;          // 等价于 Integer.TYPE
        Class<?> c5 = void.class;

        // 数组的 Class:几维是几个方括号
        Class<?> c6 = String[].class;
        Class<?> c7 = int[][].class;
    }
}

拿到 Class 之后,类名、包名、父类、接口、注解都能查:

Class<?> c = User.class;
c.getName();          // com.example.demo.User,全限定名
c.getSimpleName();   // User,简单类名
c.getPackageName();  // com.example.demo
c.getSuperclass();   // 父类,没有就是 null
c.getInterfaces();   // 实现的接口数组
c.isInterface();     // 是不是接口
c.isArray();         // 是不是数组
c.getComponentType();// 数组的元素类型

getName 和 getSimpleName 不是一回事
  前者带包名后者不带,打日志用后者,反射拼类名用前者
Class.forName 会执行静态代码块
  只想拿类型不想初始化,用 Class.forName(name, false, loader)
数组的 Class 只看维度和元素类型
  int[10] 和 int[100] 的 Class 是同一个
getSuperclass 对接口返回 null
  接口的父类型要用 getInterfaces 查
getClass 返回的是运行时类型
  父类引用指向子类实例时,拿到的是子类的 Class

6. 反射:操作成员

拿到 Class 就能查字段、方法和构造器。这里有一条贯穿所有反射 API 的规律:带 Declared 的看本类全部(不管权限),不带 Declared 的只给 public,而且会带上父类的。

public class MemberDemo {
    public static void main(String[] args) throws Exception {
        Class<?> c = User.class;

        // 字段
        Field[] publicFields = c.getFields();             // 只有 public,含继承的
        Field[] allFields = c.getDeclaredFields();        // 本类全部字段,含 private
        Field one = c.getDeclaredField("name");           // 按名字取指定的

        // 方法
        Method[] publicMethods = c.getMethods();          // 只有 public,含继承的
        Method[] allMethods = c.getDeclaredMethods();     // 本类全部方法
        Method getter = c.getMethod("getName");           // 按名字和参数类型取
        Method setter = c.getMethod("setName", String.class);

        // 构造器
        Constructor<?>[] pubCtors = c.getConstructors();
        Constructor<?>[] allCtors = c.getDeclaredConstructors();
        Constructor<?> ctor = c.getDeclaredConstructor(String.class, int.class);

        // 读字段值
        Object value = one.get(new User("张三", 20));
    }
}

取方法时参数类型必须精确匹配:int.class 和 Integer.class 是两个东西,写错一个就抛 NoSuchMethodException。

getFields 和 getDeclaredFields 差在两处
  一个只给 public,一个连 private 都给
  一个含继承来的,一个只含本类声明的
私有字段读值要先 setAccessible(true)
  不然 field.get() 抛 IllegalAccessException
参数类型必须精确匹配
  int.class 不等于 Integer.class,写错报「找不到方法」
反射调用抛的异常被包了一层
  方法体里的异常在 InvocationTargetException 里,真实原因在 getCause()
静态方法不需要实例
  invoke 的第一个参数传 null

7. 反射:创建对象与调用方法

反射创建对象有两个 API,现在只该用后者:

一次反射调用的五步链路与异常包装关系,以及 JDK 16 起模块化拒绝非法反射访问的报错

public class ReflectCreate {

    public static void main(String[] args) throws Exception {
        // 写法一:Class.newInstance(),Java 9 起已废弃
        // User u1 = (User) User.class.newInstance();

        // 写法二:getDeclaredConstructor().newInstance(),当前标准写法
        User u2 = (User) User.class.getDeclaredConstructor().newInstance();

        // 带参构造
        User u3 = (User) User.class.getDeclaredConstructor(String.class, int.class)
                .newInstance("张三", 20);

        // 调用方法
        Method setName = User.class.getMethod("setName", String.class);
        setName.invoke(u3, "李四");

        Method getName = User.class.getMethod("getName");
        System.out.println(getName.invoke(u3));     // 李四
    }
}

Class.newInstance() 从 Java 9 起被标记 @Deprecated,原因有两个:它只能调无参构造器,而且构造器抛出的受检异常会绕过编译器的异常检查直接往外抛。getDeclaredConstructor().newInstance() 把异常包进 InvocationTargetException,才是符合规范的写法。

调用私有方法需要 setAccessible(true),这一步叫「暴力反射」:

Method secret = User.class.getDeclaredMethod("secret", int.class);
secret.setAccessible(true);              // 打开访问权限
Object result = secret.invoke(u3, 1);    // 调用

Class.newInstance() 从 Java 9 起废弃
  只调无参构造器,异常还绕过编译检查
  统一写 getDeclaredConstructor().newInstance()
私有成员要 setAccessible(true)
  顺序不能错:先拿到 Member 对象,再 setAccessible,最后 invoke
JDK 16 起对 JDK 内部类的反射默认是拒绝的
  访问 java.base 里的私有成员会抛 InaccessibleObjectException
  要访问得加 --add-opens java.base/java.lang=ALL-UNNAMED
  自己写的类不受影响,模块限制只管 JDK 内部
异常被包了一层
  方法体里抛的异常在 InvocationTargetException 里,真实原因在 getCause()
  不取 getCause 的话日志里只会看到一层包装,看不出真正出错的地方

8. 反射:读取泛型与性能代价

泛型在运行时被擦除,普通反射读到的是 Object,拿不到 Map<String, User> 里的具体类型。想读到真实类型,得用泛型专用的一套 API。

public class GenericReflect {
    public void test(Map<String, List<User>> map, List<User> list) {
    }

    public Map<String, List<User>> build() {
        return null;
    }

    public static void main(String[] args) throws Exception {
        Class<?> c = GenericReflect.class;

        // 方法参数的真实泛型
        Method test = c.getMethod("test", Map.class, List.class);
        Type[] paramTypes = test.getGenericParameterTypes();
        // paramTypes[0] 是 Map<String, List<User>>

        if (paramTypes[0] instanceof ParameterizedType p) {
            Type[] args = p.getActualTypeArguments();     // [String, List<User>]
        }

        // 返回值的真实泛型
        Type returnType = c.getMethod("build").getGenericReturnType();
    }
}

反射的代价是实打实的:每次调用都要做参数检查和访问权限检查,比直接调用慢一个数量级。

public class PerfDemo {
    public static void main(String[] args) throws Exception {
        User user = new User("张三", 20);
        Method getName = User.class.getMethod("getName");
        getName.setAccessible(true);

        int n = 10_000_000;

        long t1 = System.currentTimeMillis();
        for (int i = 0; i < n; i++) {
            user.getName();                    // 直接调用
        }
        long t2 = System.currentTimeMillis();

        for (int i = 0; i < n; i++) {
            getName.invoke(user);              // 反射调用
        }
        long t3 = System.currentTimeMillis();

        System.out.println("直接调用 " + (t2 - t1) + " ms");
        System.out.println("反射调用 " + (t3 - t2) + " ms");
    }
}

这个差距的正确用法是:反射只在「必须动态」的地方用——框架启动时确定一次调用目标、插件加载、序列化映射。热点路径上逐次调用就是自找麻烦。

💡 补充几个容易忽略的:
读泛型要认准 ParameterizedType
  拿到的是 Type 不是 Class,得先判断类型再强转
泛型是擦除的,反射读到的必须是声明处的类型
  继承来的泛型在子类里被抹平了,读不到
反射的异常都会包一层
  InvocationTargetException 里才是真实异常,getCause() 一定要取
热点路径上别用反射
  要频繁调用就把反射结果缓存成 Method 字段,或者用 MethodHandle
反射能绕过私有,但绕不过模块边界
  JDK 16 起 JDK 内部类默认拒绝非法反射访问
业务代码里手写反射的场合真的不多
  Spring 的依赖注入、MyBatis 的 Mapper 代理、JUnit 的断言,全靠反射
  读懂这些框架的源码,反射是绕不过去的一节

🔥 一句话总结:注解是挂在元素上的元数据,自己什么都不干,@Retention(RUNTIME) 决定运行期能不能读到,@Target 决定能加在哪,@Inherited 只对类的直接父类有效;反射靠 Class 对象在运行期查成员,带 Declared 的看本类全部权限、不带的只给 public 且含父类;创建对象用 getDeclaredConstructor().newInstance(),Class.newInstance() 从 Java 9 起废弃,私有成员要 setAccessible(true),JDK 16 起访问 JDK 内部会抛 InaccessibleObjectException;读泛型靠 ParameterizedType,反射调用比直接调用慢一个数量级,热点路径别用。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!