主题
反射、注解、泛型与 SPI
一句话:反射把「编译期写死的类型」变成「运行时才确定的元数据」,框架因此能实例化未知类、读取注解、发现实现;代价是类型安全变弱、访问检查变慢,泛型在运行时还会被擦除。
适用 JDK 17 / JDK 21。主线是反射与动态能力,注解、泛型和 SPI 是它最常见的三个应用点。
为什么需要
框架在编译时并不知道用户会写哪些类。Spring 要扫描 @Component,JUnit 要找到 @Test 方法,JDBC 要在 classpath 上发现驱动。这些都不能靠 new 写死。
反射提供了「按名字拿类、按名字调方法」的能力;注解把配置写进字节码;SPI 把「接口在核心、实现在外部」的发现过程标准化。四者合起来,才有「引入一个 jar 就自动生效」的框架体验。
核心机制
反射:从 Class 到调用
Class 是运行时类型的入口。常用路径:
text
Class.forName("com.example.User") // 会触发初始化
ClassLoader.loadClass("...") // 默认不初始化
obj.getClass() // 已有实例
User.class // 编译期字面量拿到 Class 后可以取 Constructor、Method、Field,再 newInstance、invoke、get/set。setAccessible(true) 关闭 Java 访问检查,但 JDK 9+ 模块系统仍可能拦截,需要 --add-opens 或 opens 声明。
反射调用慢在哪:参数装箱、可变参数数组、访问权限检查、调用点无法内联。JDK 18 的 JEP 416 用方法句柄重写了核心反射,性能比旧实现好,但仍不如直接调用。热路径要么缓存 Method 对象,要么用 MethodHandle、LambdaMetafactory 或代码生成。
注解:元数据,不是逻辑
注解本身不执行任何代码。它的效果来自两处:编译期注解处理器(如 Lombok、MapStruct),或运行时通过反射读取(如 Spring)。
@Retention 决定注解保留到哪一阶段:
| 策略 | 保留位置 | 能否运行时反射读取 | 典型用途 |
|---|---|---|---|
SOURCE | 仅源码,编译后丢弃 | 否 | @Override、Lombok 的 @Data |
CLASS | 字节码,默认策略 | 否(除非字节码工具读取) | 编译期/字节码增强 |
RUNTIME | 字节码 + 运行时常量池 | 是 | Spring、JUnit、Jackson |
所以「Lombok 靠运行时反射生成 getter」是错的:Lombok 是编译期注解处理器,@Data 的保留策略是 SOURCE,编译完注解就不在了,生成的方法已经写进 class 文件。
@Target 限制可标注的位置,@Inherited 只对类注解生效,@Repeatable 允许同一位置写多次。
泛型:编译期检查,运行时擦除
泛型参数在编译后会被擦除到上界(无界是 Object),所以运行时不能 new T()、不能 T[]、不能 instanceof List<String>。编译器会在需要的地方插入强制转换,并为协变返回、泛型方法重写生成桥接方法。
需要保留泛型信息时,用「类型令牌」:匿名子类把泛型参数写进父类的签名,运行时通过 getGenericSuperclass() 读取。Jackson 的 TypeReference、Spring 的 ParameterizedTypeReference 都是这个套路。
PECS 记的是通配符方向:生产者用 ? extends T(只能读),消费者用 ? super T(只能写)。它不是性能优化,而是把「协变/逆变」限制在类型安全的方向上。
SPI:把实现发现标准化
Java SPI 的约定是:接口在核心包,实现在外部 jar,提供者在 META-INF/services/<接口全限定名> 文件里写实现类名。ServiceLoader.load(Interface.class) 用线程上下文类加载器找到所有实现并实例化。
JDBC 4 起,驱动 jar 自带 META-INF/services/java.sql.Driver,DriverManager 通过 ServiceLoader 自动发现驱动,不需要 Class.forName。JDK 9 模块路径下也可以用 provides ... with ... 声明。
注意:Spring Boot 的自动装配不是 Java SPI。Boot 2.x 读 META-INF/spring.factories,Boot 3.x 改用 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports;它是 Spring 自己的约定,只是思想相似。
最小上手
运行时读取自定义注解并调用方法:
java
import java.lang.annotation.*;
import java.lang.reflect.*;
public class ReflectDemo {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Route { String value(); }
static class Controller {
@Route("/hello")
public void hello() { System.out.println("called"); }
}
public static void main(String[] args) throws Exception {
for (Method m : Controller.class.getDeclaredMethods()) {
Route route = m.getAnnotation(Route.class);
if (route != null) {
System.out.println(route.value() + " -> " + m.getName());
m.invoke(new Controller());
}
}
}
}bash
javac ReflectDemo.java && java ReflectDemo
# /hello -> hello
# called把 @Retention 改成 SOURCE 或 CLASS,getAnnotation 会返回 null;这是注解「写了但没生效」最常见的原因。
生产动作
- 缓存反射结果:
Method、Field、Constructor在启动时解析一次,放进ConcurrentHashMap;不要在每次请求里getDeclaredMethod。 - 热路径避开反射:Bean 拷贝用 MapStruct 生成代码,调用分发用
MethodHandle或LambdaMetafactory;JDK 18+ 的核心反射虽已优化,仍不适合百万 QPS 的内层循环。 - 模块访问:JDK 17 强封装后,反射访问 JDK 内部 API 需要
--add-opens java.base/java.lang=ALL-UNNAMED;先确认没有替代 API,再开权限。 - 注解保留策略:框架要运行时读,必须
RUNTIME;只做编译期生成,用SOURCE减少字节码体积。写完注解先验证getAnnotation不是 null。 - SPI 排查:
ServiceLoader找不到实现时,检查META-INF/services文件名是否等于接口全限定名、文件内容是否是实现类全名、实现类是否有 public 无参构造、是否被类加载器隔离。 - 启动期扫描:类路径扫描会读大量 class 文件,启动慢时先看扫描范围;不要在生产每次请求触发
Class.forName。
边界
- 反射不是万能解耦:能用接口和多态解决的,不要用反射;反射把编译错误推迟到运行期。
- 注解不是配置中心:注解在编译期固定,不能动态改;需要热更新时用配置或数据库。
- 泛型擦除不可绕过:
List<String>和List<Integer>运行时是同一个Class;需要元素类型时显式传Class<T>或类型令牌。 - SPI 不是依赖注入:
ServiceLoader只负责发现和实例化,不管理生命周期、不注入依赖;需要这些能力用 Spring 或 CDI。 setAccessible不是安全开关:它能绕过 Java 访问检查,但不能绕过模块封装和 SecurityManager(若启用);生产环境慎用。
讲清楚
反射让框架在运行时按名字拿到类、方法和字段,注解把配置写进字节码,泛型在编译期做类型检查、运行时擦除,SPI 用
META-INF/services把接口和实现分离。四者合起来支撑了「引入 jar 就生效」的框架体验。关键边界有三个:注解只有
RUNTIME才能被反射读到,Lombok 是编译期处理器不是运行时反射;泛型运行时被擦除,需要类型信息时用类型令牌;SPI 是发现机制,不是依赖注入。生产上缓存反射元数据、热路径用方法句柄或代码生成、JDK 17 反射内部 API 要显式--add-opens。