系列中的上一篇
当前教程
类型擦除
系列中的下一篇

系列中的上一篇: 通配符

系列中的下一篇: 泛型的限制

类型擦除

 

泛型类型的擦除

泛型被引入 Java 语言是为了在编译时提供更严格的类型检查,并支持泛型编程。为了实现泛型,Java 编译器对泛型类型应用类型擦除,以

  • 用其边界替换泛型类型中的所有类型参数,如果类型参数是无界的,则用 Object 替换。因此,生成的字节码只包含普通的类、接口和方法。
  • 如果需要,插入类型转换以保持类型安全。
  • 生成桥接方法以保留扩展泛型类型中的多态性。

类型擦除确保不会为参数化类型创建新的类;因此,泛型不会产生运行时开销。

在类型擦除过程中,Java 编译器会擦除所有类型参数,并用其第一个边界替换每个类型参数(如果类型参数是有界的),或用 Object 替换(如果类型参数是无界的)。

考虑以下表示单链表中节点的泛型类

public class Node<T> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}

由于类型参数 T 是无界的,Java 编译器会用 Object 替换它

public class Node {

    private Object data;
    private Node next;

    public Node(Object data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Object getData() { return data; }
    // ...
}

在以下示例中,泛型 Node 类使用有界类型参数

public class Node<T extends Comparable<T>> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}

Java 编译器会用第一个边界类 Comparable 替换有界类型参数 T

public class Node {

    private Comparable data;
    private Node next;

    public Node(Comparable data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Comparable getData() { return data; }
    // ...
}

 

泛型方法的擦除

Java 编译器还会擦除泛型方法参数中的类型参数。考虑以下泛型方法

// Counts the number of occurrences of elem in anArray.
//
public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

由于 T 是无界的,Java 编译器会用 Object 替换它

public static int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (Object e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

假设定义了以下类

class Shape { /* ... */ }
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }

可以编写一个泛型方法来绘制不同的形状

public static <T extends Shape> void draw(T shape) { /* ... */ }

Java 编译器会用 Shape 替换 T

public static void draw(Shape shape) { /* ... */ }

 

类型擦除和桥接方法的影响

有时类型擦除会导致你可能没有预料到的情况。以下示例展示了这种情况是如何发生的。以下示例展示了编译器有时如何在类型擦除过程中创建合成方法(称为桥接方法)。

给定以下两个类

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

考虑以下代码

MyNode mn = new MyNode(5);
Node n = mn;            // A raw type - compiler throws an unchecked warning
n.setData("Hello");     // Causes a ClassCastException to be thrown.
Integer x = mn.data;    

类型擦除后,此代码变为

MyNode mn = new MyNode(5);
Node n = (MyNode)mn;         // A raw type - compiler throws an unchecked warning
n.setData("Hello");          // Causes a ClassCastException to be thrown.
Integer x = (String)mn.data; 

下一节解释了为什么在 n.setData("Hello"); 语句处抛出 ClassCastException

 

桥接方法

在编译扩展参数化类或实现参数化接口的类或接口时,编译器可能需要创建合成方法(称为桥接方法)作为类型擦除过程的一部分。通常你不需要担心桥接方法,但如果你在堆栈跟踪中看到一个桥接方法,你可能会感到困惑。

类型擦除后,NodeMyNode 类变为

public class Node {

    public Object data;

    public Node(Object data) { this.data = data; }

    public void setData(Object data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node {

    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

类型擦除后,方法签名不匹配;Node.setData(T) 方法变为 Node.setData(Object)。因此,MyNode.setData(Integer) 方法不会覆盖 Node.setData(Object) 方法。

为了解决这个问题并保留类型擦除后泛型类型的多态性,Java 编译器会生成一个桥接方法以确保子类型按预期工作。

对于 MyNode 类,编译器会为 setData() 生成以下桥接方法

class MyNode extends Node {

    // Bridge method generated by the compiler
    //
    public void setData(Object data) {
        setData((Integer) data);
    }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }

    // ...
}

桥接方法 MyNode.setData(object) 会委托给原始的 MyNode.setData(Integer) 方法。因此,n.setData("Hello"); 语句会调用方法 MyNode.setData(Object),并且会抛出 ClassCastException,因为 "Hello" 无法转换为 Integer

 

不可再现类型

我们讨论了编译器删除与类型参数和类型参数相关的信息的流程。类型擦除对可变参数(也称为 varargs)方法有影响,这些方法的可变参数形式参数具有不可再现类型。有关可变参数方法的更多信息,请参阅将信息传递给方法或构造函数中的任意数量的参数部分。

本页涵盖以下主题

  • 不可再现类型
  • 堆污染
  • 具有不可再现形式参数的可变参数方法的潜在漏洞
  • 防止具有不可再现形式参数的可变参数方法的警告

可再现类型是在运行时完全可用其类型信息的类型。这包括基本类型、非泛型类型、原始类型和无界通配符的调用。

不可再现类型是在编译时通过类型擦除删除了信息的类型——未定义为无界通配符的泛型类型的调用。不可再现类型在运行时不包含所有信息。不可再现类型的示例包括 List<String>List<Number>;JVM 在运行时无法区分这些类型。如泛型的限制部分所示,在某些情况下无法使用不可再现类型:例如,在 instanceof 表达式中,或作为数组中的元素。

 

堆污染

堆污染发生在参数化类型的变量引用了不是该参数化类型的对象时。如果程序执行了一些操作导致在编译时出现未经检查的警告,就会发生这种情况。如果在编译时(在编译时类型检查规则的限制范围内)或在运行时,无法验证涉及参数化类型(例如,强制转换或方法调用)的操作的正确性,则会生成未经检查的警告。例如,在混合原始类型和参数化类型时,或在执行未经检查的强制转换时,会发生堆污染。

在正常情况下,当所有代码同时编译时,编译器会发出未经检查的警告以提醒你注意潜在的堆污染。如果你分别编译代码的各个部分,就很难检测到堆污染的潜在风险。如果你确保你的代码在没有警告的情况下编译,那么就不会发生堆污染。

 

具有不可再现形式参数的可变参数方法的潜在漏洞

包含可变参数输入参数的泛型方法会导致堆污染。

考虑以下 ArrayBuilder

public class ArrayBuilder {

  public static <T> void addToList (List<T> listArg, T... elements) {
    for (T x : elements) {
      listArg.add(x);
    }
  }

  public static void faultyMethod(List<String>... l) {
    Object[] objectArray = l;     // Valid
    objectArray[0] = Arrays.asList(42);
    String s = l[0].get(0);       // ClassCastException thrown here
  }

}

以下示例 HeapPollutionExample 使用 ArrayBuiler

public class HeapPollutionExample {

  public static void main(String[] args) {

    List<String> stringListA = new ArrayList<String>();
    List<String> stringListB = new ArrayList<String>();

    ArrayBuilder.addToList(stringListA, "Seven", "Eight", "Nine");
    ArrayBuilder.addToList(stringListB, "Ten", "Eleven", "Twelve");
    List<List<String>> listOfStringLists =
      new ArrayList<List<String>>();
    ArrayBuilder.addToList(listOfStringLists,
      stringListA, stringListB);

    ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));
  }
}

编译时,ArrayBuilder.addToList() 方法的定义会产生以下警告

warning: [varargs] Possible heap pollution from parameterized vararg type T

当编译器遇到可变参数方法时,它会将可变参数形式参数转换为数组。但是,Java 编程语言不允许创建参数化类型的数组。在 ArrayBuilder.addToList() 方法中,编译器会将可变参数形式参数 T... 元素转换为形式参数 T[] 元素(一个数组)。但是,由于类型擦除,编译器会将可变参数形式参数转换为 Object[] 元素。因此,存在堆污染的可能性。

以下语句将可变参数形式参数 l 赋值给 Object 数组 objectArgs

Object[] objectArray = l;

此语句可能会引入堆污染。与可变参数形式参数 l 的参数化类型不匹配的值可以赋值给变量 objectArray,因此可以赋值给 l。但是,编译器在此语句处不会生成未经检查的警告。编译器在将可变参数形式参数 List<String>... l 转换为形式参数 List[] l 时已经生成了警告。此语句是有效的;变量 l 的类型为 List[],它是 Object[] 的子类型。

因此,如果你将任何类型的 List 对象赋值给 objectArray 数组的任何数组组件,编译器不会发出警告或错误,如以下语句所示

objectArray[0] = Arrays.asList(42);

此语句将包含一个类型为 Integer 的对象的 List 对象赋值给 objectArray 数组的第一个数组组件。

假设你使用以下语句调用 ArrayBuilder.faultyMethod()

ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));

在运行时,JVM 会在以下语句处抛出 ClassCastException

// ClassCastException thrown here
String s = l[0].get(0);

变量l 的第一个数组组件中存储的对象类型为List<Integer>,但此语句需要类型为List<String> 的对象。

 

防止带有不可再现形式参数的可变参数方法的警告

如果声明一个带有参数化类型参数的可变参数方法,并且确保方法体不会因对可变参数形式参数处理不当而抛出ClassCastException 或其他类似异常,可以通过在静态和非构造函数方法声明中添加以下注释来防止编译器为这些类型的可变参数方法生成的警告。

@SafeVarargs

@SafeVarargs 注释是方法契约的已记录部分;此注释断言方法的实现不会对可变参数形式参数进行不当处理。

也可以(虽然不太理想)通过在方法声明中添加以下内容来抑制这些警告。

@SuppressWarnings({"unchecked", "varargs"})

但是,这种方法不会抑制从方法调用站点生成的警告。如果您不熟悉@SuppressWarnings 语法,请参阅注释 部分。


上次更新: 2021 年 9 月 14 日


系列中的上一篇
当前教程
类型擦除
系列中的下一篇

系列中的上一篇: 通配符

系列中的下一篇: 泛型的限制