跳到正文

反射、注解、泛型与 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。

把一门技术讲透到既懂也会用