Java 注解与反射:自定义注解、参数校验与运行时取元数据
📝 笔记摘要: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 三个值决定注解的寿命:

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,现在只该用后者:

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,反射调用比直接调用慢一个数量级,热点路径别用。
