首页 > 编程笔记

Java泛型擦除(非常详细)

泛型的使用使得代码的重用性增强。

例如,只需要实现一个 List 接口,就可以根据实际需求向 List 里面存储 StringInteger 或其他自定义类型,而不需要实现多个 List 接口(专门存放 String 的 List 接口,专门存放 Interger 的 List 接口),那么泛型到底是如何实现的呢?

在目前主流的编程语言中,编译器主要有以下两种处理泛型的方法:

1) Code specialization

使用这种方法,每当实例化一个泛型类的时候都会产生一份新的字节代码,例如,对于泛型 ArrayList,当使用 ArrayList<String>ArrayList<Integer ) 初始化两个实例的时候,就会针对 String 与 Integer 生成两份单独的代码。

C++ 语言中的模板正是采用这种方式实现的,显然这种方法会导致代码膨胀(code bloat),从而浪费空间。

2) Code sharing

使用这种方式,会对每个泛型类只生成唯一的一份目标代码,所有泛型的实例会被映射到这份目标代码上,在需要的时候执行特定的类型检查或类型转换。

C++ 中的模板(template)是典型的 Code specialization 实现。C++ 编译器会为每一个泛型类实例生成一份执行代码。执行代码中 integer list string list 是两种不同的类型。这样会导致代码膨胀,不过有经验的 C++ 程序员可以有技巧地避免代码膨胀。

Code specialization 另外一个弊端是在引用类型系统中,浪费空间,因为引用类型集合中元素本质上都是一个指针,没必要为每个类型都产生一份执行代码。而这也是 Java 编译器中采用 Code sharing 方式处理泛型的主要原因。这种方式显然比较省空间,而 Java 就是采用这种方式来实现的。

如何将多种泛型类型实例映射到唯一的字节码中呢?Java 是通过类型擦除来实现的。

在学习泛型擦除之前,需要首先明确一个概念:Java 的泛型不存在于运行时。这也是为什么有人说 Java 没有真正的泛型的原因了。

Java 泛型擦除(类型擦除)是指在编译器处理带泛型定义的类、接口或方法时,会在字节码指令集里抹去全部泛型类型信息,泛型被擦除后在字节码里只保留泛型的原始类型(raw type)。

类型擦除的关键在于从泛型类型中清除类型参数的相关信息,然后在必要的时候添加类型检查和类型转换的方法。

原始类型是指抹去泛型信息后的类型,在 Java 语言中,它必须是一个引用类型(非基本数据类型),一般而言,它对应的是泛型的定义上界。

示例:<T> 中的 T 对应的原始泛型是 Object,<T extends String> 对应的原始类型就是 String。

泛型信息的擦除

为了便于理解,可以认为类型擦除就是 Java 的泛型代码转换为普通的 Java 代码,只不过编译器在编译的时候,会把泛型代码直接转换为普通的 Java 字节码。

【示例1】如何证明泛型会被擦除呢?下面通过一个例子来说明,Java 代码如下:
package com.company;

import java.lang.reflect.Field;

class TypeErasureSample<T> {

    public T v1;
    public T v2;
    public String v3;
}

/*泛型擦除示例*/

public class Generic3_2 {

    public static void main(String[] args) throws Exception {

        TypeErasureSample<String> type = new TypeErasureSample<String>();
        type.v1 = "String value";

        /*反射设置v2的值为整型数*/
        Field v2 = TypeErasureSample.class.getDeclaredField("v2");
        v2.set(type, 1);

        for (Field f : TypeErasureSample.class.getDeclaredFields()) {
            System.out.println(f.getName() + ":" + f.getType());

        }

        /*此处会抛出类型转换异常*/
        System.out.println(type.v2);
    }
}
运行结果:

v1:class java.lang.Object
v2:class java.lang.Object
v3:class java.lang.String
Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java. lang. String at capter3.generic.Generic3_2.main(Generic3_2.java: 29)

v1 和 v2 的类型被指定为泛型 T,但是通过反射发现,它们实质上还是 Object,而 v3 原本定义的就是 String,和前两项比对,可以证明反射本身并无错误。

代码在输出 type.v2 的过程中抛出了类型转换异常,这说明了两件事情:

①为 v2 设置整型数已经成功(可以自行写一段反射来验证)。

②编译器在构建字节码的时候,一定做了类似于 (String)type.v2 的强制转换,关于这一点,可以通过反编译工具(工具为jd-gui)验证,结果如下所示:
public class Generic3_2{

    public static void main(String[] args) throws Exception

    {
        TypeErasureSample type = new TypeErasureSample();
        type.v1= "String value";

        Field v2 = TypeErasureSample.class.getDeclaredField("v2");
        v2.set(type, Integer.valueOf(l));

        for (Field f: TypeErasureSample.class.getDeclaredFields()) { System.out.println(f.getName() + ":"+ f.getType());

        }
        System.out.println((String) type.v2);
    }
}
由此可见,如果编译器认为 type.v2 有被声明为 String 的必要,那么都会加上 (String) 强行转换。可以使用下面的代码来验证上述的分析:
Object 0=type.v2;
String s=type.v2;
后者会抛出类型转换异常,而前者是正常执行的。由此可见,泛型类型参数在编译的时候会被擦除,也就是说虚拟机中只有普通类和普通方法,而没有泛型。正因为如此,在创建泛型对象的时候,最好指明类型,这样编译器就能够尽早地做参数的类型检查。

引申1:类型检查是针对引用的还是实际对象的?

我们知道泛型在编译的时候会进行类型擦除,但是如何保证 List<Integer> 中只能插入 Integer 类型的数据,而不能插入 String 类型的数据?

Java 编译器是通过先检查代码中泛型的类型,然后再进行类型擦除,再进行编译的。也就是说,这个类型检查是在编译阶段来做的,那么这就带来一个问题:类型检查是针对引用的还是针对对象的呢?

【示例2】下面通过一个例子来说明:
List<Integer> list1=new ArrayList();

list1.add(l);  //编译正确
list1.add("a");  //编译错误,因为list1存放的类型为Integer
Integer i = listl.get(0);  //编译正确
List list2=new ArrayList<Integer>();
list2.add(l);  //编译正确
list2.add("a");  //编译正确
Integer i = list2.get(0);  //编译错误,因为get返回的类型为Object
Object obj =list2.get(0); //编译正确
通过以上这个例子可以看出,list1 只能存放 Integer 类型的数据,而 list2 可以存放任意类型的数据,从list1中获取到的数据一定是 Integer 类型,而从 list2 获取到的数据是 Object 类型。由此可以看出,类型检查是针对引用的,而不是变量实际指向的对象。

擦除带来的问题

Java 是通过擦除来实现把泛型类型实例关联到同一份字节码上的。

编译器只为泛型类型生成一份字节码,从而节约了空间,但是这种实现方法也带来了许多隐含的问题。下面介绍几种常见的问题。

1) 泛型类型变量不能是基本数据类型

泛型类型变量只能是引用类型,不能是 Java 中的 8 种基本类型(charbyteshortintlongbooleanfloatdouble)。

以 List 为例,只能使用 List<Integer>,但不能使用 List<int>,因为在进行类型擦除后,List 的原始类型会变为 Object,而 Object 类型不能存储 int 类型的值,只能存储引用类型 Integer 的值。

2) 类型的丢失

通过下面一个例子来说明类型丢失的问题。
class Test
{
    public void List<Integer> list){}
    public void List<String> list){}
}
上述代码中,编译器认为这个类中有两个相同的方法(方法参数也相同)被定义,因此会报错,主要原因是在声明 List<String> 和 List<Integer> 时,它们对应的运行时类型实际上是相同的,都是 List,具体的类型参数信息 String 和 Integer 在编译时被擦除了。

正因为如此,对于泛型对象使用 instanceof 进行类型判断的时候就不能使用具体的类型,而只能使用通配符,示例如下所示:
List<String> list=new ArrayList<String>();
if( list instanceof ArrayList<String>) {}  //编译错误
if( list instanceof ArrayList<?>) {}  //正确的使用方法

3) catch中不能使用泛型异常类

假设有一个泛型异常类的定义 MyException<T>,那么下面的代码是错误的:
try{

}catch (MyException<String> e1)
catch ( MyException<Integer>e2){…}

因为擦除的存在,MyException<String> 和 MyException<Integer> 都会被擦除为 MyException<Object>,因此,两个 catch 的条件就相同了,所以这种写法是不允许的。

此外,也不允许在 catch 子句中使用泛型变量,示例代码如下所示:
public <T extends Throwable> void test(T t) {

    try{
        ...
    }catch(T e) {  //编译错误
        ...
    } catch(IOException e){
    }
}
假设上述代码能通过编译,由于擦除的存在,T 会被擦除为 Throwable。由于异常捕获的原则为:先捕获子类类型的异常,再捕获父类类型的异常。

上述代码在擦除后会先捕获 Throwable,再捕获 IOException,显然这违背了异常捕获的原则,因此这种写法是不允许的。

4) 泛型类的静态方法与属性不能使用泛型

由于泛型类中的泛型参数的实例化是在实例化对象的时候指定的,而静态变量和静态方法的使用是不需要实例化对象的,显然这二者是矛盾的。

如果没有实例化对象,而直接使用泛型类型的静态变量,那么此时是无法确定其类型的。

优秀文章