Java基础知识梳理(五):泛型、反射、注解与Spring底层机制

泛型、反射和注解是Java框架开发中的重要基础。

泛型主要解决类型安全和代码复用问题;反射允许程序在运行期间获取和操作类的信息;注解用于给代码添加结构化元数据。Spring、MyBatis等框架正是将反射和注解结合起来,完成对象创建、依赖注入、方法拦截和配置解析。

一、什么是Java泛型

泛型允许在定义类、接口或方法时,把具体类型作为参数传入。

例如,不使用泛型时,可以把任何对象放入集合:

复制代码
List list = new ArrayList();
list.add("Java");
list.add(100);

取出数据时,程序需要手动进行类型转换:

复制代码
String value = (String) list.get(0);

如果转换类型不正确,就会在运行时抛出ClassCastException

使用泛型后,可以明确集合中允许保存的数据类型:

复制代码
List<String> list = new ArrayList<>();
list.add("Java");

此时编译器能够在编译阶段进行类型检查,避免把Integer等其他类型放入集合。

泛型的主要作用包括:

  • 提高类型安全性;

  • 减少强制类型转换;

  • 提高代码复用能力;

  • 将部分运行时错误提前到编译阶段发现。

二、Java泛型为什么采用类型擦除

Java泛型主要通过类型擦除实现。

所谓类型擦除,是指泛型类型信息主要用于编译阶段的类型检查。编译完成后,大部分具体泛型参数不会直接保留在运行时对象中。

例如:

复制代码
List<String> stringList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();

在运行期间,它们的实际类型都是ArrayList

复制代码
System.out.println(
        stringList.getClass() == integerList.getClass()
);

结果为:

复制代码
true

JVM通常不会把它们分别看成ArrayList<String>ArrayList<Integer>两个不同的运行时类。

1. 为什么使用类型擦除

Java在早期版本中没有泛型。泛型是在JDK 5中加入的。

为了让引入泛型后的代码仍然能够与旧版本Java代码和旧类库兼容,Java没有要求JVM重新设计一套完全不同的泛型类型系统,而是主要在编译器层面实现泛型。

编译器负责完成以下工作:

  1. 检查泛型类型是否正确;

  2. 对泛型代码进行必要的类型转换;

  3. 将泛型参数擦除为上界类型或Object

  4. 在需要的位置自动插入强制类型转换。

例如:

复制代码
public class Box<T> {

    private T value;

    public void setValue(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

如果T没有明确上界,类型擦除后可以近似理解为:

复制代码
public class Box {

    private Object value;

    public void setValue(Object value) {
        this.value = value;
    }

    public Object getValue() {
        return value;
    }
}

调用者使用Box<String>时,编译器会在取出数据的位置自动插入类型转换。

如果泛型具有上界:

复制代码
public class NumberBox<T extends Number> {
    private T value;
}

类型擦除后,T会被擦除为它的上界Number,而不是Object

2. 类型擦除带来了什么好处

类型擦除使泛型代码能够兼容旧版本代码,也避免了JVM为每一种泛型参数分别创建新的类。

例如,程序不需要分别生成:

复制代码
ArrayList<String>
ArrayList<Integer>
ArrayList<User>

对应的三个运行时类,而是复用同一个ArrayList类。

这样可以减少运行时类型数量,也保持了Java二进制兼容性。

三、类型擦除有哪些限制

类型擦除提高了兼容性,但也带来了一些限制。

1. 运行时不能直接获取具体泛型参数

例如:

复制代码
List<String> list = new ArrayList<>();

运行时通过list.getClass()通常只能得到ArrayList,不能直接得到当前对象的泛型参数一定是String

因此,下面的判断是不允许的:

复制代码
if (obj instanceof List<String>) {
}

可以判断:

复制代码
if (obj instanceof List<?>) {
}

因为运行时无法可靠地区分List<String>List<Integer>

需要注意的是,类、字段、方法声明中的部分泛型签名可能保存在Class文件的元数据中,并可以通过反射读取。但普通对象本身通常不会保存"这个实例是List<String>"这样的完整信息。

2. 不能直接创建泛型类型对象

下面的代码不能通过编译:

复制代码
T value = new T();

因为类型擦除后,JVM不知道应该调用哪个具体类的构造方法。

通常需要传入Class<T>或对象创建函数:

复制代码
public static <T> T create(Class<T> type)
        throws ReflectiveOperationException {
    return type.getDeclaredConstructor().newInstance();
}

3. 不能直接创建泛型数组

下面的代码同样不合法:

复制代码
T[] array = new T[10];

Java数组在运行时需要知道具体元素类型,而泛型类型经过擦除后无法提供这一信息。

实际开发中可以使用集合,或者通过反射创建数组:

复制代码
@SuppressWarnings("unchecked")
T[] array = (T[]) Array.newInstance(type, 10);

4. 静态成员不能使用类的泛型参数

下面的写法不能通过编译:

复制代码
class Box<T> {
    private static T value;
}

静态字段属于类,而不是某个具体对象。

同一个Box类可以创建Box<String>Box<Integer>对象。如果静态字段使用T,JVM无法确定它究竟应该是String还是Integer

5. 基本数据类型不能直接作为泛型参数

下面的写法是不允许的:

复制代码
List<int> numbers;

泛型参数必须是引用类型,因此需要使用包装类:

复制代码
List<Integer> numbers;

四、<? extends T><? super T>有什么区别

通配符通常用于增强泛型方法的适用范围。

最常见的两种形式是:

复制代码
<? extends T>
<? super T>

可以使用PECS原则记忆:

复制代码
Producer Extends,Consumer Super

也就是:

  • 主要从容器中读取数据时,使用extends

  • 主要向容器中写入数据时,使用super

五、<? extends T>:适合读取

假设有如下类型关系:

复制代码
class Animal {
}

class Dog extends Animal {
}

class Cat extends Animal {
}

方法接收:

复制代码
List<? extends Animal> animals

表示这个集合的实际类型可能是:

复制代码
List<Animal>
List<Dog>
List<Cat>

从这个集合中读取数据是安全的:

复制代码
Animal animal = animals.get(0);

因为无论集合实际保存的是AnimalDog还是Cat,都可以向上转型为Animal

但是,一般不能向其中添加具体对象:

复制代码
animals.add(new Dog()); // 编译错误
animals.add(new Cat()); // 编译错误

原因是编译器不知道这个集合真实类型究竟是List<Dog>还是List<Cat>

如果真实类型是List<Cat>,向其中加入Dog就会破坏类型安全。

因此,extends的特点是:

  • 可以安全读取为上界类型;

  • 不能随意写入具体子类对象;

  • 适合作为数据生产者。

六、<? super T>:适合写入

例如:

复制代码
List<? super Dog> dogs

表示集合的实际类型可能是:

复制代码
List<Dog>
List<Animal>
List<Object>

此时可以安全地加入DogDog的子类对象:

复制代码
dogs.add(new Dog());

因为无论真实集合是List<Dog>List<Animal>还是List<Object>,都能够保存Dog对象。

但是,从集合中读取时,只能保证结果是Object

复制代码
Object value = dogs.get(0);

不能直接写成:

复制代码
Dog dog = dogs.get(0);

因为集合实际可能是List<Animal>,里面可能还保存了其他类型的动物。

因此,super的特点是:

  • 可以安全写入T及其子类对象;

  • 读取时通常只能按Object接收;

  • 适合作为数据消费者。

典型的复制方法可以设计为:

复制代码
public static <T> void copy(
        List<? extends T> source,
        List<? super T> target) {

    for (T value : source) {
        target.add(value);
    }
}

source负责生产数据,所以使用extendstarget负责接收数据,所以使用super

七、什么是反射

反射是指程序在运行期间动态获取类的信息,并操作类或对象的能力。

普通调用方式通常在编译阶段就确定了类型和方法:

复制代码
UserService service = new UserService();
service.createUser();

反射可以通过类名、配置或注解,在运行时决定创建哪个对象、调用哪个方法:

复制代码
Class<?> type = Class.forName(
        "com.example.UserService"
);

Object object = type.getDeclaredConstructor()
        .newInstance();

Method method = type.getDeclaredMethod("createUser");
method.invoke(object);

通过反射可以获取:

  • 类名、父类和接口;

  • 构造方法;

  • 字段;

  • 普通方法;

  • 注解;

  • 泛型声明信息;

  • 方法参数和返回值信息。

八、反射的实现原理是什么

Java类被JVM加载后,会在运行时保存对应的类型元数据,并提供一个Class对象作为访问入口。

获得Class对象的常见方式包括:

复制代码
Class<User> type1 = User.class;

Class<?> type2 = user.getClass();

Class<?> type3 = Class.forName(
        "com.example.User"
);

反射API通过Class对象查询类的结构,并使用FieldMethodConstructor等对象描述字段、方法和构造器。

例如:

复制代码
Method method = type.getDeclaredMethod(
        "save",
        User.class
);

method.invoke(service, user);

Method.invoke()会完成参数检查、访问权限检查、调用目标定位、参数包装和异常封装等工作,最终执行对应方法。

现代JVM也会对频繁执行的反射调用进行一定优化,因此不能简单认为所有反射调用始终极慢。但在相同条件下,直接调用通常仍然更容易被JVM分析和优化。

九、为什么反射通常比直接调用慢

直接调用时,编译器和JVM能够较早确定方法签名和调用目标,并进行内联等优化。

反射调用则通常需要额外完成:

  • 查找类、方法或字段;

  • 检查访问权限;

  • 检查参数数量和类型;

  • 对基本类型进行装箱或拆箱;

  • 通过通用调用入口执行方法;

  • 将目标方法抛出的异常包装为反射异常;

  • 降低部分JIT优化的确定性。

不过,反射性能问题需要结合使用场景判断。

Spring通常在应用启动阶段通过反射扫描类、创建Bean和完成依赖注入。启动完成后,大部分业务方法依然是普通方法调用。因此,框架使用反射并不代表每一次业务操作都进行大量反射扫描。

实际开发中还可以缓存ClassMethodField等对象,避免重复查找。

十、什么是Java注解

注解是一种可以附加在类、方法、字段、参数等程序元素上的结构化元数据。

例如:

复制代码
@Override
public String toString() {
    return "User";
}

@Override告诉编译器,当前方法应该重写父类或接口中的方法。

自定义注解可以这样定义:

复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {

    String value();

    boolean recordParams() default true;
}

从语法和字节码层面看,注解是一种特殊的接口声明形式。定义注解时使用@interface,编译器会将注解信息写入Class文件的相应元数据结构中。

十一、注解中的元注解有什么作用

元注解是用于修饰其他注解的注解。

1. @Target

规定注解可以出现在哪些位置:

复制代码
@Target(ElementType.METHOD)

表示该注解只能用于方法。

常见位置包括:

  • TYPE:类、接口或枚举;

  • METHOD:方法;

  • FIELD:字段;

  • PARAMETER:参数;

  • CONSTRUCTOR:构造方法。

2. @Retention

规定注解保留到哪个阶段。

复制代码
@Retention(RetentionPolicy.RUNTIME)

常见策略包括:

  • SOURCE:只存在于源代码中;

  • CLASS:写入Class文件,但运行时不一定可通过反射获取;

  • RUNTIME:运行时仍然保留,可以通过反射读取。

框架需要在运行期间读取的注解,一般使用RUNTIME

3. @Inherited

表示类上的注解可以被子类继承。

需要注意的是,它主要作用于类级别注解,不代表接口注解或方法注解都会自动继承。

4. @Documented

表示生成Java文档时,可以把该注解包含在文档中。

十二、运行时注解是如何被框架读取的

假设定义一个运行时注解:

复制代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {

    String value() default "";
}

业务类使用该注解:

复制代码
@Component("userService")
public class UserService {
}

框架启动时可以扫描指定包下的类,获得对应的Class对象,然后判断类上是否存在该注解:

复制代码
if (type.isAnnotationPresent(Component.class)) {
    Component component =
            type.getAnnotation(Component.class);

    String beanName = component.value();
}

读取方法注解也是类似的:

复制代码
for (Method method : type.getDeclaredMethods()) {
    if (method.isAnnotationPresent(
            OperationLog.class)) {

        OperationLog log =
                method.getAnnotation(
                        OperationLog.class
                );
    }
}

框架可以根据注解中的信息决定:

  • 是否创建对象;

  • Bean使用什么名称;

  • 字段是否需要注入;

  • 方法是否需要事务;

  • 接口映射到哪个请求地址;

  • 参数如何绑定;

  • 方法是否需要日志、权限或缓存拦截。

注解本身通常只负责描述信息,真正执行功能的是读取和处理注解的框架代码。

十三、Spring为什么大量使用反射和注解

Spring的核心目标之一是降低业务代码和对象创建逻辑之间的耦合。

如果完全不使用反射,创建对象通常需要直接编写:

复制代码
UserRepository repository =
        new UserRepository();

UserService service =
        new UserService(repository);

Spring通过注解描述组件关系:

复制代码
@Service
public class UserService {

    private final UserRepository repository;

    public UserService(UserRepository repository) {
        this.repository = repository;
    }
}

容器启动时通过扫描和反射完成:

  1. 找到带有@Component@Service等注解的类;

  2. 分析构造方法和依赖关系;

  3. 通过反射创建对象;

  4. 将所需依赖注入对象;

  5. 将对象注册到IOC容器;

  6. 根据事务、缓存等注解决定是否生成代理对象。

Spring MVC也会读取:

复制代码
@RestController
@RequestMapping
@GetMapping
@RequestBody

等注解,建立请求地址、控制器方法和参数之间的映射。

因此,注解负责声明"这个类或方法需要什么能力",反射负责在运行时读取这些声明并完成实际操作。

Spring大量使用反射和注解,主要获得了以下好处:

  • 减少重复配置代码;

  • 降低对象之间的直接耦合;

  • 支持依赖注入;

  • 支持面向切面编程;

  • 支持统一事务和权限控制;

  • 让框架功能能够以声明式方式使用。

不过,注解和反射并不是越多越好。业务逻辑过度依赖隐藏的框架行为,也可能降低代码可读性和问题排查效率。合理的做法是让注解负责稳定的横切能力,而不是把复杂业务流程全部隐藏在注解中。

相关推荐
时代的狂17 天前
如何理解 C#/.NET 的依赖注入与生命周期
c#·.net·依赖注入·控制反转
Denuin多啦咧梦22 天前
玩转 .NET 依赖注入:Microsoft.Extensions.DependencyInjection 深度指南
c#·.net·依赖注入
zhuzicc1 个月前
Dubbo @Autowired 注入同模块接口,到底走的是本地调用还是 RPC?源码给你答案(Dubbo @Service注解的双重注册机制)
rpc·autowired·dubbo·依赖注入·java面试·spring ioc·dubbo源码分析
.NET修仙日记2 个月前
Scrutor:.NET 依赖注入自动化的优雅实现
c#·.net·.net core·微软技术·依赖注入·scrutor
小满Autumn2 个月前
依赖注入设计模式速查手册
开发语言·c#·wpf·mvvm·依赖注入
我叫张小白。2 个月前
基于Redis与FastAPI的分布式共享会话体系
数据库·redis·分布式·缓存·中间件·fastapi·依赖注入
CSharp精选营3 个月前
.NET 8 Web开发入门(三):解构引擎——依赖注入(DI)与中间件管道
中间件·asp.net core·依赖注入·ioc容器·请求管道·服务生命周期
庞轩px3 个月前
第三篇:泛型深度解析——类型擦除与通配符的奥秘
java·编译·泛型·类型擦除
海市公约3 个月前
FastAPI中间件与依赖系统实战指南
中间件·fastapi·依赖注入·异步编程·web架构·代码复用·拦截器模式