夏眠鱼

Oct 26, 2017

Java 泛型-类型擦除

泛型被引入到 Java 语言中,以便在编译时提供更严格的类型检查,并支持泛型编程。为了落实泛型,Java 编译器使用了类型擦除:

  • 使用泛型边界或 Object(如果没有边界)来替换泛型类型中的所有类型参数。因此,产生的字节码只包含普通类、接口和方法。
  • 如果需要,插入类型强制转换以保护类型安全。
  • 在扩展泛型类型中生成桥梁方法以保存多态性。

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

泛型类型的擦除

在类型擦除过程中,Java 编译器会擦除所有的类型参数,如果类型参数是有边界的,则将它的第一个边界来替换每一个类型参数,如果类型参数是无界的,则将 Object 类型替换每一个类型参数。

我们来看下面的这个泛型类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 来代替它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 使用一个有边界的泛型参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 编译器也可以在泛型方法的参数中进行类型擦除。我们来看下面的泛型方法吧:

1
2
3
4
5
6
7
8
9
10
11
12
// 计算 anArray 中 elem 的出现次数。
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 来替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Counts the number of occurrences of elem in anArray.
//
public static int count(Object[] anArray, Object elem) {
int cnt = 0;

for (Object e : anArray) {
if (e.equals(elem)) {
++cnt;
}
}

return cnt;
}

假设定义了如下类:

1
2
3
class Shape { /* ... */ }
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }

你可以写一个泛型方法来描绘不同的形状:

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

Java 编译器会使用 Shape 来替换 T:

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

类型擦除和桥接方法的作用

有时类型擦除会产生我们预想不到的情况。我们从下面的例子看看它是怎么发生的:

定义下面两个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);
}
}

思考下面的代码:

1
2
3
4
MyNode mn = new MyNode(5);
Node n = mn; // 原始类型 - 编译器抛出「未检查警告」
n.setData("Hello");
Integer x = mn.data; // 导致抛出一个 ClassCastException 异常

类型擦除后,上面的代码变成如下:

1
2
3
4
MyNode mn = new MyNode(5);
Node n = (MyNode)mn; // 原始类型 - 编译器抛出「未检查警告」warning
n.setData("Hello");
Integer x = (String)mn.data; // 导致抛出一个 ClassCastException 异常

桥接方法

当编译一个继承了参数化类或实现了参数化接口的类或接口时,Java 编译器可能需要创建一个称为桥接方法的合成方法,该方法是类型擦除过程的一部分。我们通常不需要担心桥接方法,但是如果出现在堆栈跟踪中,我们可能会感到困惑。

类型擦除后,Node 和 MyNode 类变成了如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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(Object),MyNode 的方法变成了 setData(Integer),所以 MyNode 的 setData 方法没有重写(override) Node 的 setData 方法。

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

对于 MyNode 类,编译器为 setData 生成以下的桥接方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyNode extends Node {

// 编译器生成的桥接方法
public void setData(Object data) {
setData((Integer) data);
}

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

// ...
}

正如我们看到的,桥接方法具有与 Node 类 setData 方法相同的方法签名,在类型擦除后,将其委托给原始的 setData 方法。

不可具体化类型

上面几节我们讨论了编译器擦除类型参数的过程。类型擦除会导致一个结果,这个结果与具有可变形参(也称 varargs)的方法有关,该方法的可变形参有不可具体化类型。

这小节我们讨论如下几个话题:

  • 不可具体化类型
  • 堆污染
  • 具有不可具体化形参的可变参数方法的潜在隐患
  • 避免来自不可具体化形参的可变参数方法的警告

不可具体化类型

可具体化类型是指在运行期间可知其类型信息的类型。它包括基本类型、非泛型、原始类型和无界通配符的调用。

不可具体化类型是指编译期间类型信息被类型擦除机制擦除的类型 – 未被定义为无界通配符的泛型类型的调用。一个不可具体化类型在运行时并不知道它的所有信息。例如 List<String>List<Number> 是不可具体化类型,JVM 不能在运行时区别这些类型。

堆污染

当参数化类型的变量引用了非参数化类型的对象时就会发生堆污染。如果程序在编译时执行一些未检查警告的操作,就会发生这样情况。如果在编译时或运行时,调用参数化类型的操作的正确性没有得到验证,就会产生未检查的警告。例如,在混合原始类型和参数化类型时,或在执行未检查类型转化时,会发生堆污染。

具有不可具体化形参的可变参数方法的潜在隐患

包含可变参数的泛型方法会发生堆污染。

思考下面的 ArrayBuilder 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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; // 有效
objectArray[0] = Arrays.asList(42);
String s = l[0].get(0); // 抛出 ClassCastException 异常
}
}

下面的例子中,HeapPollutionExample 使用 ArrayBuilder 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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[] 的元素。但由于类型擦除,编译器会将 T[] 转化成 Object[],这就有可能导致堆污染。

下面的语句将可变参数 l 赋值给 Object 数组 objectArgs:

1
Object[] objectArray = l;

这个语句有可能产生堆污染。一个与可变形参 l 的参数化类型不匹配的值可以赋值给变量 objectArgs,当然也可以赋值给 objectArray。然而编译器并不会在这个语句上生成「未检查警告」。实际上,在将可变形参 List<String>... l 转变成形参 List[] l 时,编译器已经生成一个警告。

这个语句是有效的,因为变量 l 具有 List[] 类型,List[] 类型是 Object[] 的子类型。因此,如果我们将任何类型的 List 对象分配给 objectArray 数组的任何数组组件,则编译器不会发出警告或错误:

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

假设我们使用下面的语句来调用 ArrayBuilder.faultyMethod 方法:

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

JVM 会在运行时抛出 ClassCastException 异常:

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

在变量 l 的第一个数组中存储的对象具有 List<Integer> 类型,但是这个语句期望一个 List<String> 类型的对象。

避免来自不可具体化形参的可变参数方法的警告

如果我们定义了一个具有参数化类型的可变形参方法,要先确保方法本身不会抛出 ClassCastException 异常或因处理不当可变形参造成类似的异常,然后通过添加 @SafeVarargs 注解到静态或非构造方法的声明中,就能避免编译器生成的警告。

@SafeVarargs 注解是方法概要的一部分,该注解断言此方法能恰当地处理可变形参。

也可以通过在方法的声明中添加以下内容来抑制生成警告:
@SuppressWarnings({“unchecked”, “varargs”})

但是这种方法并不会抑制来自方法调用的地方产生警告。

OLDER > < NEWER