java泛型探秘(二):泛型擦除

目录

一. 泛型擦除是什么

二. 为什么要擦除

三. 擦除造成的限制

1. 特殊的rawType

2. 不支持原始类型

3. 不能用占位符创建实例或数组

4. 不能创建泛型数组 


一. 泛型擦除是什么

java泛型是编译期的泛型,不是运行时的泛型

       java语言是跨平台的,每个平台都有对应的JVM(java虚拟机),编写的java源码不能直接在JVM中运行,能在JVM中运行的是字节码,一般都以.class文件格式存在,java源码文件转换成.class字节码文件的过程称为编译期,编译期会对java源码严格校验,生成合格的字节码。编译过程中,编译器遇到泛型代码(泛型类、方法的定义和使用)会进行特殊处理,主要进行检查类型安全并擦除泛型信息,编译之后生成的.class字节码不包含泛型信息(实际上会保留一些泛型信息,只有在某些特殊情况下才会使用),JVM虚拟机运行时不知道泛型的存在,也不会针对泛型做特殊处理。如下代码:

/**
  这是一个很简单的泛型化类,拥有一个属性var,和该属性的setter和getter方法
**/
public class Generic {
    
    private T var;

    public T getVar() {
        return var;
    }

    public void setVar(T var) {
        this.var = var;
    }
    
    
    public static void main(String[] args) {
        
        // 设置类型为Integer
        Generic inGeneric = new Generic();
        
        // 设置属性值
        inGeneric.setVar(10);
        
        // 输出值
        System.out.println(inGeneric.getVar().intValue()); 
    }
}

       上面定义了泛型类Generic, 其中属性var的声明类型也是泛型修饰,并提供了var的getter和setter方法。在main方法里首先创建了Generic实例,传入类型为Integer,意味着属性var的类型是Integer,即: Generic inGeneric = new Generic(),并调用inGeneric.setVar(10)设置var值为10,调用inGeneric.getVar().intValue()获取var的值。上面这段示例代码虽然简单,但是在类定义、属性声明、方法入参与出参、泛型类实例创建和方法调用上都使用了,通过观察编译后的.class文件,比较能全面直观地了解泛型擦除的效果。

       使用javap -v 命令输出.class字节码内容

javap -v Generic.class

       输出结果,由于字节码内容比较多,这里只摘出部分内容:

Classfile /D:/workspace/learn-class/bin/cn/learn/classes/Generic.class
  Last modified 2019-4-8; size 1247 bytes
  MD5 checksum 917868e0a5c70d19355976a97fe8b62e
  Compiled from "Generic.java"
public class cn.learn.classes.Generic extends java.lang.Object
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Class              #2             // cn/learn/classes/Generic
   #2 = Utf8               cn/learn/classes/Generic
   #3 = Class              #4             // java/lang/Object
   #4 = Utf8               java/lang/Object
   #5 = Utf8               var

   ....

   public T getVar();
    descriptor: ()Ljava/lang/Object;
    flags: ACC_PUBLIC
    Signature: #22                          // ()TT;
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #23                 // Field var:Ljava/lang/Object;
         4: areturn
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/learn/classes/Generic;
      LocalVariableTypeTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/learn/classes/Generic;

  public void setVar(T);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC
    Signature: #27                          // (TT;)V
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: putfield      #23                 // Field var:Ljava/lang/Object;
         5: return
      LineNumberTable:
        line 12: 0
        line 13: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Lcn/learn/classes/Generic;
            0       6     1   var   Ljava/lang/Object;
      LocalVariableTypeTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Lcn/learn/classes/Generic;
            0       6     1   var   TT;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #1                  // class cn/learn/classes/Generic
         3: dup
         4: invokespecial #30                 // Method "":()V
         7: astore_1
         8: aload_1
         9: bipush        10
        11: invokestatic  #31                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        14: invokevirtual #37                 // Method setVar:(Ljava/lang/Object;)V
        17: getstatic     #39                 // Field java/lang/System.out:Ljava/io/PrintStream;
        20: aload_1
        21: invokevirtual #45                 // Method getVar:()Ljava/lang/Object;
        24: checkcast     #32                 // class java/lang/Integer
        27: invokevirtual #47                 // Method java/lang/Integer.intValue:()I
        30: invokevirtual #51                 // Method java/io/PrintStream.println:(I)V
        33: return
      LineNumberTable:
        line 19: 0
        line 22: 8
        line 25: 17
        line 26: 33
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      34     0  args   [Ljava/lang/String;
            8      26     1 inGeneric   Lcn/learn/classes/Generic;
      LocalVariableTypeTable:
        Start  Length  Slot  Name   Signature
            8      26     1 inGeneric   Lcn/learn/classes/Generic;
}
SourceFile: "Generic.java"
Signature: #63                          // Ljava/lang/Object;

       在10-11行,类名上定义的泛型T被擦除,和普通的类名一样;在13-14行,属性var的类型T被擦除,用Object类型的全限定名java/lang/Object表示;在18-19行,getVar()返回类型T被擦除,descriptor描述符表示方法的入参和出参的实际类型,表明返回类型T被java/lang/Object替换;在36-37行,setVar(T)的入参类型T被参数,descriptor表明入参类型T被java/lang/Object替换;在63行,创建Generic实例时传入的Integer丢失,和创建普通的类一样;在70行,显示调用setVar方法时实际调用了setVar(java/lang/Object),因为实参10会自动封箱为Integer类型,所以能成功传入到setVar(java/lang/Object)方法中;在73-74行,显示getVar实际返回的参数是java/lang/Object,并且为了使用Integer的intValue()方法输出属性值,会将getVar的返回参数java/lang/Object检查并强转成Integer类型。

       通过上面的.class字节码和源码对比,可以知道在编译阶段,在类名、属性和方法上定义的泛型占位符和使用泛型类时传入的实际类型参数都会被擦除或者被java/lang/Object替换(注:泛型会擦除到定义的泛型边界,默认边界是java/lang/Object,可以用extends自定义边界),另外在一些地方编译器插入了强制类型转换,而这些开发人员是无感知的。当JVM加载.class文件时,由于泛型信息都被擦除了,JVM感受不到泛型的存在。其他编程语言如C++,泛型模板在源码和运行阶段都是存在的,相比较而言,java的泛型更像一种语法糖,只提供泛型编码能力,真正运行时,泛型并不存在。

 

二. 为什么要擦除

       泛型在java5正式发布,更早之前,在java1只推出一年后,Scala之父Martin Odersky就用java实现了Pizza项目,Pizza有三大特性,其中之一就是实现了"真正的java泛型"(泛型信息在运行阶段依然存在)。然后java核心开发者Gilad Bracha 和David Stoutamire 邀请 Martin Odersky为java实现泛型功能,如果这个时候他们能赶在java下个版本发布泛型,"真正的泛型"功能可能会被实现,事实上,java泛型不仅没能在下个版本发布,反而推迟了6年(在没有泛型的版本中,数组承担了部分泛型责任,java核心开发者认为数组中的方法应该是通用的,这也是为什么数组是协变的原因)。

       等真正确定要在java5版本中添加泛型特性时,java已经经历了好几个版本,如果要对java集合类等核心类实现泛化,有两种方案,第一种方案是重新实现一套完整的泛型集合类,对之前未泛化的集合类完全抛弃;第二种方案是直接将原来的集合类泛化,兼容未泛化代码和字节码。第一种方案的优点是有效地隔离了泛型和未泛型,甩掉了历史包袱,只需专注于怎样更好地实现泛型类;缺点是新增了大量新泛型集合类的api,需要java程序员大量的学习成本,旧代码改造成泛型很困难。第二种方案优点是新旧集合类平滑过渡,可以逐步对项目内之前未泛化的代码进行泛化;缺点是需要谨慎处理泛化和未泛化之间的兼容性。

       最终第二种方案胜出,由于有大量未泛型化的java源码和字节码编译文件存在,java核心开发者认为java应该具有"完全的向后兼容性"(源码和字节码都要兼容),比如要对java集合库实现泛型化就要兼容之前的非泛型化的集合库(在java5之前,ArrayList和LinkedList等是未泛化集合类,在java5中,ArrayList泛化为ArrayList,LinkedList泛化为LinkedList),要求对原来的非泛型集合库的代码和字节码都能兼容,才有了rawType特殊写法和编译期擦除的泛型实现方式。Martin Odersky在关于Scala的访谈中,吐槽了java擦除泛型,看了之后能对目前的java泛型实现机制有更好的理解,访谈地址:https://www.artima.com/scalazine/articles/origins_of_scala.html

 

三. 擦除造成的限制


1. 特殊的rawType

       在使用泛型类时,声明泛型类对象或者创建泛型类实例,一般都会用真实类型替换泛型占位符,如果没有替换,也能正常编译运行 ,这是因为java泛型是完全的向后兼容(源码和字节码都兼容),java5已泛型化的类在java5之前的版本中是未被泛型化的,所以包含了非泛型类的代码也是可以用java编译器编译的,并将未泛型化类称为泛型化类的rawType,比如List是List的rawType(原生类型)。虽然java保留了rawType,但在编写java泛型代码时,尽量避免使用rawType,否则容易发生类型不安全。如下代码,三种方式都是可以编译,但是提倡使用第一种:

// 第一种:正常的泛型类声明和创建实例
List strList = new ArrayList();
        
// 第二种:声明使用rawType
List rawList1 = new ArrayList();
        
// 第三种:创建实例使用rawType
List rawList2 = new ArrayList();


2. 不支持原始类型

       目前编译器擦除泛型信息时,擦除到边界(默认是Object),边界类型要求是类,不支持原始类型(byte、short、int、long、boolean、char、float、double)。原始类型和类的数据结构不同,如果要实现擦除之后的泛型类同时支持原生类型和类,实现较为困难,java核心开发者考虑到了实现成本,只能暂时放弃了对原始类型的泛型化(Project Valhalla是正在进行中的OpenJDK项目,计划给未来的Java添加改进的泛型支持以及原始类型支持)。注:java具有封箱功能,在传入原始类型的值时,java会自动封箱为对应的包装类,如下代码:

// 编译失败, 不允许传入原始类型
List intList = new ArrayList();
        
// 编译成功, 允许传入原始类型的值
List integerList = new ArrayList();
integerList.add(10); // 参数值10自动封箱为 Integer(10)

 

3. 不能用占位符创建实例或数组

       java编译后会擦除泛型信息,占位符被边界类型(默认是Object)代替,所以不能用new 关键字创建占位符T的实例或者数组,只能用占位符声明对象,如下代码:

public class Generic {
    
    // 可以用占位符T声明var和arrVar
    private T var;
    
    private T[] arrVar;

    // 编译失败, 不能同占位符T创建实例和数组
    public Generic(){
        var = new T(); // 编译失败
        arrVar = new T[10]; // // 编译失败
    }
    
}

 

4. 不能创建泛型数组 

       数组支持协变的,所以在编译时无法进行类型安全验证,只能在运行时验证类型安全。而泛型信息在编译器就会被擦除,在运行阶段无法验证类型安全,如果java支持泛型数组的创建,会导致该数组在编译和运行阶段都无法进行类型检查。如下代码: 

// 用泛型类型声明数组对象
List[] arr = null;
        
// 假设编译成功, 创建泛型数组
arr = new ArrayList[10];

// 声明object数组, 并赋值arr
Object[] objArr = arr;
        
// 数组中的第一个元素赋值
objArr[0] = new ArrayList();

// 取出arr第一个元素并遍历
List firstOfArr = arr[0];

for(String s : firstOfArr){

    ...
}    

       上面代码模拟了如果java支持泛型数组,即上面代码中的 new ArrayList[10]  假设编译成功,又因为数组是支持协变的,所以可以将new ArrayList[10] 赋值给声明为List[]的arr。接下来将arr赋值给声明为Object[] 的objArr ,然后为objArr[0] 赋值ArrayList实例,因为不违反类型安全,所以这段代码能成功编译。在编译阶段,这段代码的泛型信息会被擦除,arr = new ArrayList[10] 擦除之后实际变成了arr = new ArrayList[10](即arr是原生类型ArrayList的数组),objArr[0] = new ArrayList() 实际变成了objArr[0] = new ArrayList()(即原生类型ArrayList),那么这段代码在运行阶段是可以成功运行的,但是当获取arr第一个元素并遍历时,因为实际元素是Integer类型,遍历用string类型时,会发生类型转换错误,导致发生了堆污染。

 

你可能感兴趣的:(Java基础知识)