图标
创作项目友邻自述归档留言

浅谈泛型擦除

前言

泛型是 Java 5 引入的一个新特性,现在的高级语言基本也都支持泛型了。泛型本质上是将类型作为参数,以提供类型检查和避免不必要的类型转换。具体关于类型的本篇文章就不再说明了,具体可以自行查找。

什么是泛型擦除?

在 Java 中的泛型,常常被称为伪泛型。之所以这么说是因为 Java 的泛型在经过编译时会将实际的类型信息擦除掉。在字节码中就不存在泛型的信息了,JVM 运行的时候自然而然的就没有泛型信息了。

真泛型与伪泛型:

  • 真泛型:即泛型在运行期间是真实存在的,运行时可以取得泛型对应的实际类型,如 this.getComponent<ExampleComponent>() 方法可以取得 ExampleComponent 类型的实例。
  • 伪泛型:即泛型在运行期间不可见,仅在编译时进行类型检查,如 this.getComponent<ExampleComponent>(ExampleComponent.class) 方法,必须传入 ExampleComponent.class 才能取得对应类型的实例,因为泛型定义 <ExampleComponent> 在运行期间无法取得,只能通过另外传入类型的方式来实现相同的功能。

为什么 Java 要进行泛型擦除?

在说明原因的时候我们需要了解真泛型和伪泛型的实现机制。

实现思路

编程语言引入真泛型的方式一般是使用代码膨胀的方式,举个例子来说当我们有一个 Component<T> 的类,那么代码经过编译后会生成如 Component@T 的类,其中多出来的 @T 分别为标记和占位符。当我们使用的时候如 new Component<String>(),此时编译器则会将原始类中的 T 占位符更改为 String 类,同时生成一个新的类,如 Component@String 类,此时实际类型就被保留下来了。当我们使用的时候就可以同普通类一样使用,如:

if (obj instanceof T) {}
new T();
new T[1];
if (obj instanceof T) {}
new T();
new T[1];

伪泛型的处理方式则很简单,还是举个例子来说吧,当 Component<T> 编译后会将其中的 T 占位符去除,变成 Component 类,当我们使用的时候如 new Component<String>() 那么经过编译后,会将 String 类型标注去除,变成 Component 相当于 Component<Object>

使用真泛型带来的问题

Java 是在 Java 5 后才引入的泛型支持,为了支持真泛型需要修改 JVM 源代码,加入泛型支持,同时为了兼容 Java 5 以前的程序,不能改动以前的旧版本类,而是另外新增一套支持泛型的新版本类,如 java.util.ArrayListjava.util.generic.ArrayList<T>

这样的实现看似很简单,只需要引入一套泛型,当需要使用泛型的时候就使用泛型包下的类,似乎还更灵活一点?但是我们忽略了一个问题,当项目中某个依赖库使用了泛型重新生成了字节码文件,此时这个项目为了兼容就必须也同时升级到 Java 5,否则会抛出 UnsupportedClassVersionError 错误。而 Java 5 之前的生态已经非常完善,此时如果为了引入泛型,就不得不让许多库都修改代码,显然这是不合理的。

为什么 Spring 还能进行泛型注入?

实现思路

我们知道 Spring 可以通过集合元素的泛型来注入对应的依赖,如下面的代码,在容器启动后,Spring 会将容器内对应类型的依赖注入到响应的变量中。

class Demo1ApplicationTests {
    @Autowired
    List<ApplicationContext> contexts;

    @Autowired
    Map<String, ApplicationContext> contextMap;

    @Test
    void contextLoads() {
        log.info("ApplicationContext: {}", contexts);
        log.info("ApplicationContext: {}", contextMap);
    }
}
class Demo1ApplicationTests {
    @Autowired
    List<ApplicationContext> contexts;

    @Autowired
    Map<String, ApplicationContext> contextMap;

    @Test
    void contextLoads() {
        log.info("ApplicationContext: {}", contexts);
        log.info("ApplicationContext: {}", contextMap);
    }
}

既然 Java 会进行泛型擦除,那么这两个字段在 JVM 中实际上是 List<Object>Map<Object, Object>,理应是无法注入的,那么 Spring 又是通过何种手段取得这本不存在的信息呢?

实际上 Java 虽然将泛型信息擦除了,但是为了能够让虚拟机解析、反射等各种场景正确获取到参数类型,JCP 组织修改了虚拟机规范,引入了 SignatureLocalVariableTypeTable。这样即使泛型被擦除了,在一些特定的场景下还是能取得泛型信息。我们就以上面的代码为例,通过 jclasslib 查看字节码信息(偷懒不用 javap):

可以看到特有信息里出现了 ApplicationContext 的身影,这就是保留下来的泛型信息。在 Java 中,以下场景都会提供签名:

  • 具有通用或者具有参数化类型的超类或者超接口的类。
  • 方法中的通用或者参数化类型的返回值或者入参,以及方法的 throw 子句中的类型变量。
  • 任何类型、类型变量、或者参数化类型的字段、形式参数或者局部变量。

取得泛型信息

既然知道 Java 会将泛型信息保留到 Signature,那么我们就可以取得对应的泛型信息了,如下:

class Demo1ApplicationTests {
    @Autowired
    List<ApplicationContext> contexts;

    @Test
    void contextLoads() throws NoSuchMethodException, NoSuchFieldException {
        // 具有泛型的超类
        log.info(
            "Type: {}",
            Arrays.toString(
                (
                    (ParameterizedType) TestClass.class.getGenericSuperclass()
                ).getActualTypeArguments()
            )
        );
        // 返回值
        final Method method =
            this.getClass().getDeclaredMethod("testMethod", List.class);
        log.info(
            "Type: {}",
            Arrays.toString(
                (
                    (ParameterizedType) method.getGenericReturnType()
                ).getActualTypeArguments()
            )
        );
        // 参数
        log.info(
            "Type: {}",
            Arrays
                .stream(method.getGenericParameterTypes())
                .map(type -> (ParameterizedType) type)
                .map(ParameterizedType::getActualTypeArguments)
                .collect(Collectors.toList())
        );
        // 字段
        log.info(
            "Type: {}",
            Arrays.toString(
                (
                    (ParameterizedType) this.getClass()
                        .getDeclaredField("contexts")
                        .getGenericType()
                ).getActualTypeArguments()
            )
        );
    }

    List<ApplicationContext> testMethod(List<ApplicationContext> contexts) {
        return contexts;
    }

    public static class TestClass extends ArrayList<ApplicationContext> {}
}
class Demo1ApplicationTests {
    @Autowired
    List<ApplicationContext> contexts;

    @Test
    void contextLoads() throws NoSuchMethodException, NoSuchFieldException {
        // 具有泛型的超类
        log.info(
            "Type: {}",
            Arrays.toString(
                (
                    (ParameterizedType) TestClass.class.getGenericSuperclass()
                ).getActualTypeArguments()
            )
        );
        // 返回值
        final Method method =
            this.getClass().getDeclaredMethod("testMethod", List.class);
        log.info(
            "Type: {}",
            Arrays.toString(
                (
                    (ParameterizedType) method.getGenericReturnType()
                ).getActualTypeArguments()
            )
        );
        // 参数
        log.info(
            "Type: {}",
            Arrays
                .stream(method.getGenericParameterTypes())
                .map(type -> (ParameterizedType) type)
                .map(ParameterizedType::getActualTypeArguments)
                .collect(Collectors.toList())
        );
        // 字段
        log.info(
            "Type: {}",
            Arrays.toString(
                (
                    (ParameterizedType) this.getClass()
                        .getDeclaredField("contexts")
                        .getGenericType()
                ).getActualTypeArguments()
            )
        );
    }

    List<ApplicationContext> testMethod(List<ApplicationContext> contexts) {
        return contexts;
    }

    public static class TestClass extends ArrayList<ApplicationContext> {}
}

文中就只列举了几种常见的场景,不过需要注意有些泛型信息虽然有保留但是使用 Java 反射无法获取,如局部变量,Java 没有对应局部变量的反射机制,自然也无法取得对应的泛型信息,需要额外借助 ASM 等字节码工具类来进行读取。

擦除方式

在 Java 中,我们为了限制泛型的使用会使用一些修饰符来定义泛型的上下限,如以下几种定义:

<?>           // 无限制通配符
<T>           // 无限制通配符
<? extends E> // extends 关键字声明了类型的上界,表示参数化的类型可能是所指定的类型,或者是此类型的子类
<? super E>   // super 关键字声明了类型的下界,表示参数化的类型可能是指定的类型,或者是此类型的父类
<T extends Staff & Passenger> // 合并限制
<?>           // 无限制通配符
<T>           // 无限制通配符
<? extends E> // extends 关键字声明了类型的上界,表示参数化的类型可能是所指定的类型,或者是此类型的子类
<? super E>   // super 关键字声明了类型的下界,表示参数化的类型可能是指定的类型,或者是此类型的父类
<T extends Staff & Passenger> // 合并限制

从上面的几种修饰方式我们可以看出,泛型擦除被分成了有限制泛型擦除和无限制泛型擦除两种。

无限制泛型擦除

无限制泛型擦除,当类或方法定义中的类型参数没有任何限制时,即形如 <T><?> 的类型参数都属于无限制泛型擦除。在进行无限制泛型擦除时,Java 编译器会将这些泛型信息都替换为 Object

如以下的测试代码,其编译后的签名为 <<T:Ljava/lang/Object;>(TT;)V>

public static <T> void object(final T object) {} // <<T:Ljava/lang/Object;>(TT;)V>
public static <T> void object(final T object) {} // <<T:Ljava/lang/Object;>(TT;)V>

有限制泛型擦除

有限制泛型擦除,当类或方法参数等类型参数存在限制(上下界)时,即形如 <T extends Number><? extends Number><? super Number> 的类型参数都属于有限制泛型擦除。在进行有限制泛型擦除的时候 Java 编译器会将这些泛型信息进行变更。其中 extends 修饰的会被替换成具体的类型,如 <T extends Number> 会替换为 Numbersuper 修饰的会被替换为 Object

如以下的测试代码,其编译后的签名为 <<T:Ljava/lang/Number;>(TT;)V>

public static <T extends Number> void number(final T number) {} // <<T:Ljava/lang/Number;>(TT;)V>
public static <T extends Number> void number(final T number) {} // <<T:Ljava/lang/Number;>(TT;)V>

合并泛型擦除

合并泛型擦除也属于有限制泛型擦除,不过由于其泛型定义具有多个类型,其签名也存在多个类型信息,如以下的测试代码,其编译后包含了两个类型:<<T::Lorg/springframework/context/ApplicationContext;:Lorg/springframework/context/annotation/AnnotationConfigRegistry;>(TT;)V>

public static <T extends ApplicationContext & AnnotationConfigRegistry> void merge(final T merge) {} // <<T::Lorg/springframework/context/ApplicationContext;:Lorg/springframework/context/annotation/AnnotationConfigRegistry;>(TT;)V>
public static <T extends ApplicationContext & AnnotationConfigRegistry> void merge(final T merge) {} // <<T::Lorg/springframework/context/ApplicationContext;:Lorg/springframework/context/annotation/AnnotationConfigRegistry;>(TT;)V>

需要注意一点,虽然签名中保留了多个泛型信息,但是在进行反射取得方法的时候需要使用第一个类型来获取:

this.getClass().getDeclaredMethod("merge", ApplicationContext.class);
this.getClass().getDeclaredMethod("merge", ApplicationContext.class);

泛型检查

由于泛型是在语法层面上支持的也就是说编译器上支持的,所以自然而然就要有泛型的检查,否则泛型就没有存在的意义了。

比如以下的代码:

public static void main(String[] args) {
  List<String> list = new ArrayList<String>();
  list.add("123");
  list.add(123); //编译错误
}
public static void main(String[] args) {
  List<String> list = new ArrayList<String>();
  list.add("123");
  list.add(123); //编译错误
}

由于我们定义 List 的元素类型是 String,所以当添加的类型不为 String 的时候,Java 编译器就会检测出来,并抛出编译错误。即使在运行时会进行 泛型擦除,List 可以存储任何 继承 自 Object 的类型,不过运行时是处于编译后,在编译期间就会对泛型的使用进行检查。

泛型检查是针对泛型的,如果原始使用的话就不会报编译错误,这是因为如果不写泛型,那么 Java 会默认泛型为 Object 。如下:

public static void main(String[] args) {
  List list = new ArrayList();
  list.add("123");
  list.add(123); //编译通过
}
public static void main(String[] args) {
  List list = new ArrayList();
  list.add("123");
  list.add(123); //编译通过
}

还有一种常见的泛型检查是泛型间类型的转换,如下:

List<String> list1 = new ArrayList<Object>(); //编译错误
List<Object> list2 = new ArrayList<String>(); //编译错误
List<String> list1 = new ArrayList<Object>(); //编译错误
List<Object> list2 = new ArrayList<String>(); //编译错误

第一种转换报错的原因是因为 Object 类型无法转换为 String,如果 List 中存储了非 String 类型的对象,当我们获取的时候,Java 会默认认为 List 中所有对象都是 String,而此时如果获取它,就会抛出 ClassCastException 异常。因此 Java 编译器不允许此种情况的发生。

第二种虽然可以正常工作,但是如果这样转换泛型也就失去了意义,所以 Java 编译器也不允许这种情况的发生。

结语

这篇文章从 5 月份就开始计划写了,一直咕到了今天 🤣。

最近应该都会写一些深入一点的文章(类似这篇),框架系列就一直没写了,等过几天看看吧(逃。

引用

浅谈泛型擦除

https://blog.ixk.me/post/talking-about-type-erasure
  • 许可协议

    BY-NC-SA

  • 本文作者

    Otstar Lin

  • 发布于

    2021/07/01

转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!

浅谈垃圾回收浅谈单点登录