Valhalla (1): 背景 How We Got the Generics We Have

翻译 https://openjdk.java.net/projects/valhalla/design-notes/in-defense-of-erasure

在我们讨论泛型该如何发展时,我们首先要看看当前泛型是什么样的。这篇文章主要聚焦在泛型是如何发展到现在的,以及为什么会是这个样子,了解这些可以帮助我们基于现有的泛型尝试构建出“更好”的泛型。

特别是,我们强调擦除实际上是 2004 年将泛型添加到 Java 时明智且务实的选择,而且很多导致我们选择擦除的因素至今仍在运作。

系列文章(未完待续)

擦除 Erasure

向任何开发人员询问 Java 泛型,你可能会得到关于擦除的负面情绪(虽然通常是不知情的)。擦除可能是 Java 中最广泛、最深入被误解的概念了。

擦除不是 Java 特有的,也不是泛型特有的,它是一种无处不在且必要的工具,用于将代码转换为较低级别的代码(例如从 Java 源代码编译为字节码,或将 C 源代码编译为本地代码)。这是因为当我们从高级语言转换到到中间表示再到本地代码再到硬件时,底层提供的类型抽象几乎总是比高层提供更弱更简单,这也是合理的(我们不想将虚拟分派的语义加入到 X86 指令集中,或者在其寄存器中支持 Java 的基本类型)。擦除是一种将更丰富的类型映射到较低级别的简单类型的技术(理想情况下,在更高级别执行完整的类型检查之后),这也是编译器的日常工作。

例如,Java 字节码包含在堆栈和局部变量(iloadistore)之间移动整数值的指令,以及对整数(iaddimul 等)进行算术运算的指令。单精度浮点数(floadfstorefmul 等)、长整数(lloadlstorelmul)、双精度浮点数(dloaddstoredmul)和对象引用(aloadastore)都有类似的指令。但其他基本类型(byteshortcharboolean)没有这样的指令,因为这些类型被编译器擦除为整数,并使用整数移动和算术指令。这是字节码指令集设计的权衡,它降低了指令集的复杂性,进而可以提高运行时的效率。Java 语言的许多其他特性(例如检查异常、方法重载、枚举、definite assignment analysis、内部类、通过 lambda 或局部类捕获局部变量等)是“语言错觉”,它们在 Java 编译器中检查,但在转换为类文件时被擦除了。

类似地,在将 C 编译为本机代码时,有符号和无符号整数都被擦除到通用寄存器中(没有单独的有符号寄存器和无符号寄存器),并且 const 变量存储在可变寄存器和可变的内存中。我们根本不觉得这种擦除很奇怪。

同构与异构翻译 Homogeneous vs. Heterogeneous Translations

在具有参数多态性的语言中,翻译泛型类型有两种常用的方法:同构和异构翻译。在同构翻译中,泛型类 Foo<T> 被转换为单一的产物,例如 Foo.class (对于泛型方法也是如此)。在异构翻译中,泛型类型或方法的每个实例(Foo<String>Foo<Integer>) 都被视为一个单独的实体,并生成独立的产物。例如,C++ 使用异构翻译,模板的不同实例是完全不同的类型,具有不同的语义和不同的生成代码。vector<int>vector<float> 是不同的类型。一方面,这对于类型安全(每个实例可以在扩展后单独进行类型检查)和生成代码的质量(因为每个实例可以单独优化)非常有用。另一方面,这意味着更大的代码空间占用(因为 vector<int>vector<float> 有独立的代码),我们不能谈论“某个东西的 vector”(就像 Java 通过通配符所做的那样),因为每个实例都是一个完整的不相关的类型。(作为对可能的占用空间成本的极端演示,Scala 试验了一个 @specialized 注解,当应用于类型变量时,会导致编译器为所有基本类型生成专门的版本。这听起来很酷,但导致生成类的数量随着类型变量的数量爆炸式增长,因此可以从几行代码轻松生成一个 100MB 的 JAR 文件。)

在同构和异构翻译之间的选择,涉及到语言设计者一直在进行的各种权衡。异构翻译提供了更多的类型特异性,但代价是更大的静态和动态开销,以及更少的运行时共享,所有这些都会对性能产生影响。同构翻译更适合抽象相似的参数类型,例如 Java 的通配符或 C# 的 declaration-site variance(C++ 缺少这两者,vector<int>vector<float> 之间没有任何共同点)。有关翻译策略的更多信息,请参阅这篇有影响力的论文

在 Java 中擦除泛型 Erased Generics in Java

Java 使用同构翻译来翻译泛型。泛型在编译时进行类型检查,但是在生成字节码时,像 List<String> 这样的泛型类型会被擦除到 List,而像 <T extends Object> 这样的类型变量会被擦除到它们的边界(在这种情况下就是 Object)。
如果我们有:

1
2
3
4
5
6
7
8
9
class Box<T> {
private T t;

public Box(T t) { this.t = t; }

public Box<T> copy() { return new Box<>(t); }

public T t() { return t; }
}

javac 编译器构建出一个单一的类文件 Box.class,作为 Box 的所有实例化的实现,包括通配符 (Box<?>) 和原始类型 (Box)。字段、方法、父类型的描述都被擦除,类型变量被擦除到它们的边界,泛型类型参数被擦除到它们的原始类型(List<String> 擦除到 List),如下所示:

1
2
3
4
5
6
7
8
9
class Box {
private Object t;

public Box(Object t) { this.t = t; }

public Box copy() { return new Box(t); }

public Object t() { return t; }
}

注:上面一段描述比较绕,指的是类型参数出现在不同的位置,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Demo<
F extends Number,
M extends List<F>,
S extends Serializable,
E extends Object> implements List<S> {
private F field;
private List<E> list;
private S object;

M t(M m) {
return m;
}
}

被擦除为:

1
2
3
4
5
6
7
8
9
class Demo implements List {
private Number field;
private List list;
private Serializable object;

List t(List m) {
return m;
}
}

泛型签名会被保留在 Signature 属性中,以便编译器在读取类文件时可以看到泛型签名,但 JVM 在链接时仅使用擦除后的描述符。这种转换方案意味着在类文件级别,Box<T> 的布局和 API 都被擦除了。同样的事情也会发生在使用的时候,对 Box<String> 的引用被擦除到 Box,并且会插入到 String 的类型转换。例如:

1
2
Box<String> box = new Box<>();
String value = box.t();

会被转换成:

1
2
Box box = new Box();
String value = (String)box.t();

有没有替代方案 Why? What were the Alternatives?

正是在这一点上,人们很容易生气并宣称这是愚蠢或懒惰的选择,或者说擦除是一种非常 hack 的行为。毕竟,为什么编译器会丢弃完美的类型信息呢?

为了更好地理解这个问题,我们还应该问:我们是否要特化该类型信息,我们希望用它做什么,以及与之相关的成本是多少?我们可以设想几种不同的场景,需要使用特化的类型参数信息:

  • 反射:对于某些人来说,“特化泛型”仅仅意味着可以知道 List 它是什么的列表,无论是使用诸如 instanceof 之类的语言特性或对类型变量进行模式匹配,还是使用反射库来查询类型参数。
  • 特化 API 和布局:在有基本类型或者内联类的语言中,将 Pair<int, int> 布局扁平化成两个 int 会更好,而不是两个指向包装对象的引用。
  • 运行时类型检查:当使用者尝试将 Integer 放入 List<String> 中时(例如,通过 List 原始类型引用),会导致内存污染。能够识别这类问题并失败,会比(可能的)后续再通过类型转换检测会更好。

Integer 放入 List<String> ,在后续不会读取的情况下,可能是不会出报错的。

虽然并不相互排斥,但这三种可能(反射、特化和类型检查)有助于实现不同的目标(分别是便利性、性能和安全性),并且具有不同的含义和成本。虽然说“我们想要特化”很容易,但如果深入研究,会发现,对于什么是最重要的、它们的成本和收益是什么,是存在巨大分歧的。

要了解擦除在这里是多么明智和务实的选择,我们还必须了解当时的目标、优先事项和限制以及替代方案。

逐步迁移兼容性 Goal: Gradual Migration Compatibility

Java 泛型定了一个雄心勃勃的要求:必须能够以二进制兼容和源代码兼容的方式将现有的非泛型类演化为泛型类。

这意味着现有的调用者和子类,比如 ArrayList,可以继续重新编译而不改变成泛型的 ArrayList<T>,并且现有的类文件将继续链接到泛型的 ArrayList<T> 的方法。满足上述要求,意味着调用者和泛化类的子类可以选择立即、稍后或永远不进行泛化,并且可以独立于其他调用者或子类的维护者的选择。

如果没有这个要求,生成一个类将需要一个“截止日期”,所有调用者和子类就算不修改,但至少必须重新编译。对于像 ArrayList 这样的核心类,这本质上要求重新编译世界上所有的 Java 代码(或永久降级保留在 Java 1.4 上)。因为整个 Java 生态中已经不存在这样的“截止日期”,所以我们需要一个泛型类型系统,允许核心平台类(以及流行的第三方库)被泛化,而不需要调用者知道它们被泛化。(更糟糕的是,它不会是一个确定的时间,因为世界上所有的代码都不可能在同一个时刻被泛型化)

另一种满足此要求的方式是:隔离所有可以被泛化的代码被认为是不可接受的,或者让开发人员在泛型之间进行选择并保留它们已有的代码。通过使泛化成为兼容操作,可以保留已有的代码,而不是废弃掉。

原文有点绕,感觉上是在说,可以单独设计一套 API(例如 GenericList<T> ),然后之前的 API 继续存在(例如只有 List 而不存在 List<T> ),这样开发人员可以自行选择,之前的代码都可以继续保留。

对“截止日期”的厌恶来自 Java 设计的一个基本原则:Java 是单独编译和动态链接的。单独编译是指将每个源文件编译成一个或多个类文件,而不是将一组源文件编译成单个产物。动态链接意味着类之间的引用在运行时基于符号信息链接的:如果类 C 在调用了 D 中 void m(int x) 方法,那么在 C 的类文件中我们记录了调用的方法的名称和描述符 (I)V,并在链接时在 D 中查找一个方法使用此名称和描述符,如果找到匹配项,则链接该调用。

这听起来像是非常巨大的工作,但是将编译和动态链接分开是 Java 的最大优势之一:可以针对一个版本的 D 编译 C,并在类路径上使用不同版本的 D 运行(只要不破坏二进制兼容性)。对动态链接的承诺允许我们简单地在类路径上放置一个新的 JAR 以更新到依赖项的新版本,而无需重新编译任何东西。 我们经常这样做,甚至都没有注意到。但是如果出现问题,它确实会被注意到。

在泛型被引入 Java 的时候,世界上已经有非常多的 Java 代码,它们的类文件中充满了对 java.util.ArrayList 等 API 的引用。如果我们不能兼容地泛化这些 API,那么我们将不得不编写新的 API 来替换它们。更糟糕的是,所有旧 API 的调用代码都将陷入一个站不住脚的选择:要么永远停留在 1.4,要么重写它们以同时使用新的 API(不仅包括应用程序代码,还包括应用程序依赖的所有第三方库)。这会影响当时几乎所有存在的 Java 代码。

C# 做出了相反的选择:更新他们的 VM,并使它们现有的库和所有依赖它的用户代码无效(需要升级到泛型)。当时他们可以这样做,因为世界上的 C# 代码相对较少,但 Java 当时没有这个选项。

然而这种选择的一个结果是,一个泛型类将同时拥有泛型和非泛型调用者或子类,这将是一种预期的情况。这对软件开发过程来说是一个福音(兼容),但在这种混合使用下对类型安全有潜在的影响。

内存污染 Heap Pollution

以这种方式擦除,并支持泛型和非泛型调用者之间的相互操作,会产生内存污染的可能性:存储在 Box 中内容的运行时类型,与预期的编译时类型不兼容。 当调用者使用 Box<String> 时,会插入 TString 的类型转换,所以在类型变量(Box 的实现)到具体类型转换的时候,内存污染会被检测出来。在存在堆污染的情况下,类型转换会失败。

内存污染可能来自非泛型代码使用泛型类,或者当我们使用未经检查的强制转换或原始类型,来伪造对错误泛型类型变量的引用时 (当我们使用未经检查的强制转换或原始类型时,编译器会发出警告),例如:

1
2
3
4
Box<String> bs = new Box<>("hi!");   // safe
Box<?> bq = bs; // safe, via subtyping
Box<Integer> bi = (Box<Integer>) bq; // unchecked cast -- warning issued
Integer i = bi.get(); // ClassCastException in synthetic cast to Integer

这段代码的错误是从 Box<?>Box<Integer> 的未经检查的转换:我们必须相信开发人员的说法,即指定的 Box 确实是 Box<Integer>。但是内存污染并没有立即被捕获,只有当我们尝试将 Box 中的字符串用作整数时,才会检测到出现问题。在同构翻译下,如果我们在将 Box 用作 Box<String> 之前将 Box<Integer> 转换成 Box<String>,不会发生任何事情(无论好坏)。但在异构翻译下,Box<String>Box<Integer> 将具有不同的运行时类型,并且此转换将失败。

Java 语言实际上为泛型提供了相当强大的安全保证,只要我们遵循以下规则:如果程序编译时没有未检查或原始类型警告(不考虑反射之类的情况),则编译器插入的强制转换永远不会失败。

未检查警告:Unchecked cast: ‘…’ to ‘…’ 。
原始类型警告:Raw use of parameterized class ‘…’ 。

换句话说,只有当我们与非泛型代码进行交互或对编译器撒谎时,才会发生内存污染。在发现内存污染时,我们会得到一个明确的异常(ClassCastException),告诉我们预期的类型和实际的类型。

JVM 生态 Context: Ecosystem of JVM Implementations and Languages

围绕泛型的设计选择还受到 JVM 实现生态结构和在 JVM 上运行的语言结构的影响。虽然对大多数开发人员来说,“Java”是一个整体的概念,但实际上 Java 语言和 Java 虚拟机 (JVM) 是独立的实体,分别有自己的规范。 Java 编译器为 JVM 生成类文件(其格式和语义在 Java 虚拟机规范中列出),但是 JVM 会很乐意运行任何有效的类文件,而不管它最初来自什么源语言。据统计,有超过 200 种语言使用 JVM 作为编译目标,其中一些与 Java 语言(例如 Scala、Kotlin)有很多共同点,而另一些则是非常不同的语言(例如,JRuby、Jython、Jaskell)。

JVM 作为编译目标如此成功的一个原因(即使是与 Java 完全不同的语言),是它提供了一个相当抽象的计算模型,而受 Java 语言本身的影响有限(独立的规范)。语言和虚拟机之间的抽象层不仅激发了在 JVM 上运行的其他语言的生态,而且对 JVM 独立实现的生态也很有用。虽然今天的市场已经大幅整合,但在将泛型添加到 Java 时,有十多种商业上可行的 JVM 实现。将泛型特化意味着我们不仅需要增强语言以支持泛型,还需要增强 JVM。

虽然当时在技术上可以为 JVM 添加泛型支持,但这不仅是一项需要大量实现者之间协调的重大工程,而且 JVM 上的语言生态也可能有关于特化泛型的意见。例如,如果特化的解释执行包括运行时的类型检查,Scala(及它的 declaration-site variance)是否愿意让 JVM 强制执行 Java 中的(不变的)泛型子类型规则?

擦除是务实的妥协 Erasure was the Pragmatic Compromise

总之,这些限制(技术和生态)作为一股强大的力量推动我们走向同构翻译策略,即在编译时擦除泛型类型信息。 总而言之,推动我们做出这一决定的因素包括:

  • 运行时成本。异构转换需要各种运行时成本:更大的静态和动态占用空间、更大的类加载成本、更大的 JIT 成本和代码缓存压力等。这可能使开发人员不得不在类型安全和性能之间做出选择。
  • 迁移的兼容性。当时还没有已知的翻译方案允许迁移到特化的泛型,并保持源代码和二进制兼容,从而造成“截止日期”并使开发人员需要对大量现有代码进行改动。
  • 还是运行时成本。如果将特化解释为在运行时检查类型(就像动态检查 Java 协变数组中的存储一样),这将对运行时产生重大影响,因为 JVM 必须在运行时对每个字段或数组元素存储时执行泛型子类型检查,使用语言的泛型类型系统。当类型像 List<String> 这样的简单类型时,这可能听起来既简单又成本低,但是当遇到像 Map<? extends List<? super Foo>>, ? super Set<? extends Bar>> 这样的类型时会很快变得成本高昂。(事实上,后来的研究对泛型子类型的可判定性产生了怀疑)
  • JVM 生态。能够让十几家 JVM 供应商就是否以及如何在运行时特化类型达成一致,是非常不现实的。
  • 交付。即使有可能让十几个 JVM 供应商就一个实际可行的方案达成一致,也会大大增加复杂性、时间周期和风险,这是非常复杂且冒险的工作。
  • 语言生态。像 Scala 这样的语言可能不乐意将 Java 的不变泛型融入 JVM 的语义中。就 JVM 中泛型跨语言可接受的语义达成一致,将再次增加复杂性、时间周期和风险,这是非常复杂且冒险的工作。
  • 无论如何,用户都必须处理擦除(因此也必须处理内存污染)。即使可以在运行时保留类型信息,在类被泛化之前总是会编译出原始的类文件,因此堆中仍然可能存在没有任何附加类型信息的 ArrayList,一样有内存污染的风险。
  • 某些有用的语句是无法表达的。当现有的泛型代码知道一些编译器不知道的运行时类型时,它偶尔会诉诸未经检查的强制转换,并且在泛型类型系统中没有简单的方法来表达它。许多这样的技术对于特化的泛型是不可能的,这意味着它们必须以不同的方式表达,而且通常成本更高。(没有明白具体说的是什么,可能指例如 instanceOf List 这样的判断)

很明显成本和风险是巨大的。那么会有什么好处?之前我们提到了特化的三个可能的好处:反射、布局特化和运行时类型检查。上述论点在很大程度上排除了我们进行运行时类型检查的可能性(运行时成本、不可判定性风险、生态风险以及原始类型实例的存在)。

能够知道 List 的元素类型是什么(也许可以知道,但也可能没有)当然会有一些好处。只是成本和收益相差了几个数量级。(同构翻译的另一个代价是不能支持基本类型作为类型参数,例如必须使用 List<Integer> 而不是 List<int>

普遍认为擦除是不好的 hack 的误解,通常源于缺乏对替代方案的真实成本的认识,包括工程工作量、时间周期、交付风险、性能、生态影响和便利性,鉴于已经编写的大量 Java 代码以及在 JVM 上运行的 JVM 实现和语言的多样化生态。

Valhalla (1): 背景 How We Got the Generics We Have

https://www.alphalxy.com/2021/10/how-we-got-the-generics-we-have/

Author

Xinyu Liu

Posted on

2021-10-08

Updated on

2021-12-26


Comments