泛型、反射与注解
开篇:Java 的"超能力"三件套
想象你是一个厨师。泛型就像一套万能模具,不管做蛋糕还是饼干,模具的形状保证了产品的规格;反射就像X光透视,能在运行时看穿任何对象的内部结构;注解就像食品标签,虽然不影响味道,但告诉处理系统该怎么对待这个食品。
这三个特性撑起了 Spring、MyBatis 等框架的底层魔法。理解它们,你就能看懂框架源码中那些"玄学"代码。
一、泛型:让代码更安全
1.1 没有泛型的痛苦
在 JDK 5 之前,集合里什么都能塞,取出来的时候全靠强转,错了就是运行时炸弹:
// JDK 5 之前的写法
List list = new ArrayList();
list.add("hello");
list.add(123); // 编译不报错!
String s = (String) list.get(1); // 运行时 ClassCastException有了泛型之后,编译器帮你把关:
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123); // 编译直接报错,问题扼杀在摇篮里
String s = list.get(0); // 不需要强转一句话:泛型把类型检查从运行时提前到了编译时。
泛型不仅能用在集合上,还能用在自定义类和方法上:
// 泛型类
public class Result<T> {
private T data;
private String message;
public Result(T data, String message) {
this.data = data;
this.message = message;
}
public T getData() { return data; }
}
// 泛型方法
public <T> T getFirst(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}泛型中常见的字母约定:T(Type)、E(Element)、K(Key)、V(Value)、?(不确定类型)。这些字母本身没有强制含义,但遵循约定能让代码更容易读懂。
1.2 类型擦除:编译时的魔法
很多人以为 List<String> 和 List<Integer> 在 JVM 里是两个不同的类。其实不是。
Java 的泛型是通过类型擦除实现的——编译器在编译时检查完类型安全后,就把泛型信息"擦掉"了。你可以把它想象成一个安检门:进门前检查得严严实实,一旦进了门(变成字节码),所有人都一样。
// 你写的代码
public class Box<T> {
private T value;
public T getValue() { return value; }
}
// 编译后的字节码(伪代码)
public class Box {
private Object value; // T 变成了 Object
public Object getValue() { return value; }
}这意味着:
List<String>和List<Integer>在运行时是同一个类- 无法用
new T()或new T[]直接创建泛型实例 - 无法用
instanceof判断泛型类型
可以用一段代码验证类型擦除:
List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
System.out.println(strList.getClass() == intList.getClass()); // true!因为类型擦除的存在,你甚至可以通过反射"骗过"泛型检查,往 List<Integer> 里塞一个 String:
List<Integer> list = new ArrayList<>();
Method addMethod = list.getClass().getMethod("add", Object.class);
addMethod.invoke(list, "我是字符串"); // 编译通过,运行也不报错
System.out.println(list.get(0)); // 输出:我是字符串为什么 Java 不像 C++ 那样为每种泛型生成一份代码?因为 Java 要保持向后兼容。JDK 5 的泛型代码必须能跑在旧的 JVM 上,类型擦除是当时最务实的选择。C++ 的模板会为每种类型生成独立的代码,虽然运行时更高效,但会导致代码膨胀。
1.3 通配符 ? extends 与 ? super
这是泛型中最容易让人晕的部分,但记住 PECS 原则(Producer Extends, Consumer Super)就够了。
用一个水果篮的例子来理解:
// 从篮子里"取"水果 → 用 extends(生产者)
public void eat(List<? extends Fruit> basket) {
Fruit f = basket.get(0); // 能取,一定是 Fruit 或子类
// basket.add(new Apple()); // 不能放!编译器不知道篮子的具体类型
}
// 往篮子里"放"水果 → 用 super(消费者)
public void fill(List<? super Apple> basket) {
basket.add(new Apple()); // 能放,篮子至少能装 Apple
// Apple a = basket.get(0); // 取出来只能当 Object,不安全
}为什么 extends 不能写?假设篮子实际上是 List<Orange>,你往里面放一个 Apple,取出来当 Orange 用就炸了。编译器没法确定篮子的具体类型,所以干脆禁止写入。
为什么 super 不能安全地读?假设篮子是 List<Object>,取出来的可能是任何东西,编译器无法保证它是 Apple。
简单记忆:要读用 extends,要写用 super,又读又写就别用通配符。
另外要注意 List<?> 和 List<Object> 的区别:List<?> 是未知类型的列表,可以接受 List<String>、List<Integer> 的赋值;而 List<Object> 是明确的 Object 类型列表,不能接受 List<String> 的赋值(泛型不支持协变)。
二、反射:运行时的透视镜
2.1 什么是反射?
正常写代码,你在编译时就知道要调用哪个类的哪个方法。但反射不一样——它能在运行时才决定操作哪个类、调用哪个方法,就像给程序装了一副X光眼镜,能看穿任何对象的内部结构。
// 正常调用:编译时确定
User user = new User();
user.setName("Tom");
// 反射调用:运行时确定
Class<?> clazz = Class.forName("com.example.User");
Object obj = clazz.getDeclaredConstructor().newInstance();
Method method = clazz.getMethod("setName", String.class);
method.invoke(obj, "Tom");两段代码做的事情完全一样,但反射版本在编译时不需要知道 User 这个类的存在。这种"晚绑定"能力让框架可以处理任意用户定义的类——Spring 不需要提前认识你的 UserService,但它能在运行时创建它、注入依赖、调用方法。
2.2 反射的核心 API
反射的入口是 Class 对象。JVM 为每个加载的类维护一个唯一的 Class 实例,它记录了这个类的全部结构信息。
获取 Class 对象的三种方式:
// 方式一:通过类名字符串(最常用于框架,类可以不存在于编译时)
Class<?> c1 = Class.forName("java.lang.String");
// 方式二:通过对象实例
Class<?> c2 = "hello".getClass();
// 方式三:通过 .class 字面量(编译时确定,最安全)
Class<?> c3 = String.class;拿到 Class 对象后,你可以做这些事:
| 操作 | API | 说明 |
|---|---|---|
| 获取字段 | getDeclaredFields() | 包含私有字段,不含父类 |
| 获取方法 | getDeclaredMethods() | 包含私有方法,不含父类 |
| 获取构造器 | getDeclaredConstructors() | 包含私有构造器 |
| 创建实例 | constructor.newInstance() | 动态创建对象 |
| 调用方法 | method.invoke(obj, args) | 动态调用方法 |
| 读写字段 | field.get(obj) / field.set(obj, val) | 可访问私有字段 |
注意 getFields() 和 getDeclaredFields() 的区别:前者只返回 public 字段(含父类),后者返回所有字段(不含父类)。Methods 同理。
访问私有成员时需要先调用
setAccessible(true),这就是反射"破坏封装"的由来。封装是 Java 的基本原则,但反射提供了一个"后门"让框架在必要时能打破封装的桎梏。
2.3 反射的应用场景
你不一定会在业务代码里写反射,但你每天都在间接使用它:
Spring 的 IoC 容器在启动时,通过反射扫描所有带 @Component 注解的类,然后用 newInstance() 创建实例并注入依赖。你写一个 @Autowired 就能用,背后全是反射在干活。
Jackson 在序列化时,用反射遍历对象的所有字段或 getter 方法,把它们的值转成 JSON。反序列化时则反过来,用反射创建对象并设置字段值。
2.4 反射的性能代价
反射比直接调用慢,主要原因有四个:
- 无法 JIT 优化:反射调用是动态解析的,JVM 没法做内联等优化
- 装箱拆箱开销:参数要打包成
Object[],返回值也要拆箱 - 安全检查:每次调用都要检查方法可见性和参数类型
- 方法查找:需要遍历方法数组来定位目标方法
不过,现代 JVM 对反射做了很多优化(如 MethodHandle、生成字节码绕过反射),在框架启动阶段大量使用反射是完全可以接受的。真正的性能敏感路径上,框架通常会缓存反射结果或使用字节码生成技术来避开反射。
反射还有一些副作用需要注意:
- 破坏封装:可以访问 private 字段和方法,单例模式也能被反射破坏
- 代码可读性差:反射代码比直接调用难理解得多
- 编译时无法检查:方法名写错了编译不报错,运行时才炸
所以原则是:业务代码尽量不用反射,框架和中间件才用。
三、注解:代码的标签
3.1 内置注解与元注解
注解就是贴在代码上的标签。它本身不做事,但可以被编译器或框架读取并据此执行相应逻辑。
Java 内置了几个常用注解:
@Override—— 告诉编译器这是重写方法,写错方法名会报错@Deprecated—— 标记方法已过时,IDE 会显示删除线@SuppressWarnings—— 关闭编译器的特定警告@FunctionalInterface—— 标记函数式接口(只能有一个抽象方法)
而元注解是"注解的注解",用来定义新注解的行为:
| 元注解 | 作用 | 常用值 |
|---|---|---|
@Target | 注解能贴在哪里 | TYPE, METHOD, FIELD, PARAMETER |
@Retention | 注解保留到什么阶段 | SOURCE, CLASS, RUNTIME |
@Documented | 是否出现在 Javadoc 中 | - |
@Inherited | 子类是否继承父类的注解 | - |
其中 @Retention 最关键,决定了注解的"寿命":
框架中的注解(如 Spring 的 @Component、@Autowired)几乎都是 RUNTIME 级别的,因为框架需要在运行时通过反射读取它们。
@Target 限制了注解能贴在什么地方:
// 只能贴在方法上
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log { }
// 可以贴在类和方法上
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth { }3.2 自定义注解实战
我们来写一个简单的 @Log 注解,用来自动打印方法的执行时间:
// 第一步:定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
String value() default "";
}
// 第二步:在业务方法上使用
public class OrderService {
@Log("创建订单")
public void createOrder(String orderId) {
// 业务逻辑...
}
}注解本身只是一个标记,真正的逻辑需要通过反射来读取并执行:
// 第三步:通过反射读取注解(框架内部做的事)
Method method = OrderService.class.getMethod("createOrder", String.class);
if (method.isAnnotationPresent(Log.class)) {
Log log = method.getAnnotation(Log.class);
System.out.println("即将执行:" + log.value());
// 记录开始时间,执行方法,计算耗时...
}在实际项目中,Spring AOP 会帮你把第三步自动化——你只需要写一个切面类,就能拦截所有带 @Log 注解的方法。注解 + 反射 + 动态代理,三位一体,构成了 Spring 框架的核心魔法。
四、动态代理
4.1 JDK 动态代理 vs CGLIB
代理模式的核心思想:在不修改原始代码的情况下,给方法增加额外的功能(如日志、事务、权限检查)。
静态代理需要手写代理类,有多少接口就要写多少代理,太累了。动态代理在运行时自动生成代理类,省心省力。
JDK 动态代理——基于接口:
// 1. 定义接口
public interface UserService {
void save(String name);
}
// 2. 实现类
public class UserServiceImpl implements UserService {
public void save(String name) {
System.out.println("保存用户: " + name);
}
}
// 3. 创建动态代理
UserService proxy = (UserService) Proxy.newProxyInstance(
UserService.class.getClassLoader(),
new Class[]{UserService.class},
(proxyObj, method, args) -> {
System.out.println("--- 前置增强 ---");
Object result = method.invoke(new UserServiceImpl(), args);
System.out.println("--- 后置增强 ---");
return result;
}
);
proxy.save("Tom");
// 输出:--- 前置增强 ---
// 保存用户: Tom
// --- 后置增强 ---CGLIB 动态代理——基于继承,无需接口:
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserServiceImpl.class); // 直接代理实现类
enhancer.setCallback((MethodInterceptor) (obj, method, args, methodProxy) -> {
System.out.println("--- CGLIB 前置 ---");
Object result = methodProxy.invokeSuper(obj, args);
System.out.println("--- CGLIB 后置 ---");
return result;
});
UserServiceImpl proxy = (UserServiceImpl) enhancer.create();
proxy.save("Tom");两者的核心区别:
| 维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 原理 | 基于接口,运行时生成实现类 | 基于继承,运行时生成子类 |
| 要求 | 目标类必须实现接口 | 目标类不能是 final |
| 性能 | 创建快,调用时需要反射 | 创建慢(生成字节码),调用快 |
| 依赖 | JDK 内置,无需额外依赖 | 需要引入 cglib 库 |
| final 方法 | 不受影响(代理接口方法) | 无法代理 final 方法 |
4.2 代理在 Spring 中的应用
Spring AOP 的核心就是动态代理。它的选择策略很简单:
你平时用的 @Transactional、@Cacheable、@Async 等注解,背后都是代理在工作。当你调用一个带 @Transactional 的方法时,实际上调的是代理对象的方法,代理在真正执行前开启事务,执行后提交或回滚。
经典坑:在同一个类中,方法 A 调用方法 B,即使 B 加了
@Transactional,事务也不会生效——因为 A 调的是this.B(),绕过了代理对象。解决方案是注入自身的代理对象(@Autowired自己),或将方法提取到另一个 Bean 中。
理解了动态代理,你也就理解了为什么 Spring 的很多功能"只在 Bean 之间调用时才生效"——所有的魔法都发生在代理层,绕过代理就没有魔法。
五、常见面试题精选
Q1:Java 泛型是怎么实现的?有什么局限性?
通过类型擦除实现。编译后泛型信息被擦除,List<String> 和 List<Integer> 在运行时是同一个类。局限性包括:不能 new T()、不能用基本类型作为类型参数(只能用包装类)、不能对泛型类型用 instanceof、泛型方法不能仅靠泛型参数类型差异来重载。
Q2:? extends T 和 ? super T 的区别是什么?
? extends T 表示上界,只能读不能写(生产者);? super T 表示下界,只能写不能安全读(消费者)。记住 PECS 原则:Producer Extends, Consumer Super。Collections.copy(dest, src) 就是典型例子:src 用 ? extends T(生产者),dest 用 ? super T(消费者)。
Q3:反射为什么慢?实际项目中应该怎么用?
慢的原因:无法 JIT 优化、需要装箱拆箱、每次调用有安全检查、需要遍历方法数组。实际使用中,框架通常会缓存 Class/Method 对象来降低开销,或使用 MethodHandle / 字节码生成替代高频反射调用。业务代码中应尽量避免使用反射。
Q4:JDK 动态代理和 CGLIB 的区别?Spring 怎么选?
JDK 代理基于接口、CGLIB 基于继承。JDK 代理要求目标类必须实现接口,CGLIB 要求目标类不能是 final。Spring 默认策略:目标类有接口用 JDK 代理,没接口用 CGLIB。Spring Boot 2.x 起默认全部使用 CGLIB(通过 spring.aop.proxy-target-class=true)。
Q5:为什么 Spring 同类方法调用 @Transactional 不生效?
因为 @Transactional 依赖动态代理。同类内部调用走的是 this 引用而非代理对象,代理拦截不到。解决方案有三种:注入自身代理对象、将方法提取到另一个 Bean 中、使用 AopContext.currentProxy() 获取当前代理。
小结
泛型让代码安全,反射让代码灵活,注解让代码优雅,动态代理把它们粘合在一起。你不需要在业务代码里天天写反射和代理,但理解它们,你就能看懂框架在背后做了什么,也能在面试中讲出"知其然更知其所以然"的深度。