java泛型你需要知道的一切

0.946字数 3192阅读 6653

最近准备回归下基础知识,先对泛型进行下总结,从以下几个方面进行阐述:

  1. 泛型的引入及工作原理
  2. 泛型注意事项及带来的问题
  3. 泛型的通配符相关

1. 泛型的引入及工作原理

先来说说为什么会引入泛型,泛型是jdk1.5引入的。在jdk1.5以前,如果要实现类似泛型的功能,基本上都是依赖于Object。比如:

public class A {
    private Object b;
    public void setB(Object b) {
        this.b = b;
    }
    public Object getB() {
        return b;
    }
}
--------------------------------------  
A a=new A();
a.setB(1);
int b=(int)a.getB();//需要做类型强转
String c=(String)a.getB();//运行时,ClassCastException

编译器检查不出这种错误,只有在运行期才能检查出来,此时就会出现恼人的ClassCastException,应用当然也就挂了。所以用Object来实现泛型的功能就要求时刻做好类型转换,很容易出现问题。那么有没有办法将这些检查放在编译期做呢,泛型就产生了,泛型在编译期进行类型检查,问题就容易发现的多了。我们用泛型来实现一下看看:

public class A<T> {
    private T b;
    public void setB(T b) {
        this.b = b;
    }
    public T getB() {
        return b;
    }
}
// Test1.java
A<Integer> a=new A<Integer>();
a.setB(1);
int b=a.getB();//不需要做类型强转,自动完成
String c=(String)a.getB();//编译期报错,直接编译不通过

显而易见,泛型的出现减少了很多强转的操作,同时避免了很多运行时的错误,在编译期完成检查。

泛型工作原理

java中的泛型都是编译器层面来完成的,在生成的java字节码中是不包含任何泛型中的类型信息的,使用泛型时加上的类型参数,会在编译时被编译器去掉。
这个过程称为类型擦除。泛型是通过类型擦除来实现的,编译器在编译时擦除了所有泛型类型相关的信息,所以在运行时不存在任何泛型类型相关的信息(暂且这么说,实际上并不是完全擦除),譬如 List<Integer> 在运行时仅用一个 List 来表示,这样做的目的是为了和 Java 1.5 之前版本进行兼容。泛型擦除具体来说就是在编译成字节码时首先进行类型检查,接着进行类型擦除(即所有类型参数都用他们的限定类型替换,包括类、变量和方法),下面来看几个关于擦除原理的相关问题,加深一下理解。

  • 上文中我们在调用getB方法时不需要手动做类型强转,其实并不是不需要,而是编译器给我们进行了处理,具体来讲,泛型方法的返回类型是被擦除了,并不会进行强转,而是在调用方法的地方插入了强制类型转换,下面看一下a.getB()的字节码。用javap查看下上面代码的字节码。
//定义处已经被擦出成Object,无法进行强转,不知道强转成什么
public T getB();
   Code:
      0: aload_0
      1: getfield      #23                 // Field b:Ljava/lang/Object;
      4: areturn
//调用处利用checkcast进行强转
L5 {
            aload1
            invokevirtual com/ljj/A getB()Ljava.lang.Object);
            checkcast java/lang/Integer
            invokevirtual java/lang/Integer intValue(()I);
            istore2
        }

2. 泛型注意事项及带来的问题

  • 泛型类型参数不能是基本类型。例如我们直接使用new ArrayList<int>()是不合法的,因为类型擦除后会替换成Object(如果通过extends设置了上限,则替换成上限类型),int显然无法替换成Object,所以泛型参数必须是引用类型。

  • 泛型擦除会导致任何在运行时需要知道确切类型信息的操作都无法编译通过。例如test1,test2,test3都无法编译通过,这里说明下,instanceof语句是不可以直接用于泛型比较的,上文代码中,a instanceof A<integer>不可以,但是a instanceof A或者 a instanceof A<?>都是没有问题的,只是具体的泛型类型不可以使用instanceof。

public class A<T> {
    private void test1(Object arg) {
        if (arg instanceof T) { // 编译不通过
        }
    }
    private void test2() {// 编译不通过
        T obj = new T();
    }
    private void test3() {// 编译不通过
        T[] vars = new T[10];
    }
}
  • 类型擦除与多态的冲突,我们通过下面的例子来引入。
class A<T> {
    private T value;
    public void setValue(T t) {
        this.value = t;
    }
    public T getValue() {
        return value;
    }
}
class ASub extends A<Number> {
    @Override // 与父类参数不一样,为什么用@Override修饰
    public void setValue(Number t) {
        super.setValue(t);
    }
    @Override // 与父类返回值不一样,为什么用@Override修饰
    public Number getValue() {
        return super.getValue();
    }
}

 ASub aSub=new ASub();
 aSub.setValue(123);//编译成功
 aSub.setValue(new Object);//编译不通过

不知道大家看完这段代码后,有没有比较诧异?按照前面类型擦除的原理,为什么ASub的setValue和getValue方法都可以用@Override修饰能不报错?
我们知道@Override修饰的代表重写,重写要求子类中的方法与父类中的某一方法具有相同的方法名,返回类型和参数列表。显然子类的getValue方法的会返回值与父类不同。而setValue方法就更奇怪了,A方法的setValue方法在类型擦除后应该是setValue(Object obj),看起来这不是重写,不就是我们认知中的重载(函数名相同,参数不同)吗?而且最后当我们调用aSub.setValue(new Object())时编译不通过,说明确实实现了重写功能,而非重载。我们看一下通过javap编译后的class文件。

Compiled from "ASub.java"
public class com.ljj.ASub extends com.ljj.A<java.lang.Number> {
  public com.ljj.ASub();
    Code:
       0: aload_0
       1: invokespecial #8                  // Method com/ljj/A."<init>":()V
       4: return

  public void setValue(java.lang.Number);
    Code:
       0: aload_0
       1: aload_1
       2: invokespecial #16                 // Method com/ljj/A.setValue:(Ljava/lang/Object;)V
       5: return

  public java.lang.Number getValue();
    Code:
       0: aload_0
       1: invokespecial #23                 // Method com/ljj/A.getValue:()Ljava/lang/Object;
       4: checkcast     #26                 // class java/lang/Number
       7: areturn

  public void setValue(java.lang.Object);//编译器生成的桥方法,调用重写的setValue方法
    Code:
       0: aload_0
       1: aload_1
       2: checkcast     #26                 // class java/lang/Number
       5: invokevirtual #28                 // Method setValue:(Ljava/lang/Number;)V
       8: return

  public java.lang.Object getValue();//编译器生成的桥方法,调用重写的getValue方法
    Code:
       0: aload_0
       1: invokevirtual #30                 // Method getValue:()Ljava/lang/Number;
       4: areturn
}

我们可以看到子类真正重写基类方法的是编译器自动合成的桥方法,而桥方法的内部直接去调用了我们复写的方法,可见,加载getValue和setValue上的@Override只是个假象,虚拟机巧妙使用桥方法的方式,解决了类型擦除和多态的冲突。这里同时存在两个getValue()方法,getValue:()Ljava/lang/Number和getValue:()Ljava/lang/Object。如果是我们自己编写的java源代码,是通不过编译器的检查的。这里需要介绍几个概念。描述符和特征签名,这里只针对method,不关心field。
描述符是针对java虚拟机层面的概念,是针对class文件字节码定义的,方法描述符是包括返回值的。

A method descriptor represents the parameters that the method takes and the value that it returns: 

特征签名的概念就不一样了,java语言规范和java虚拟机规范中存在不同的定义。
java语言层面的方法特征签名可以表述为:
特征签名 = 方法名 + 参数类型 + 参数顺序;
JVM层面的方法特征签名可以表述为:
特征签名 = 方法名 + 参数类型 + 参数顺序 + 返回值类型;
如果存在类型变量或参数化类型,还包括类型变量或参数化类型编译未擦除类型前的信息(FormalTypeParametersopt)和抛出的异常信息(ThrowsSignature),上面的表述可能不太严谨,不同的jvm版本是有变更的。
这就解释了为什么编译器加入了桥方法后能够正常运行,我们加入却不行的问题。换句话说class文件结构是允许返回值不同的两个方法共存的,是符合class文件规范的。在热修复领域,桥方法的使用有时会给泛型方法修复带来很多麻烦,这里就不多说了,感兴趣的可以阅读美团的这篇文章Android热更新方案Robust开源,新增自动化补丁工具
进一步想一下,泛型类型擦除到底都擦除了哪些信息,是全部擦除吗?
其实java虚拟机规范中为了响应在泛型类中如何获取传入的参数化类型等问题,引入了signature,LocalVariableTypeTable等新的属性来记录泛型信息,所以所谓的泛型类型擦除,仅仅是对方法的code属性中的字节码进行擦除,而原数据中还是保留了泛型信息的,这些信息被保存在class字节码的常量池中,使用了泛型的代码调用处会生成一个signature签名字段,signature指明了这个常量在常量池的地址,这样我们就找到了参数化类型,空口无凭,我们写个非常简单的demo看一下,没法再简单了,我们只写了两个函数,第一个函数入参包含泛型,第二个方法入参只是string。

public class Test2 {
    public static void mytest(List<Integer> s) {
    }
    public static void mytest(String s) {
    }
}

我们利用javap工具看一下,注意此时要看详细的反汇编信息,要添加-c参数。

Constant pool:
  #14 = Utf8               mytest
  #15 = Utf8               (Ljava/util/List;)V
  #16 = Utf8               Signature
  #17 = Utf8               (Ljava/util/List<Ljava/lang/Integer;>;)V
test2.png

一目了然,可以看出来调用到了泛型的地方会添加signature和LocalVariableTypeTable,现在就明白了泛型擦除不是擦除全部,不然理解的就太狭隘了。其实,jdk提供了方法来读取泛型信息的,利用class类的get
GenericSuperClass()方法我们可以在泛型类中去获取具体传入参数的类型,本质上就是通过signature和LocalVariableTypeTable来获取的。我们可以利用这些虚拟机给我们保留的泛型信息做哪些事呢?

public abstract class AbstractHandler<T> {
    T obj;
    public abstract void onSuccess(Class<T> clazz);
    public void handle() {
        onSuccess(getType());
    }
    private Class<T> getType() {
        Class<T> entityClass = null;
        Type t = getClass().getGenericSuperclass();
        if (t instanceof ParameterizedType) {
            Type[] p = ((ParameterizedType) t).getActualTypeArguments();
            entityClass = (Class<T>) p[0];
        }
        return entityClass;
    }
}
-------------------------------------------------
public class Test1 {
    public static void main(String[] args) {
        new AbstractHandler<Person>() {
            @Override
            public void onSuccess(Class<Person> clazz) {
                System.out.println(clazz);
            }
        }.handle();
    }
    static class Person {
        String name;
    }
}
------------------------------
输出结果:class com.ljj.Test1$Person

我们来简单的分析下这段代码,定义一个抽象类AbstractHandler,提供一个回调方法onSuccess方法。然后通过一个匿名子类传入一个Person进行调用,结果在抽象类中动态的获取到了Person类型。jdk提供的api的使用基本上像getType方法所示。我们想想其实序列化的工具就是将json数据序列化为clazz对象,前提就是要传入Type的类型,这时候Type的类型获取就很重要了,我们完全可以在泛型抽象类里面来完成所有的类型获取、json序列化等工作,有些网络请求框架就是这么处理的,这也是在实际工作场景的应用。
好了,泛型引入带来的问题介绍的差不多了,最后说一下泛型的通配符。

3. 泛型的通配符

泛型中的通配符一般分为非限定通配符和限定通配符两种,限定通配符有两种: <? extends T>和 <? super T>。<? extends T> 保证泛型类型必须是 T 的子类来设定泛型类型的上边界,<? super T> 来保证泛型类型必须是 T 的父类来设定类型的下边界,泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。非限定通配符指的是<?>这种形式,可以用任意泛型类型来代替,因为泛型是不支持继承关系的,所以<?>很大程度上弥补了这一不足。说一个简单的例子来体验下?的作用。
比如说现在有两个List,一个是List<Integer>,一个是List<String>,我想用一个方法打印下list里面的值,因为泛型是无法继承的,List<Integer>和List<Object>是没有关系的,我们此时可以借助于通配符解决。

public class Test1 {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<Integer>();
        list.add(12);
        handle(list);
        List<Float> list1 = new ArrayList<Float>();
        list1.add(123.0f);
        handle(list1);
    }
    private static void handle(List<?> list) {
        System.out.println(list.get(0));
    }
}

ok,成功运行了,那如果我想把第一个元素在添加一遍呢,好说直接加一条语句就行了。

private static void handle(List<?> list){
     System.out.println(list.get(0));
     list.add(list.get(0));
}

此时,你会发现编译不过去了。

The method add(capture#2-of ?) in the type List<capture#2-of ?> is not applicable for the arguments (capture#3-of ?)。

“capture#2 of ?” 表示什么?当编译器遇到一个在其类型中带有通配符的变量,它认识到必然有一些 T ,它不知道 T 代表什么类型,但它可以为该类型创建一个占位符来指代 T 的类型。占位符被称为这个特殊通配符的捕获(capture)。这种情况下,编译器将名称 “capture#2of ?” 以 List类型分配给通配符,每个变量声明中每出现一个通配符都将获得一个不同的捕获,错误消息告诉我们不能调用add方法,因为形参类型是未知的,编译器无法检测出来了。所以我们在使用?通配符时一定要注意写入问题。
简单总结一句话:一旦形参中使用了?通配符,那么除了写入null以外,不可以調用任何和泛型参数有关的方法,当然和泛型参数无关的方法是可以调用的。
关于通配符这一块,需要具体的实例来进行学习比较好,很多种情形许多种坑,我觉得【码农每日一题】Java 泛型边界通配符基础面试题【【码农每日一题】Java 泛型边界与通配符实战踩坑面试题介绍的demo非常好,强烈建议查看,受篇幅原因,这里就不过多介绍了,有兴趣的同学可以查看。
好了,有关泛型的知识就总结这么多,有问题欢迎指正。

推荐阅读更多精彩内容

  •   在Effective中讲到泛型之处提到了一个概念,类型擦除器,这是什么呢?接下来我们跟随这篇文章探索类型擦除的...
  • 在之前的文章中分析过了多态,可以知道多态本身是一种泛化机制,它通过基类或者接口来设计,使程序拥有一定的灵活性,但是...
  • 参数类型的好处 在 Java 引入泛型之前,泛型程序设计是用继承实现的。ArrayList 类只维护一个 Obje...
  • pdf下载地址:Java面试宝典 第一章内容介绍 20 第二章JavaSE基础 21 一、Java面向对象 21 ...
  • 开发人员在使用泛型的时候,很容易根据自己的直觉而犯一些错误。比如一个方法如果接收List作为形式参数,那么如果尝试...