UCB-CS294-113-虚拟机与托管运行时笔记-全-

UCB CS294-113 虚拟机与托管运行时笔记(全)

001:课程介绍与虚拟机概述 🖥️

在本节课中,我们将学习虚拟机和托管运行时的基本概念、课程目标以及几种不同类型的虚拟机示例。课程将涵盖从历史背景到实际应用的广泛内容,旨在为后续深入学习打下基础。

课程概述

大家好,欢迎来到这门关于虚拟机和托管运行时的新课程。我叫Mario Walchko,在Oracle工作。Patrick是助教,他将帮助大家完成练习和其他事务。

首先,我需要展示一张法律声明幻灯片。请不要根据本课程内容购买Oracle股票,如果因此产生损失,请不要起诉我或其他人。同样,本课程也不涉及任何产品决策。

课程目标

在本课程中,我希望大家通过本学期的学习,能够充分理解虚拟机领域,并对相关技术有扎实的实践经验。这是一门非常实用的课程,讲座内容旨在帮助大家能够在实验环节中独立完成任务。

学习方法

我们将通过以下方式达成目标:

  • 文献与历史:了解该领域的学术背景和重要里程碑,理解特定技术开发的背景和解决的问题。
  • 实验练习:通过实现一系列逐步复杂的练习,在一个名为Fi的简单语言环境中应用主要实现技术。
  • 每周安排:每周约有2小时的讲座,偶尔会有特邀嘉宾来介绍重要论文或进行问答。其余时间将用于讨论作业结果和阅读材料。

课程评估与调整

大部分评估将基于实验练习,由Patrick和我共同评分。由于这是本课程材料的首次运行,我们不确定时间安排是否合理。如果大家普遍觉得任务过重,我们会动态调整截止日期。我们的目标是让尽可能多的同学完成课程。

课程范围与视角

需要说明的是,本课程无法做到面面俱到。这不是一门编译器课程,我假设大家已经具备一些编译器相关知识。此外,课程内容不可避免地会反映我个人的经验和观点,重点会放在我熟悉的领域,例如语言虚拟机。对于系统虚拟机,我只会简要提及。

讲师介绍

我在Oracle担任架构师。我从事虚拟机相关工作约30年,我的第一个虚拟机项目是1984年的硕士论文,内容是构建一个Smalltalk虚拟机。此后,我在Sun公司参与了多种虚拟机项目,包括Self虚拟机和各种Java系统。目前,我在Oracle研究院工作,负责虚拟机研究。

开设本课程的原因

我认为更多人需要了解虚拟机知识。虚拟机和托管语言在当今计算中占据很大比重,但行业中精通此道的人才却非常稀缺。我希望通过这门课程增加这方面的人才储备。此外,这也是一个非常有趣且实用的主题。

学生背景调查

在继续之前,我想了解一下大家的情况。请依次告诉我:

  1. 你的名字。
  2. 你最精通的三种编程语言。
  3. 你最喜欢的编程语言(可以不在前三种之中)。
  4. 用一句话描述你最自豪的程序或项目。

(学生自我介绍环节,内容省略)

此外,我想通过举手了解一下:谁写过解释器?谁写过编译器?谁接触过虚拟机或为其工作过?

课程材料与安排

幻灯片已上传至Piazza,大家可以下载并跟随课程。请不要提前阅读,因为我偶尔会提问,希望大家先思考再看答案。

今天的内容是一个相对温和的入门介绍,主要涉及术语、背景材料,并浏览几种不同的虚拟机,让大家感受这个领域的多样性。之后,我们将开始讲解第一种技术:解释执行。

术语与定义

我将使用一些术语和定义,其中一些是我自己提出的,大家可以提出异议,因为该领域的定义有时并不明确。

我借鉴了Jim Smith和Ravi Nair十年前出版的《Virtual Machines》一书中的分类。这是一本很好的书,虽然有些过时,但第一章对该领域的概述和术语分类很有价值。

通用的虚拟机架构非常简单:上层是客户机,下层是主机,中间通过接口连接。客户机和主机的架构可以相同,也可以不同。

虚拟机是某种机器架构的软件实现。因为是软件,所以是“虚拟”的。它需要底层硬件来运行。客户机可能是一个抽象机器,也可能是对某个真实机器的仿真。主机通常是硬件,但也可以是其他虚拟机,从而实现虚拟机堆叠。

虚拟机示例

让我们看看几种不同的虚拟机及其特性:

1. Java虚拟机 (JVM)

这是一个语言虚拟机的典型例子,最初设计用于运行单一语言(Java)。后来被重新用于运行多种其他语言(如Scala、Clojure、JRuby、JavaScript等)。

Java虚拟机在90年代中期首次开发,与80年代末70年代初的早期语言虚拟机(如Pascal的P-machine和Smalltalk虚拟机)非常相似。它的不同之处在于获得了大规模采用,从而引发了行业对虚拟机实现的大量投资和研发。

它部署的范围极广,从传感器设备上的几十KB内存,到服务器和集群应用。我们将在后续课程中剖析JVM架构。

2. VirtualBox

这是一个系统虚拟机,用于在x86架构上运行操作系统和应用程序。它的主要用途是同时运行多个客户机操作系统,例如在Mac上运行Windows应用程序,而无需分区磁盘或重新启动。

系统虚拟机可以分为两类:

  • 托管式虚拟机:如VirtualBox,运行在主机操作系统之上。
  • 经典系统虚拟机:如IBM早期的虚拟机监控程序,直接运行在硬件之上,将资源分区给多个客户操作系统。

3. Rosetta

这是苹果公司在2005年从PowerPC转向x86架构时使用的二进制翻译虚拟机。它允许PowerPC应用程序在新的x86 Mac上运行,为用户提供了过渡期。

Rosetta是一个进程虚拟机,它实现了应用程序二进制接口(ABI),而不是整个系统接口。

4. Dynamo

这是惠普研究院开发的一个系统,用于动态重新优化PA-RISC二进制代码的热点部分,使其运行得更快。它是跟踪驱动优化的一个典型例子。

5. Transmeta Crusoe

这是一个协同设计虚拟机的例子,虚拟机与Crusoe处理器芯片一同设计。它通过二进制翻译在VLIW(超长指令字)架构上模拟x86,旨在提供与x86相当的性能,但功耗更低。

虚拟机分类

根据《Virtual Machines》一书,虚拟机可分为:

  • 进程虚拟机:实现应用程序二进制接口(ABI)。
    • 相同ISA:动态二进制优化器。
    • 不同ISA:动态二进制翻译器(语言虚拟机可归入此类)。
  • 系统虚拟机:实现机器指令集架构(ISA)。
    • 相同ISA:经典系统虚拟机(虚拟机监控程序)或托管式虚拟机。
    • 不同ISA:整个系统虚拟机或协同设计虚拟机。

其他相关术语

  • 模拟器:通常指模仿真实指令集的东西。
  • 仿真器:可能更慢,或用于观察和测量。
  • 解释器:直接执行程序元素的机制。
  • 虚拟机监控程序/管理程序:类似于虚拟机监控程序,通常与资源分区相关联。
  • 源/目标:有时用作客户机/主机的替代词。
  • 原生/虚拟指令集:对应于主机和客户机架构。

虚拟机堆叠与托管运行时

虚拟机可以堆叠运行。例如,一个Ruby应用程序运行在JRuby(用Java实现的Ruby)上,JRuby运行在JVM上,JVM运行在Linux上,Linux通过VirtualBox运行在Windows上,而Windows又运行在Crusoe处理器上。这展示了虚拟机的灵活性。

“托管运行时”这个术语通常与.NET的公共语言运行时(CLR)相关。托管语言是指编译器产生的代码不能直接无限制地访问硬件。但本质上,CLR也是一个虚拟机。在本课程中,我将统一使用“虚拟机”这个术语。

使用虚拟机的理由

使用虚拟机的主要原因包括:

  • 解耦:将客户机与主机分离,提供独立性。
  • 安全沙箱:通过约束对底层平台的访问来增强安全性。
  • 硬件虚拟化:将单一硬件划分为多个虚拟环境。
  • 资源整合:在同一硬件上运行多个操作系统或应用。
  • 版本管理:同时运行不同版本的软件环境。
  • 持久化:通过快照保存和恢复虚拟机状态。
  • 性能:通过自动优化提升性能(有时)。

课程后续内容预告

接下来我们将休息一下。休息后,Patrick将介绍Fi语言,这是所有实验练习的基础。之后,我将讲解抽象语法树解释器,并查看一些解释器代码。下周,我们将深入虚拟机内部,研究字节码设计等内容。

后续课程将包括:

  • JVM规范剖析。
  • 内存管理(垃圾回收)的详细讲解。
  • 即时编译(JIT)及其优化。
  • 使用虚拟机构建工具包。
  • 跟踪编译和元跟踪编译。
  • 系统虚拟机及相关工具(如调试和分析)。

重点强调语言虚拟机的原因

本课程重点强调语言虚拟机,主要是因为我对这方面更熟悉。从实用角度讲,任何人都可以发明一种语言并为其构建虚拟机,但很少有人能发明一个重要的指令集架构(ISA)。系统虚拟机的复杂性通常在于繁琐的细节,而非核心原理。语言虚拟机创造了新的可能性,而系统虚拟机通常只是解决一些不便之处。


虚拟机与托管运行时:第1讲(续):Fi语言介绍与解释器基础 🧑‍💻

在上一节中,我们介绍了课程概况和虚拟机的基本概念。本节中,我们来看看实验部分将使用的Fi语言,并开始探讨解释器的基础知识。

实验部分介绍

我是Patrick,本课程的助教。我的研究方向是编程语言设计、类型理论和编译器实现。

实验部分的目标是:我们设计了一个名为Fi的编程语言,大家将逐步实现性能越来越高的Fi语言实现。

所有作业和测试框架都将在Piazza上发布。随着课程进展,我们可能会安排每周的讨论环节。

Fi语言设计

根据之前的调查,大家都有使用Python、Perl、Ruby、Lua、JavaScript、Scheme或Lisp等语言的经验。Fi语言的设计深度与这些现代流行脚本语言类似,但限制了广度,使其易于实现但难以优化。

Fi语言的特点:

  • 命令式、动态类型:类似于Python、Ruby等。
  • 原型对象系统:类似于JavaScript,无需先声明类,可直接创建对象。
  • 垃圾回收语言
  • 有限的数据类型:只有对象、数组和整数。没有字符串、字符、布尔值(用null0表示假和真)。

Fi语言基础语法与语义

以下是一些Fi语言的基本语法示例:

打印输出printF是主要的输出方式。

printF("hello world")
printF("2 + 3 = ~(2+3)")

算术运算+, -, *, /, %。它们都是方法调用的语法糖。

2 + 3        # 等价于 2.+(3)

比较运算<, <=, >, >=, ==。也是方法调用的语法糖。falsenull表示,true0表示。

10 < 23      # 返回 0 (true)
10 > 23      # 返回 null (false)

变量声明与赋值

var x = 23
printF("x is equal to ~x")
x = 10

条件表达式

if x < 10:
    printF("x is less than 10")
else:
    printF("x is not less than 10")

循环

var i = 0
while i < 10:
    printF("i = ~i")
    var j = i + 1
    i = j

函数:函数自动返回最后一个表达式的结果。

def double(x):
    var y = x + x
    printF("x = ~x, y = ~y")
    y

var z = double(10)
printF("z = ~z")

注意:函数内不能定义其他函数,也不能提前返回。

对象与槽位

# 创建对象
var p = object: x = 10, y = 20
printF("p.x = ~(p.x), p.y = ~(p.y)")

# 赋值
p.x = 30
p.y = 40

方法:方法内部可以使用this关键字引用接收者对象。

var p = object:
    x = 10
    y = 20
    def print():
        printF("x = ~(this.x), y = ~(this.y)")

p.print()

方法作用域是其自身作用域和全局作用域的组合,但不能访问其词法作用域(即外部变量)。

继承:对象是一组绑定到另一个对象的绑定集合(类似于链表)。

def pair(x, y):
    object: x = x, y = y, def print(): printF("(~x, ~y)")

def pair2(x, y):
    var parent = pair(x, y)
    object parent: def printTwice(): this.print(); this.print()

var p = pair2(1, 3)
p.printTwice()
p.print()

晚期绑定

def maxer():
    object:
        def max(x, y):
            if this.lt(x, y): y else: x
        def lt(x, y): x < y

def pairMaxer():
    var parent = maxer()
    object parent:
        def lt(a, b):
            if a.x < b.x: 0
            else if a.x > b.x: null
            else: a.y < b.y

var m = pairMaxer()
var p1 = object: x = 10, y = 30
var p2 = object: x = 2, y = 5
m.max(p1, p2)  # 返回 p1

实验作业结构

第一个作业(一周后截止)是熟悉Fi语言,包括:

  1. 编写解决汉诺塔问题的程序,并打印移动步骤。
  2. 实现一个栈库(createStack, push, pop, peek)。
  3. 使用栈库改进汉诺塔程序,打印出移动的盘子编号。

后续作业将依次实现:

  1. Fi语言的抽象语法树解释器。
  2. 字节码解释器。
  3. 字节码编译器。
  4. 垃圾回收器。
  5. 动态编译器(即时编译)。
  6. 更多优化。
  7. 使用Oracle Labs的Truffle和Graal框架快速构建高性能虚拟机。

测试包中包含了一些示例程序,如二分查找、斐波那契数列、复数库、原型对象系统示例、列表库、向量库以及数独求解器(用于性能测试)。


解释器基础

现在,我们开始探讨解释器的基础知识。什么是解释器?这是一个难以精确定义的概念。

一个初步的定义是:解释器是直接执行源语言L中所有程序的机制。但这取决于“直接执行”的含义,并且可能包括硬件(微处理器就是其指令集的解释器)。

另一个定义是:软件解释器是一个自包含的、用于执行语言L中所有程序的完整程序。它不生成额外的指令。但这排除了硬件。

一个更操作性的定义是:解释器是一种直接执行程序的机制,它独立地执行源程序的每个元素,而不参考其他元素。这一定义也适用于硬件。

解释的特点:

  • 性能通常均匀且可预测,因为每个节点的执行都是独立的。
  • 缺点是速度慢,因为缺乏优化空间(无法跨元素优化)。

相比之下,编译器不执行任何操作,它只是将程序从一种形式转换为另一种(通常是更高效的)形式。在实际中,解释和编译常常结合使用:通常先通过编译(或解析)将源代码转换为更高效的中间表示(如抽象语法树),然后再解释执行这个中间表示。

所有执行最终都依赖于硬件解释。实现解释器可以带来直接执行的体验,这对于教学和交互式编程非常有力。但在高级虚拟机实现中,目标是保持这种直接交互的错觉,同时通过幕后优化(如即时编译)获得接近原生编译的性能。

抽象语法树解释器

我们将从最简单、最高级的解释技术开始:抽象语法树解释

AST是编译器前端(解析器)为高级语言生成的树形结构。它去除了所有无关内容(如注释、格式),将程序元素浓缩为原子实体,并捕获了程序的语义结构。

例如,一个简单的C语言循环片段可以被翻译成包含标识符、常量原子以及表示语句和表达式连接关系的树形结构。这是一种便于解释的形式,因为它易于导航和附加操作。

以下是一个简单表达式语言的AST解释器示例(使用C语言风格):

该语言包含:单字母变量(小写,存储整数)、整数常量、简单算术运算和赋值表达式。

AST节点结构定义

typedef struct exp Exp;
struct exp {
    enum {CONST, V, ADD, SUB, MUL, DIV, ASSIGN} tag;
    union {
        int v;                  // CONST: 整数值
        char c;                 // V: 变量名
        struct { Exp *e1, *e2; } ops; // ADD/SUB/MUL/DIV: 左右子表达式
        struct { char c; Exp *e; } ass; // ASSIGN: 变量名和表达式
    } u;
};

解释器核心函数

int eval(Exp *e) {
    switch (e->tag) {
        case CONST: return e->u.v;
        case V: return vars[e->u.c - 'a']; // 变量名已偏移以便快速索引
        case ADD: return eval(e->u.ops.e1) + eval(e->u.ops.e2);
        case SUB: return eval(e->u.ops.e1) - eval(e->u.ops.e2);
        // ... MUL, DIV 类似
        case ASSIGN:
            int r = eval(e->u.ass.e);
            vars[e->u.ass.c - 'a'] = r;
            return r;
    }
}

这种风格在C语言中可能显得有些笨拙。在面向对象语言中实现AST解释器会更加清晰:每种节点类型成为一个类,使用继承来提取公共部分,并使用方法分派进行求值。

添加语句序列
我们可以扩展语言,支持由分号分隔的语句序列。

typedef struct stmt Stmt;
struct stmt {
    enum {SEQ} tag;
    int n;
    Stmt **stmts;
};

void evalS(Stmt *s) {
    switch (s->tag) {
        case SEQ:
            for (int i = 0; i < s->n; i++) {
                evalS(s->stmts[i]);
            }
            break;
    }
}

添加控制流
如果控制流语义与实现语言的控制结构本地对应,则实现起来很简单。例如,实现一个do-while语句:

// 在Stmt的enum和union中添加DO_WHILE
struct { Stmt *body; Exp *cond; } dowhile;

// 在evalS的switch中添加
case DO_WHILE:
    do {
        evalS(s->u.dowhile.body);
    } while (eval(s->u.dowhile.cond) != 0); // 假设0为false
    break;

实验作业预告

第一个实验作业是熟悉Fi语言。第二个作业(两周后截止)是在Patrick提供的框架内编写Fi语言的AST解释器。完成解释器后,需要测量其性能并进行一些扩展和设计决策的思考。


总结与问答

本节课中,我们一起学习了虚拟机和托管运行时的基本概念、课程目标,并概述了Fi语言和解释器的基础知识。我们看到了几种不同类型的虚拟机,并开始探讨如何构建一个简单的AST解释器。

问答环节摘要

  • 关于虚拟机类型:进程虚拟机实现应用程序二进制接口(ABI),系统虚拟机实现硬件指令集接口(ISA)。前者运行单个应用,后者运行整个操作系统。
  • 关于系统虚拟机的资源分配:例如在VirtualBox中,用户可以在设置中指定客户操作系统可以访问哪些设备(如键盘、鼠标),虚拟机软件根据这些设置进行资源分区和虚拟化。
  • 关于Java之前的可移植性:在Java之前,大多数程序都是为特定的指令集和操作系统编写的,需要在不同平台上重新编译才能运行。
  • 关于合作伙伴:实验作业可以单独完成,也可以与伙伴合作。详细信息请查看Piazza。

今天的课程到此结束。谢谢大家!

002:抽象语法树解释器与字节码基础

在本节课中,我们将学习抽象语法树解释器的实现细节、其性能与局限性,并初步探讨字节码虚拟机作为一种更优解决方案的设计理念。

抽象语法树解释器的控制流实现

上一节我们介绍了如何解释简单的表达式和语句。本节中,我们来看看如何处理更复杂的控制流,例如 break 语句。

在AST解释器中,实现break这样的非局部控制流转移可能会很棘手。例如,在一个do-while循环中遇到break时,简单的函数调用返回无法跳出外层的循环上下文。

以下是几种实现方案:

  • 修改返回值类型:让所有求值函数返回一个包含实际值和“是否发生中断”标志的配对值。这需要在整个调用链中传递这个配对,使代码变得冗长。
  • 使用宿主语言的长跳转机制(如C语言的setjmp/longjmp:这种方法性能较好,但破坏了正常的栈展开流程。
  • 使用异常机制(如Java):在解释break节点时抛出一个特定的异常,在解释循环的节点处捕获它。这种方法实现简单,但性能开销很大,因为异常处理通常很慢。

抽象语法树解释器的性能与内存分析

那么,我们实现的AST解释器性能如何?让我们分析一个简单表达式 b = 2 * a + 1 的解释过程。

为了求值这个表达式,解释器需要进行多次递归的eval函数调用。每次调用都涉及函数调用开销、switch语句分发以及通过指针在内存中遍历树结构。实际执行乘法和加法等“有效工作”的指令只占整个过程的一小部分。

内存方面,AST的表示通常非常低效。在一个64位系统上,二叉树中每个内部节点至少包含两个指针(共16字节),加上内存分配器的开销。大约一半的节点是内部节点,导致内存占用很大。此外,遍历树结构是一种近乎随机的内存访问模式,容易导致缓存未命中。

可以通过以下技术进行优化:

  • 使用索引而非指针来引用节点,以压缩存储。
  • 通过深度优先遍历对树进行线性化布局,改善缓存局部性。

尽管如此,AST解释器的主要优点是实现简单、易于推理且高度可移植。它通常是新语言实现的首选方案。

从抽象语法树到字节码虚拟机

由于AST解释器在性能和存储效率上的局限性,实践中更常见的方案是字节码虚拟机

字节码虚拟机的核心思想是设计一个面向堆栈的抽象指令集架构。程序源码先被编译成紧凑的字节码序列,然后虚拟机解释执行这些字节码。这解耦了语言语义与运行时实现,并带来了更好的性能。

一个为简单表达式语言设计的字节码指令集可能如下所示:

  • LIT:将一个字面量整数压入堆栈。
  • ADD/SUB/MUL/DIV:弹出栈顶两个元素,进行运算,结果压栈。
  • GET:根据索引读取变量值并压栈。
  • PUT:弹出栈顶值,存入指定变量。
  • END:结束程序。

表达式 b = 2 * a + 1 可以被编译成以下字节码序列:

LIT 2
GET a
MUL
LIT 1
ADD
PUT b
END

字节码解释器是一个循环,不断读取下一条指令(操作码),并通过一个大的switch语句分发到对应的处理逻辑。其状态通常包括程序计数器、操作数堆栈和变量存储区。

为字节码虚拟机添加控制流和函数

我们可以扩展字节码指令集以支持控制流和函数调用。

  • 控制流:添加比较指令(如BEQ-相等时分支)和跳转指令(如BR-无条件跳转)。这些指令的操作数是代码内的偏移量,使得实现循环和条件判断变得简单高效。
  • 函数调用:添加CALL指令。调用时,需要管理调用帧,包括保存返回地址、设置新的帧指针以访问参数和局部变量,以及管理操作数堆栈。RETURN指令则负责恢复之前的帧并将返回值传递给调用者。

在面向对象语言中,方法调用更为复杂,涉及从接收者对象到其类,再沿继承链查找方法的过程。这个过程是后期性能优化的重要目标。

字节码的序列化与验证

字节码的一个关键优势是易于序列化,可以作为一种可移植、独立于源码的发布格式。这引出了字节码验证的重要性:虚拟机在运行不受信任的代码前,必须检查字节码是否符合安全性和结构化的约束(例如,不会跳转到指令中间、类型使用正确等)。Java虚拟机就具有一个复杂的验证阶段。

窥探现实:Java虚拟机字节码初览

最后,我们简要浏览真实的Java虚拟机字节码设计,可以看到工业级虚拟机的复杂性:

  • 丰富的类型化指令:针对int, long, float, double, reference等不同类型,有独立的算术、加载、存储指令。
  • 特殊的指令格式:如wide指令作为前缀,用于扩展后续指令的操作数宽度。
  • 对象与数组操作new, getfield, putstatic, arraylength等指令,抽象了内存分配和访问。
  • 常量池解析:像ldc(加载常量)这样的指令,在首次执行时可能触发昂贵的类加载和解析过程。

JVM字节码规范中充满了设计权衡和历史痕迹,例如操作数堆栈概念上的“值”与实际实现中基于32位“槽”的存储方式之间的微妙差异。

总结

本节课中我们一起学习了:

  1. AST解释器实现控制流的复杂性及其在性能和内存上的主要缺点。
  2. 字节码虚拟机作为替代方案的基本原理,包括其堆栈机模型、指令集设计以及解释执行循环。
  3. 如何为字节码添加控制流、函数调用支持,并讨论了序列化与验证问题。
  4. 通过概览JVM字节码,了解了真实世界虚拟机指令集的丰富性和设计考量。

字节码虚拟机在性能、可移植性和安全性之间取得了更好的平衡,是现代托管运行时环境的基石。在接下来的课程中,我们将深入探讨字节码优化和即时编译技术。

003:指令集详解与验证

在本节课中,我们将深入探讨Java虚拟机(JVM)字节码指令集的更多细节,包括控制流、方法调用、异常处理和同步机制。我们还将学习字节码验证的核心概念——抽象解释。课程内容基于对作业的回顾展开,旨在帮助初学者理解JVM的内部工作原理。

作业回顾

上一节我们介绍了课程背景,本节我们先来回顾一下第一次作业中的几个关键设计问题。

以下是作业中关于F语言语义设计的几个主要方案:

  • 特殊真值对象:像许多其他语言一样,引入一个名为 true 的特殊对象。例如,1 < 3 返回 true1 > 3 返回 null。这是一个清晰的设计,唯一的代价是需要引入额外的数据类型,但这在解释器中只需增加几行代码。
  • 数值即布尔值:借鉴C语言的思路,将数值直接解释为布尔值。例如,1 < 3 返回某个非零值,1 > 3 返回 0。这个方案的问题在于,当语言扩展其他数据类型(如浮点数、字符串)时,需要定义更多“假值”(如 0.0、空字符串),这通常被视为糟糕的设计选择。
  • 返回有意义的值:一个优雅但可能有性能代价的方案。例如,1 < 3 如果为真,则返回两个数中较小的那个。这样返回值本身具有意义。性能代价在于,通常我们只进行谓词测试,并不关心返回值,因此可能白费力气计算了一个最终被丢弃的结果。

关于闭包与对象的区别,核心在于它们本质上是相同的。我们可以用对象来模拟闭包:创建一个对象,将所有被闭包捕获的变量复制进去,并提供一个名为 callapply 的方法。然而,当闭包需要修改捕获的变量时,简单的对象模拟会失效,因为每个闭包对象持有的是变量的副本,无法共享更新。解决方案是将需要修改的变量放入一个特殊的“单元格”(如数组)中,然后让闭包对象共享对这个单元格的引用。

字节码指令详解

在回顾了作业之后,我们进入本节课的核心内容:JVM字节码指令。

控制转移指令

控制转移指令用于实现条件分支、循环和开关语句。JVM提供了多种条件分支指令。

以下是整数和引用比较的分支指令:

  • if 系列(与零比较):这类指令将栈顶的值与0进行比较。例如,ifeq(等于零跳转)、ifne(不等于零跳转)、iflt(小于零跳转)等。它们从栈顶弹出一个值进行判断。
  • if_icmp 系列(整数比较):这类指令比较栈顶的两个整数值。例如,if_icmpeq(两个整数相等跳转)、if_icmpne(不相等跳转)、if_icmplt(小于跳转)等。
  • if_acmp 系列(引用比较):这类指令比较栈顶的两个对象引用,只能进行相等或不等判断。例如,if_acmpeqif_acmpne

此外,还有用于实现 switch 语句的指令:tableswitch(适用于case值密集的情况)和 lookupswitch(适用于case值稀疏的情况)。tableswitch 通过索引跳转表实现高效跳转,其指令格式中包含对齐填充字节,以确保跳转表地址对齐,便于快速访问。

方法调用与返回指令

方法调用是任何虚拟机的核心。JVM最初有四种调用指令,后来增加了 invokedynamic

以下是四种基本的调用指令:

  • invokevirtual:用于调用对象的实例方法(虚方法)。动态分派基于对象的实际类型。调用时,需要将对象引用(this)和方法参数压入操作数栈。
  • invokestatic:用于调用静态方法。无需对象引用,分派是静态的,直接调用指定类的方法。
  • invokeinterface:用于调用接口方法。由于一个类可以实现多个接口,其分派机制比单继承的虚方法更复杂。
  • invokespecial:用于调用一些特殊方法,包括实例初始化方法(<init>)、私有方法以及通过 super 关键字调用的父类方法。它的分派基于调用者的类型,而非对象的实际类型。

返回指令则根据返回值的类型进行区分,如 return(void)、ireturn(int)、areturn(引用)等。

虚方法分派实现

在静态类型、单继承的语言中,虚方法分派通常通过虚方法表(vtable)实现。每个类都有一个vtable,其中按固定顺序排列着该类所有虚方法的入口地址。子类的vtable是父类vtable的扩展,新增的方法追加在末尾。这样,对于任何在父类中定义的方法,其在子类vtable中的索引是固定的。方法调用时,通过对象引用找到其类,再根据固定索引从vtable中取出方法地址进行调用。这个过程可以用伪代码描述:

// 假设 obj 是对象引用,method_index 是方法在vtable中的固定索引
Class clazz = obj.class;
Method target = clazz.vtable[method_index];
call target;

同步指令

Java中的同步通过 synchronized 关键字实现,在字节码层面有两种体现:

  • 同步方法:在方法的访问标志(ACC_SYNCHRONIZED)中标记。当调用此类方法时,JVM会隐式地获取和释放锁。
  • 同步语句块:通过 monitorentermonitorexit 指令对实现。这两个指令显式地包围需要同步的代码块。monitorenter 尝试获取对象的监视器锁(monitor),monitorexit 释放锁。如果执行 monitorexit 的线程不是该监视器的所有者,则会抛出异常。这种设计允许了非结构化的锁操作,但也带来了复杂性和安全隐患。

异常处理指令

异常处理是JVM中较为复杂的部分。在源代码中,我们使用 try-catch-finally 块。

以下是异常处理在字节码层面的实现方式:

  • 抛出异常:使用 athrow 指令。它从操作数栈顶弹出一个异常对象引用,并开始异常传播过程。
  • 异常表:每个方法都附带一个异常表(Exception Table)。表中的每一项定义了一个受保护的代码范围(起始和结束字节码索引)、一个异常类型以及一个处理器代码的起始索引。当在保护范围内抛出异常时,JVM会查找异常表,匹配异常类型,并跳转到对应的处理器代码。
  • finally 的实现(历史方式):在早期Java版本中,编译器使用 jsr(跳转子程序)和 ret(从子程序返回)指令来实现 finally 代码块的复用。jsr 将返回地址压栈并跳转到 finally 块,finally 块执行完后用 ret 指令返回。由于这种机制给垃圾回收和验证带来了巨大复杂性,Java SE 6 之后已弃用,改为直接复制 finally 块代码。

字节码验证与抽象解释

为了保证加载的字节码是安全且符合规范的,JVM会在类加载过程中进行验证。验证的核心技术之一是抽象解释

抽象解释是一种分析程序的方法,它不像普通解释器那样执行具体的值计算,而是在一个抽象的域(例如类型域)上“执行”程序,以推导出程序的某些属性(例如类型是否正确)。

例如,为了验证字节码的类型安全,我们可以设计一个抽象解释器。这个解释器模拟操作数栈和局部变量表,但栈中存储的不是实际值,而是值的类型(如 INT, FLOAT, REFERENCE 等)。解释器遍历字节码,对于每条指令,检查其操作数类型是否符合规定,并更新抽象的栈状态。

以下是一个简化的抽象解释器处理加法指令的伪代码:

case IADD: // 整数加法
    if (stack.size() < 2) {
        throw new VerificationError(“操作数栈不足”);
    }
    Type value1 = stack.pop();
    Type value2 = stack.pop();
    if (value1 != INT || value2 != INT) {
        throw new VerificationError(“操作数类型必须为int”);
    }
    stack.push(INT); // 结果类型为int
    break;

通过这种方式,验证器可以在不实际运行程序的情况下,确保字节码指令序列不会出现类型错误、栈溢出或下溢等问题。JVM规范中详细定义了字节码验证时类型推导和检查的规则。

总结

本节课我们一起深入学习了JVM字节码指令集的多个关键部分。我们回顾了控制转移指令如何实现程序分支,分析了四种方法调用指令的区别及其背后的实现机制(特别是虚方法表)。我们还探讨了同步指令 monitorenter/monitorexit 的工作原理,以及异常处理中异常表和 finally 块实现的演变。最后,我们介绍了字节码验证的核心——抽象解释技术,它通过在类型抽象域上模拟执行来保证字节码的类型安全。理解这些底层细节,对于掌握虚拟机的工作原理和实现高效的托管运行时环境至关重要。

004:与论文作者对话 - 1984年Smalltalk-80系统实现回顾

概述

在本节课中,我们将回顾一篇发表于1984年的里程碑式论文,该论文描述了Smalltalk-80系统在Sun-1工作站上的实现。我们将与论文的两位作者——Peter Deutsch和Alan Schiffman——进行对话,深入探讨这项工作的背景、技术细节、面临的挑战以及它对后续虚拟机技术发展的深远影响。本次讨论将涵盖从硬件选择、动态编译、内存管理到系统设计哲学的多个方面。

课程背景与动机

上一节我们介绍了虚拟机的基本概念和历史背景。本节中,我们将聚焦于一项具体且极具影响力的早期实现。

1983年,计算领域正处于变革之中。IBM PC/XT、Apple III和Apple Lisa等机器定义了个人计算的形态,而Sun-1工作站则代表了新兴的工作站市场。在这个背景下,Peter Deutsch和Alan Schiffman着手将Smalltalk-80——一个在Xerox PARC开发的先进、高度交互的编程环境——移植到基于Motorola 68000处理器的Sun-1工作站上。他们的目标不仅仅是移植,更是要挑战当时的主流观念,即像Smalltalk这样的高级语言环境只能在昂贵的、微码化的专用硬件(如Xerox的Dorado机器)上高效运行。

硬件环境与选择

以下是关于目标硬件平台Sun-1的一些关键事实:

  • 处理器: Motorola 68000,运行频率约10-12 MHz。
  • 内存: 通常配置为2 MB(在当时已相当可观)。
  • 架构特点: 无缓存、无流水线、无内存管理单元(MMU),指令集不支持虚拟内存(页面故障无法中断指令执行)。
  • 成本: 部件成本约1500美元,与价值超过10万美元的Dorado工作站形成鲜明对比。

选择Sun-1的主要动机是,它是第一款大众市场的32位微处理器机器。作者们认为,这是第一款有潜力以合理速度运行Smalltalk的商用硬件,从而有机会证明通用商品硬件可以替代昂贵的专用硬件。

核心实现技术与贡献

论文概述了该实现的四项主要贡献,每一项都对后来的虚拟机设计产生了影响。

1. 首个真正的即时(JIT)编译器

这项工作的一个突出贡献是实现了首个真正的即时编译器。它采用按需翻译的方式:当需要执行某段Smalltalk字节码时,系统会动态地将其编译成68000机器码,并缓存结果以供后续使用。

核心机制
翻译器一次处理一个方法(method)。它顺序遍历字节码,几乎逐条地生成原生代码,同时构建一个包含代码所需字面量对象的侧表。一个关键的优化是使用了一个专用的机器寄存器作为运行时栈顶的单元素缓存,避免了大量不必要的栈操作。

关于这项技术的起源,Alan提到,动态翻译的想法最初来自Butler Lampson在走廊里的一次偶然对话。当Peter担心代码体积过大时,Butler建议:“为什么不缓存编译好的代码呢?”

2. 延迟引用计数

Smalltalk早期的微码实现使用了实时引用计数,开销巨大。Peter Deutsch和Daniel Bobrow在1976年的一篇论文中提出了“延迟引用计数”的想法。

核心思想
只在对象被堆外存储引用时才维护其引用计数。对于仅在栈上被引用的对象,则将其加入一个“零引用表”。系统会周期性地停止计算,扫描栈,并回收那些在零引用表中且未被栈引用的对象。

公式/伪代码表示

// 普通加载/存储操作(无引用计数开销)
stack.push(local_variable);
local_variable = stack.pop();

// 周期性回收过程
pause_computation();
for each object in zero_count_table:
    if not referenced_by_stack(object):
        reclaim(object);
resume_computation();

这项技术对于生成高效的原生代码至关重要,因为它将每个栈推送/弹出操作从可能需要的多条指令(用于增减引用计数)简化到了最低限度。

3. 执行上下文的按需转换

Smalltalk-80的一个独特特性是其执行状态(栈帧,在Smalltalk中称为“context”)是完全可反射的,即它们被定义为系统中的一等对象。这给编译实现带来了巨大挑战:为了高效执行,上下文最好存放在硬件栈上;但为了满足语言规范,它们又必须能够作为堆分配的对象被检查和操作。

解决方案
他们设计了一个复杂的机制,让上下文在“易失”(在栈上执行)、“混合”(既是对象,状态又在栈上)和“稳定”(状态已转移到堆对象中)几种模式间按需转换。通过“稳定化”和“再活化”等操作,并在调用/返回点进行精心拦截,他们既保证了执行效率,又完全遵守了语言的反射语义。

4. 内联缓存

论文描述了对消息发送(方法调用)的优化技术,即内联缓存。

核心思想
观察到在循环或频繁执行的代码路径中,发送给同一消息选择器的接收者类型(类)通常是相同的。因此,可以在消息发送点缓存上一次成功查找的结果(目标方法地址)。下次发送相同消息时,首先检查接收者类型是否与缓存类型匹配,如果匹配则直接跳转到缓存的方法,从而避免了昂贵的完整方法查找过程。

代码描述

// 未优化的消息发送
result = send_message(receiver, selector, args);

// 使用内联缓存优化后(伪代码)
if (receiver->class == cached_class) {
    jump_to cached_method;
} else {
    // 执行完整查找,并更新缓存
    cached_class = receiver->class;
    cached_method = lookup_method(receiver->class, selector);
    jump_to cached_method;
}

Alan回忆道,这个想法的灵感也可能来自Butler Lampson,他观察到在循环中接收者类型很可能不变,并认为实现复杂的多态调度机制是在浪费时间。

系统设计与工程决策

为何使用汇编语言?

整个系统(约数万行代码)完全用Motorola 68000汇编语言编写。当时C编译器尚不成熟,生成的代码质量不高,且他们需要极致性能以超越Dorado微码实现的性能标杆。使用汇编语言给予了他们完全的控制权。

“操作系统”的角色

该系统几乎没有传统意义上的操作系统。它主要包含一些必要的设备驱动(鼠标、键盘、显示器、磁盘、网络)。更高级的功能,如网络协议和文件系统,都是在Smalltalk语言层面实现的。正如Dan Ingalls的名言所说:“操作系统就是你在编程时忘记放入的所有东西。”在单用户、单线程且无需内存保护的Smalltalk环境中,许多传统OS功能并非必需。

处理硬件限制的技巧

  • 栈溢出检查: 在每个被翻译方法的前导代码中,加入两条指令,将栈指针与一个保存在专用寄存器中的栈下限进行比较。这同时用于检测栈溢出和作为上下文模式转换的触发标志。
  • 代码缓存与自修改代码: 在支持写后执行(W^E)限制的架构上,他们计划通过增加一层间接寻址来解决——将内联缓存放在数据内存中,而不是直接修改指令流。

影响与遗产

这项工作的影响是深远的:

  1. 证明了商品硬件的可行性: 它有力地挑战了“高级语言虚拟机需要专用硬件”的观念,为后来所有基于通用处理器的虚拟机(如JVM、.NET CLR)铺平了道路。
  2. 即时编译的典范: 其实验验证了JIT编译的潜力,尽管其优化还很初级,但开创了动态编译的先河。
  3. 启发RISC架构: Alan认为,这项工作展示了即使在简单指令集的处理器上也能高效运行复杂语言环境,从而为RISC架构的研究提供了支持论据,反驳了那些认为未来架构必须更复杂以支持高级语言的观点。
  4. 技术传承: 内联缓存、延迟引用计数、栈映射等技术被后续无数虚拟机所采纳和发展。

问答与讨论精选

在讨论环节,作者们回答了众多问题,其中一些亮点包括:

  • 关于专业硬件 vs 通用硬件: 通用硬件的胜利主要归因于规模经济(更高的产量带来更先进的制程和更低的成本)和编译优化的力量。编译能够将语言层面的复杂操作(如类型检查)从热循环中提升出去,从而在简单硬件上获得高性能。
  • 关于内存限制: 即使内存变得廉价充足,性能瓶颈也会以新的形式出现(如CPU缓存)。代码膨胀和缓存友好性之间的权衡始终存在。
  • 关于编程文化与Xerox PARC: 作者们纠正了当时程序员都很“正经”的误解。Xerox PARC及其所在的硅谷文化在当时已经相当“波西米亚”,T恤衫和反传统精神是常态,正是这种文化孕育了许多突破性创新。
  • 关于集成开发环境的未来: 作者们对回归到Smalltalk那样高度统一、集成的开发环境持悲观态度。现代软件由太多分散的、使命各异的组件构成,很难再出现一个团队在隔离环境中创造出完全自洽的“城堡”式系统。Dick Gabriel的“更差就是更好”论文很好地解释了这种现象。

总结

本节课中,我们一起学习了1984年Smalltalk-80在Sun-1上实现的里程碑工作。通过与作者对话,我们深入了解了这项开创性研究的技术细节、设计决策和历史背景。从首个JIT编译器到内联缓存,从延迟引用计数到可反射上下文的巧妙实现,这项工作的创新点几乎为现代高性能虚拟机奠定了所有核心概念的基础。它不仅仅是一项工程成就,更是一次观念的革新,证明了通过软件编译优化,高级语言系统完全可以在廉价、通用的硬件上蓬勃发展。

005:AST解释器回顾与动态语言优化

在本节课中,我们将回顾AST解释器作业,并探讨动态语言实现中的核心挑战与优化技术,特别是类型标记、方法查找缓存和内存管理的基础知识。

课程回顾:AST解释器

上一节我们介绍了AST解释器的实现。本节中,我们来看看作业中常见的两个主要问题。

主要挑战

以下是实现中需要特别注意的两个方面:

  1. 作用域的正确处理ifwhile等语句拥有自己的作用域。这一点非常微妙,因为相关代码路径可能不常被执行,导致许多情况下看似工作正常,实则存在隐患。
  2. 对象系统的复杂性:Fi语言采用原型系统,需要沿着父链接向上查找属性。在设置变量时,必须在链接链的适当层级进行设置。这一点同样较为微妙。

性能测量与分析

关于性能测量,大家应该关注以下两个主要统计数据:

  • 环境查找时间占比:例如,在数独基准测试中,近一半的执行时间都花在了环境查找上。
  • 整数方法调用占比:大量操作是简单的整数原语运算,但每次仍需进行开销巨大的通用查找。

因此,我们的优化将主要集中于:

  • 如何消除环境查找的时间开销。
  • 如何让原语操作达到应有的速度,或至少更快。

作业问题解答

本次作业只有两个主要问题。

问题一:如何支持 return 语句?

大家主要提到了两种通用方法:

  1. 利用语言构造:在C语言中,可以使用 setjmplongjmp。每次进入函数或方法时设置跳转点,执行 return 时直接长跳转跳出。需要注意正确处理递归(保存每个原生函数帧的跳转缓冲区)并传递返回值(例如使用全局变量)。在Java或ML中,可以抛出异常;在Scheme中,可以捕获续延。每种语言都有其独特的方式。
  2. 增强 eval 的返回值:目前,eval 直接返回操作结果。我们可以将其包装起来:要么是普通结果,要么是返回结构。每次求值子表达式后,都需要检查返回类型。如果是普通结果,则继续;如果是返回结构,则停止求值剩余子表达式并将其向上传递。这是最清晰的方式,但代码量可能最大,速度也可能最慢。

并非所有语言都生而平等。使用 setjmp/longjmp 的实现代码量最少。如果你的实现语言不支持长跳转,则只能采用第二种方式。在探索不同控制流操作方式时,Scheme的续延可能是最强大的构造。

问题二:关于克隆(clone

首先,需要精确定义克隆的语义。关键在于选择浅克隆还是深克隆,特别是对于父链接的处理。少数实现采用了半浅半深的方式。

  • 浅克隆:不递归克隆子对象,仅复制指针。如果修改克隆对象引用的子对象,所有克隆成员都会看到更改。
  • 深克隆:递归复制整个对象图。这带来了额外复杂性:由于Fi语言不是纯函数式语言,存在赋值操作,可能导致对象图中出现循环。深克隆必须处理这些循环。

语义上,两种方式均可定义。历史上,不同语言采用了不同策略。很少有语言提供深克隆功能,并非因为本质困难,而是通常不那么实用。深克隆无法用纯Fi函数编写。

接下来,探讨实现克隆的负面影响。最强大的语言特性并非语法糖,而是语言提供的不变性保证。克隆会破坏某些不变性。

考虑一个用例:我们提供一个创建“点对”的合约,支持 getXgetYsetXsetY 方法。假设实现者使用一个两元素数组来跟踪状态。如果你决定重构代码,将数组成员展开为独立字段,在浅克隆下,数组不会被克隆,导致一个点对的更新会影响另一个点对;而在重构后的版本中,克隆会得到两个独立的点对。因此,在存在克隆的情况下,程序无法保证这种重构转换的正确性。这是一个严重的语言缺陷。

Fi语言并非完美的语言,但也不差,它提供了一些其他不变性保证,例如内存安全(永远不会出现段错误)和 while 循环后循环条件不变性的保证。

关于深克隆的负面影响?思考后认为没有特别严重的后果。Smalltalk同时提供了浅克隆和深克隆,但深克隆在遇到循环时可能无法停止,或者因为难以确定边界而克隆整个系统。一些混合方案(如对数组深克隆、对对象浅克隆)的缺点是需要向用户解释其复杂性。语言设计中最糟糕的事情之一是设计一个技术上正确但无人理解的功能,因为那意味着需要反复回答相同的问题。


下一个任务:字节码编译器

下一个任务是编写字节码编译器。完成后,你将拥有一个自包含的Fi系统。编译器本身并不难,但希望大家利用这段时间重构微码解释器,因为这是后续所有构建的基础。

特别是,后续将实现垃圾回收。垃圾回收的难度取决于堆上值的布局方式。

堆布局建议

强烈建议采用以下布局格式:

[对象类型标记] [可变数量的槽...]

每个对象前端有一个字(或单元)作为对象头,用于指示对象类型(例如,null、数组、类等特殊类型)。对于数组,头部后是长度和可变数量的槽。对于对象,头部后是父对象引用和可变数量的槽(方法槽无需放在此处)。

这种布局的最大优势是:

  • 无需跟随链表:所有对象都是扁平、连续的结构,这使得垃圾回收器更容易实现。
  • 便于堆解析:由于所有类型标记都在对象前端,可以从一个方向解析堆。

请务必进行此项重构,否则垃圾回收器实现将非常困难。

此外,建议进行窥孔优化,确保尽可能多的字节码操作是“简单”的(即基本上是直线代码,无需调用外部函数)。这样,在为字节码操作生成汇编序列时,可以避免处理函数调用约定等复杂问题。

快速调查:谁的字节码解释器比AST解释器更快?(假设测量正确)练习的目的是让系统变得更快、更小、更高效。我们可能会在最后举办一个性能竞赛,并为最快版本提供赠书。


动态语言优化技术

接下来,我们简要介绍一些动态语言优化技术,这些技术将在后续练习中出现,也是对阅读论文内容的总结。

动态语言的主要挑战在于,查看源码时,你对类型信息知之甚少。这会导致一系列连锁反应:你通常无法提前知道类型;大多数动态语言允许在运行时创建新类型;特定代码片段中的类型可能随时间变化。

值的表示

由于没有变量类型声明,但值本身携带类型信息,我们需要在表示上做出选择。

1. 内联表示(适用于小数据)
对于仅包含简单类型(如整型、浮点型)的语言,可以使用C联合体,分配足以容纳最大类型的内存空间。所有变量和栈元素都使用这种联合类型。解释器代码需要检查标签并执行相应操作。虽然比简单标量表示复杂,但尚可管理。

2. 间接表示(适用于大数据)
当变量可能引用布尔值或百万元素数组时,无法分配最大空间。此时,所有变量都成为指向堆上值的指针。每个堆对象都有一个包含类型信息的头部(例如,类引用),后跟实际数据。这种表示支持共享和传递指针。

对象表是一种变体:所有指针指向一个全局对象表,表中条目再指向实际对象。这增加了间接层,但优点包括:

  • 便于对象重定位:移动对象只需更新表中的单个指针。
  • 压缩指针:如果表比地址空间小,可以使用比完整指针更窄的索引,节省空间。
    然而,在现代系统中,额外的内存访问开销和内存占用(通常使对象大小增加约10%)导致这种技术已不常用于高性能实现。

3. 标记指针(Tagged Pointers)
一种常见优化是将类型信息编码到指针的低位中(利用对象地址通常按字对齐,低位为0的特性)。例如,在32位机器上,用最低两位表示类型:00 表示地址(对象指针),01 表示字符,10 表示整数等。这样,小整数等原始值可以直接存储在指针中,无需堆分配,极大地提高了算术运算速度。但浮点数的处理较复杂,可能需要牺牲精度或范围。

在实现中,会定义一组宏来操作这些带标记的字,以避免函数调用开销。这是Lisp、Smalltalk等系统中历史上的常见技术,甚至有些硬件直接支持标记算术。

方法查找优化

在动态语言中,方法调用 x.f() 在编译时无法确定目标,因为不知道 x 的类型。最坏情况下,每次调用都需要完整的查找和分派,性能极差。许多动态语言甚至将原始操作(如 +-)和控制结构也纳入方法查找语义,使得优化更为关键。

1. 全局方法缓存
最简单的加速技术是使用全局方法查找缓存。缓存键通常由接收者的类和方法名哈希而成。缓存命中时,直接跳转到目标代码,避免查找开销。虽然简单,但在高负载下命中率可能不理想。

2. 内联缓存
更好的方案是使用内联缓存,将缓存直接嵌入调用点。结合编译技术,可以将分支内联到代码中。这将在后续编译章节详细讨论。


内存管理导论

最后,我们开始介绍内存管理,这将持续数周。今天涵盖基础概念。

为什么需要自动内存管理?

显式内存管理(如 malloc/free)容易导致悬垂指针、内存泄漏等错误,且在多组件集成时管理所有权非常复杂。自动内存管理(垃圾回收)消除了这类安全隐患,但并不能防止逻辑上的内存泄漏。

可达性与垃圾定义

理想情况下,对象在不再需要时即为垃圾。但系统无法预知未来。因此,垃圾回收使用一个更弱但可判定的定义:从根集(如栈变量、全局变量)出发,通过指针链无法访问到的对象就是垃圾。这是一种保守但安全的近似。

分配器

分配速度很重要,因为对象分配在面向对象语言中非常频繁。

  • 空闲链表:传统方法,但遍历、分割和合并开销大,可能导致碎片化。
  • 按大小分隔的空闲链表:针对常见的小对象尺寸维护多个空闲链表。分配时根据已知大小直接获取,是常量时间操作。大对象或链表为空时回退到通用分配。这比单一空闲链表高效得多。
  • 指针碰撞分配:在连续内存区域中,仅维护一个“自由指针”。分配时只需移动指针并检查是否越界。这是最快的方法,但需要与压缩式垃圾回收器配合,在回收后将所有存活对象整理到区域一端,重新获得连续空间。

引用计数

最简单的自动内存管理技术。每个对象维护一个引用计数(指向它的指针数)。创建新引用时计数增加,删除引用时计数减少。当计数降为零时,对象可立即被回收,并在回收时减少其所有子对象的引用计数(可能递归)。

优点:渐进式回收,停顿时间短。
缺点与挑战

  1. 循环引用:无法回收形成循环引用的垃圾对象组。
  2. 性能开销:每次指针赋值都需要更新计数,增加指令和缓存失效。
  3. 递归释放:释放大对象可能导致长停顿或栈溢出。可通过延迟释放到队列中来缓解。
  4. 计数溢出:使用较小位宽(如8位)计数时需处理饱和问题。

因此,引用计数通常需要配合一个周期性的追踪式垃圾回收器来处理循环引用。


总结

本节课中,我们一起学习了:

  1. AST解释器实现中关于作用域和对象系统的常见陷阱。
  2. 支持 return 语句和定义 clone 语义的方法及其影响。
  3. 动态语言中值表示的几种策略(内联、间接、标记指针)及其权衡。
  4. 通过全局缓存和内联缓存优化动态方法查找。
  5. 内存管理的基本概念,包括分配策略(空闲链表、指针碰撞)和简单的引用计数回收算法及其局限性。

下一讲,我们将深入探讨更强大的追踪式垃圾回收算法。

006:Self语言虚拟机内部结构详解 🧠

概述

在本节课中,我们将深入探讨Self语言虚拟机的内部结构。我们将回顾Self语言的核心概念,并详细解析其虚拟机的实现机制,包括堆结构、对象表示、内存管理以及垃圾回收等关键部分。通过了解这些内部细节,我们可以更好地理解现代虚拟机设计中的许多重要思想和优化技术。


Self语言快速回顾 🔄

上一节我们介绍了Self语言的基本动机和设计哲学。本节中,我们来看看Self语言本身的核心语法和语义,为理解其虚拟机实现打下基础。

Self是一个基于原型的、纯面向对象的语言。其核心概念非常简洁:

  • 对象:一切皆为对象。数字(如 3)、浮点数(如 4.2)都是对象。
  • 创建对象:使用字面量语法创建带有命名槽(slot)的对象。
    (| x = 3. y <- 4 |)
    
    • = 用于定义常量槽(不可变)。
    • <- 用于定义可赋值槽(可变)。
  • 消息发送:通过向对象发送消息来访问或修改其槽。
    anObject x          “访问 x 槽”
    anObject y: 5       “将 y 槽赋值为 5”
    
  • 方法:方法是存储在槽中的代码对象。
    (| x. y. r = ( (x square + y square) squareRoot ) |)
    
  • 克隆:通过 _Clone 原语进行浅拷贝来创建新对象。
  • 继承:通过父槽(以 * 结尾)实现委托式继承。
    (| parent* = anObject. x <- 10. r = ( (resend.r) asString ) |)
    
  • 控制结构:使用块(闭包)实现。例如,条件判断通过向布尔对象发送 ifTrue:False: 消息完成。
    (x = y) ifTrue: [ ‘do something’ ] False: [ ‘do something else’ ]
    
  • 非局部返回:块中的 ^ 操作符用于从创建该块的方法中返回。
  • 向量:通过索引访问的槽,分为对象向量和字节向量。
    vector _Clone: 7 “创建一个包含7个元素(初始为nil)的向量”
    

字节码与栈机 🧱

Self虚拟机执行一种非常简单的字节码。理解这些字节码是理解后续编译和优化的基础。

以下是Self字节码指令集的核心部分:

  • pushSelf:将接收者(self)压入栈。
  • pushLiteral: index:将字面量向量中索引为 index 的常量压入栈。
  • send: selector:向栈顶对象发送消息 selector,参数从栈中弹出,结果压回栈顶。
  • selfSend: selector:向 self 发送消息 selector(优化常用操作)。
  • superSend: selector:向父对象发送消息 selector
  • nonLocalReturn:执行非局部返回。

字节码序列由简单的栈机执行。方法体被编译成一个代码对象,其中包含字节码序列和一个字面量向量(主要存放消息选择器字符串)。

与Java等语言复杂的字节码集相比,Self的原始版本只有8条指令,设计非常简洁,并未过多考虑性能优化,将生成高效代码的重任留给了虚拟机。


堆结构与内存组织 🗃️

Self虚拟机采用分代式堆结构进行内存管理,这与我们上周讨论的生成式垃圾回收思想一脉相承。

堆主要分为两代:

  1. 新生代(Young Generation):进一步分为Eden区和两个Survivor区(fromto),用于新对象的分配和次要回收(Scavenge)。
  2. 老年代(Old Generation):由多个段(segment)组成,用于存放存活时间较长的对象。

一个巧妙的设计是空间内的双向分配

  • 普通对象(包含对象引用)从内存区域的低地址向高地址分配(向左增长)。
  • 纯字节对象(如字节数组)从同一内存区域的高地址向低地址分配(向右增长)。
  • 两者相向而行,直到相遇。

这样做的好处是:

  • 垃圾回收器在扫描新生代时,可以安全地忽略高地址的字节区域,因为其中不包含需要追踪的对象引用,提升了扫描效率。
  • 所有需要扫描的区域(低地址部分)都具有统一的内存布局,便于快速遍历。

对象表示与映射(Map)🎯

在基于原型的系统中,如果每个对象都完整存储其所有槽的名称和值,空间开销会非常大。Self采用了一种称为映射(Map)(在其他系统中也称为隐藏类)的优化技术来共享元数据。

核心思想

  • Map 是一个内部对象,用于描述一组结构相同的对象(克隆家族)的布局和元数据。
  • 每个用户对象包含一个指向其 Map 的指针,以及其自身可变槽的数据。
  • 不可变部分(如方法代码、常量槽的值)可以存储在 Map 中,从而被所有实例共享。

对象布局示例

  1. 普通命名槽对象
    [ 头部 (标记字) | Map指针 | 槽1数据 | 槽2数据 | ... ]
    
  2. 索引对象(向量)
    [ 头部 | Map指针 | 长度 | 元素0 | 元素1 | ... ]
    
  3. 字节索引对象
    [ 头部 | Map指针 | 长度 | 指向字节数据的指针 ]
    

映射(Map)的结构
Map 本身也是一个对象,它包含:

  • 一个指向 MapMap 的指针(用于形成闭环)。
  • 一个C++虚函数表指针(用于实现VM层面的多态)。
  • 描述信息:如实例大小、槽数量等。
  • 一个槽描述符数组,每个描述符定义了一个槽的名称、类型(常量/可赋值/参数)、是否父槽以及其在实例中的偏移量。

当通过编程原语修改对象结构(如添加槽)时,系统会计算新的 Map,并在全局 Map 表中查找是否已存在相同结构的 Map 以实现规范化,避免重复。


标记方案与对象头 🏷️

Self使用一种紧凑的标记方案来区分不同类型的值,这对于垃圾回收器的快速扫描至关重要。

每个32位的对象引用(称为OOP)都有两个低位的标记位:

  • 00:表示一个小整数(Small Integer)。整数运算可以直接进行,只需检查溢出。
  • 01:表示一个内存对象引用。由于是奇数,如果忘记去除标记位就解引用,会导致对齐错误。
  • 10:表示一个浮点数(缺少两个指数位)。
  • 11仅用于标记对象的头部字

头部字(标记字)包含哈希码、分代年龄位等元信息。关键点在于,只有头部字被标记为 11。因此,垃圾回收器可以快速扫描堆,只需查找标记位为 11 的字,就能找到所有对象的头部,而无需理解对象的内部结构。


C++实现技巧:将VM对象映射到Self对象 🔧

用C语言编写虚拟机需要处理大量的类型判断和转换,代码冗长。Self的VM利用C++的特性,以一种巧妙的方式实现了面向对象的VM层。

核心技巧

  1. 内存覆盖:将C++的类或结构体视为对一块内存的布局描述。通过类型转换,可以将任何内存块“解释”为特定的C++对象。
  2. 静态方法:非虚方法不依赖于对象的虚表指针,可以在对象指针为任意值(甚至为空)时调用,仅作为命名空间使用。
  3. 将虚函数限制在Map中
    • 我们不能在每个Self对象中都添加C++虚表指针,因为这会增加内存开销。
    • 相反,我们将虚函数只放在 Map 这个C++类中。Map 对象有虚表指针。
    • VM代码通过检查对象的标记位,确定其类型,找到对应的 Map,然后通过 Map 的虚函数进行动态分发(例如,调用特定于该对象布局的 scavenge 方法)。

这种方法使得VM代码清晰、易于维护,同时避免了给每个语言级对象增加开销。这一设计模式对后来的许多虚拟机(如HotSpot JVM)产生了深远影响。


分配器与垃圾回收流程 🚀

Self的分配和回收流程经过精心设计,以确保常见路径(快速路径)极其高效。

快速分配路径
分配器(如 EdenSpace::allocate)本质上是一个指针碰撞分配器(bump-pointer allocator)。它检查当前分配指针是否会与字节分配区域碰撞,如果没有,则直接移动指针并返回新对象的地址。这段代码被设计为内联,非常高效。

分配失败与回收触发
如果Eden区空间不足,系统会设置一个标志,表示需要在下一个安全点(GC Safe Point) 进行垃圾回收。编译器生成的代码只在特定位置(安全点)记录栈上的指针信息,因此垃圾回收不能随意中断执行。

垃圾回收(Scavenge)流程

  1. 扫描根集:使用 apply_to_vm_roots 等宏,扫描VM中所有包含OOP的变量和数据结构。
  2. 扫描卡表:老年代对象可能持有对新生代对象的引用。这些引用通过卡表(Card Table) 记录。回收器扫描卡表中被标记的卡片,对其对应的内存区域中的每个字进行检查,如果发现指向新生代的指针,则调用相应的 scavenge 虚函数进行处理。
  3. 处理晋升:从新生代晋升到老年代的对象可能本身也包含对新生代的引用。因此,回收器需要循环扫描新晋升的区域和Survivor区,直到没有新的引用被发现。
  4. 更新引用:在移动对象后,所有对旧地址的引用都需要被更新为新地址。

自适应堆大小调整
Self虚拟机采用了一种自适应的策略来管理堆大小:

  • 黄线:当分配超过此线时,会唤醒一个Self级别的线程,它可以决定是否触发GC或向操作系统申请更多内存。这一切都由Self代码实现,策略可定制。
  • 红线:如果Self代码未能及时行动,分配触及红线,则VM会强制进行垃圾回收,防止内存耗尽。

编程原语与对象结构变更 🛠️

Self的编程环境允许动态修改对象的结构(如添加槽、修改方法)。这些操作通过编程原语实现。

实现策略

  • 函数式风格:编程原语通常以函数式风格实现,即创建新的对象和 Map,而不是就地修改。
  • 最终替换_Define: 原语用于将旧对象替换为新对象。它首先克隆新对象(确保其唯一引用在VM手中),然后遍历堆,更新所有指向旧对象的引用,使其指向新对象。
  • 优化扫描:为了高效地找到所有引用,系统利用了现有的记忆集(Remembered Set)和卡表机制。如果要修改的对象在新生代,只需扫描新生代和脏卡片区域,而非整个堆。
  • 快速堆扫描:对于需要全堆扫描的情况,利用标记位模式(11)可以快速定位对象头部。扫描循环经过高度优化,甚至使用哨兵值来减少循环内的条件判断,旨在最大化内存带宽利用率。

总结

本节课我们一起深入探索了Self语言虚拟机的内部结构。我们从语言本身的简洁设计出发,逐步剖析了其字节码、堆内存组织、基于映射(Map)的高效对象表示、巧妙的标记方案,以及利用C++特性实现的优雅VM架构。我们还详细了解了其高效的指针碰撞分配器、分代垃圾回收流程以及自适应堆管理策略。最后,我们探讨了支持动态编程环境的原语实现机制。

Self虚拟机的许多设计,如映射、基于卡表的记忆集、快速扫描技术以及VM层的对象映射模式,都成为了后来高性能虚拟机(如HotSpot JVM、V8)的基石。尽管Self本身已不再活跃开发,但其设计思想依然极具学习价值,为我们理解现代运行时系统的核心原理提供了清晰的蓝图。

007:从解释到编译 🚀

在本节课中,我们将要学习如何从解释执行过渡到编译执行。我们将首先回顾几种提升解释器性能的经典技术,然后深入探讨动态编译(JIT)的基本原理、设计选择以及实现时需要考虑的关键问题,例如代码缓存管理和垃圾收集的协调。

概述 📋

上一节我们介绍了垃圾收集的实现。本节中,我们来看看如何超越解释执行的性能瓶颈。解释执行虽然灵活,但存在固有的开销。为了获得更高的性能,我们需要将代码编译成本地机器指令。这个过程涉及一系列重要的设计决策和技术挑战。

解释器性能优化技术 ⚙️

在深入编译之前,了解一些提升解释器速度的技术是有益的。这些技术有时在无法进行编译的场景下(如安全限制或资源约束)非常有用。

线程化代码

线程化代码的核心思想是消除解释循环中重复的“取指-解码-分发”开销。

在传统的解释器循环中,我们不断读取字节码,解码其含义,然后跳转到对应的处理例程。线程化代码则将这些字节码替换为对应处理例程的地址指针(或代码片段)。这样,执行流程就变成了一系列直接的跳转,省去了中间的解码步骤。

以下是其工作原理的简化表示:

原始解释循环(伪代码):

while (1) {
    opcode = *pc++;
    switch (opcode) {
        case ADD: ...; break;
        case PUSH: ...; break;
        // ...
    }
}

线程化代码后(概念性汇编):

    load  r1, [thread_ptr]  ; 加载第一个例程地址
    jmp   r1                ; 跳转执行
label_ADD:
    ... ; 执行加法操作
    load  r1, [thread_ptr+4] ; 加载下一个例程地址
    jmp   r1                ; 跳转到下一个
label_PUSH:
    ... ; 执行压栈操作
    load  r1, [thread_ptr+8]
    jmp   r1

通过将控制流“缝合”在一起,我们移除了中央分发循环,从而减少了分支预测错误和指令缓存未命中的开销。更进一步的优化是使用call/ret指令替代直接跳转,这能更好地利用现代CPU的返回地址预测器。

栈顶缓存

在基于栈的虚拟机中,大量操作都围绕着栈顶进行。栈顶缓存技术旨在将栈顶元素保留在CPU寄存器中,而不是内存中。

其基本思路是:将解释器中所有涉及弹出栈顶、操作、再压回栈顶的字节码,重写为直接操作寄存器的本地代码序列。例如,一个ADD字节码原本需要两次内存访问(弹出两个操作数),现在可以转换为直接在两个寄存器上操作的指令。

当栈顶元素的类型可能不同(如整数、浮点数)时,实现会变得复杂。一种解决方案是使用多个分发表,根据当前栈顶寄存器的类型(状态)来跳转到专门优化的处理例程。

超级指令与选择性内联

这两种技术都致力于让每个“步骤”做更多的工作,从而分摊分发开销。

  • 超级指令:通过分析字节码序列,找出频繁出现的模式(如PUSH_LOCAL i后接POP_LOCAL j,可能表示一个赋值),并将它们合并为一个新的、更复杂的“超级”字节码。这相当于在软件层面创建了更复杂的指令。
  • 选择性内联:可以看作是线程化代码的扩展。它不是将单个字节码替换为一个地址,而是将一段连续的字节码序列替换为它们对应处理例程的代码体拼接起来的一个大块。这样,执行完这一序列中的第一个操作后,无需任何跳转就直接进入第二个操作,完全消除了序列内的分发开销。

这些技术通常需要生成器或领域特定语言来有效实现,因为它们严重依赖于对底层机器指令的精细控制。


迈向编译:动态编译基础 🛠️

尽管优化解释器能带来提升,但要获得数量级的速度增长,必须转向编译。动态编译(常被误称为JIT)在程序运行时将字节码转换为本地机器代码。

为何需要动态编译?

静态编译(Ahead-Of-Time, AOT)是传统方式,但动态编译在托管运行时环境中具有独特优势:

  • 可移植性:分发字节码,在目标机器上编译成本地代码。
  • 延迟绑定:运行时才能获得的信息(如具体类层次、实际数据类型)可用于生成更优化的代码。
  • 基于行为的优化:可以监控程序的实际执行情况(哪些路径是热路径),并据此进行激进优化。
  • 利用目标机器特性:编译时已知具体的CPU型号、缓存大小等,可以生成针对性更强的代码。

关键设计选择

构建一个动态编译系统面临几个核心决策:

  1. 编译单元:编译什么?常见选择有单个方法、整个类、或执行轨迹(Trace)。方法级编译是常见起点。
  2. 编译时机:何时编译?
    • 提前(AOT):在程序运行前,包括开发者编译或安装时编译。
    • 即时(JIT):在方法首次即将被执行时编译。这需要暂停执行以等待编译完成。
    • 延迟:在方法被执行多次(成为“热”方法)后才进行优化编译。这能更好地利用运行时信息。
  3. 优化级别:生成代码的优化程度。
    • 简单JIT:快速生成代码,主要目标是消除解释开销,可能进行一些简单的窥孔优化。
    • 优化JIT:进行更耗时的优化,如内联、逃逸分析、高级寄存器分配等。

性能模型:何时编译能获益?

编译不是免费的。我们可以用一个简单模型来思考编译的收益。

定义:

  • I:解释执行一个字节码的平均周期数。
  • C:编译一个字节码的平均周期数。
  • E:执行编译后代码中一个字节码对应指令的平均周期数。

通常,C > I(编译比解释慢),但我们期望 E < I(编译后的代码更快)。

盈亏平衡点公式
一个代码段需要被执行 N 次才能抵消编译成本并开始获益。
N = C / (I - E)

解释

  • (I - E) 是每次执行因编译带来的速度提升。
  • C 是编译的总成本。
  • 比值 C / (I - E) 就是需要多少次执行才能“赚回”编译所花的时间。

结论:要使动态编译有价值,我们需要确保热代码(执行次数多的代码)被编译,并且编译器的速度(C不能太大)和生成代码的质量(E要足够小)之间取得平衡。


简单JIT编译器的实现 🧩

一个简单的JIT编译器本质上是对字节码进行“抽象解释”,但输出的不是计算结果,而是等价的机器指令序列。

编译过程

以下是一个概念性的过程,将解释器循环转换为代码生成器:

  1. 分配代码缓冲区:在内存中分配一块空间,用于存放即将生成的机器码。
  2. 遍历字节码:像解释器一样顺序读取字节码。
  3. 生成指令:对于每个字节码,不是执行它,而是将执行它所需的机器指令序列写入代码缓冲区。
    • 示例 - 加载字面量:解释器会解码字节码,读取字面值,然后压栈。JIT编译器则生成将立即数移动到栈顶位置(或寄存器)的指令。
    • 示例 - 加法:解释器弹出两个值,相加,结果压栈。JIT编译器生成从栈(或寄存器)加载两个操作数到临时寄存器,执行加法指令,再将结果存回栈(或寄存器)的指令。
  4. 处理控制流:对于跳转指令,需要计算目标地址。由于代码正在生成中,可能需要使用回溯补丁(backpatching)技术,即先预留位置,待地址确定后再填写。
  5. 链接与安装:代码生成完毕后,将其从临时缓冲区复制到代码缓存中一个永久位置。更新方法表或其他数据结构,使后续调用能跳转到新生成的本地代码。

代码缓存管理

生成的本地代码需要被存储和管理,这个区域称为代码缓存。

挑战与解决方案

  • 内存分配与碎片:频繁编译和可能的方法卸载会导致代码缓存出现碎片。解决方案包括:简单的驱逐策略(当缓存满时丢弃一些代码)、更复杂的代码缓存压缩(移动代码块以合并空隙),或者将代码作为普通堆对象管理,由垃圾收集器处理。
  • 代码回收:当一个方法对应的字节码不再有效(如类被卸载),其生成的本地代码也应被回收。这需要跟踪代码的使用情况。一个复杂的情况是:如果回收时,该代码正在某个线程的调用栈中执行(栈上有其激活帧),则不能立即回收。这引入了安全点的概念。
  • 安全点:指代代码中一些特定的、状态已知的位置(如方法入口、循环回跳点)。在这些点上,线程可以安全地暂停,以便虚拟机进行垃圾收集、代码回收等操作。编译器会在安全点插入检查代码,轮询一个全局标志,以判断是否需要进入安全点协议。

与垃圾收集器的协作

这是实现中最棘手的部分之一。垃圾收集器需要找到所有存活的堆对象指针,这些指针不仅存在于堆和全局变量中,也存在于:

  • 线程栈帧里:被编译方法的本地变量和临时值。
  • CPU寄存器中:当前正在执行的方法可能将引用保存在寄存器里。
  • 生成的代码内部:如果代码中嵌入了对象指针作为立即数。

解决方案

  1. 栈图:编译器为每个安全点(尤其是调用点)生成“栈图”,精确记录哪些栈槽和寄存器中存放着对象指针(Oop)。当线程在安全点暂停时,垃圾收集器利用这个图来精确扫描根集合。
  2. 保守式扫描:对于非移动式垃圾收集器,可以保守地扫描栈和寄存器,将任何看起来像指针的值都当作指针。这可能导致一些内存无法及时回收,但不会破坏程序正确性。
  3. 处理移动式收集器:如果收集器会移动对象(压缩堆),则所有指向旧位置的指针都必须更新到新位置。这要求栈图必须绝对精确,并且要能安全地修改栈和寄存器中的值。通常这需要在所有线程都停在安全点时才能进行。

总结 🎯

本节课中我们一起学习了从解释执行到编译执行的演进路径。

我们首先探讨了提升解释器性能的几种技术:线程化代码通过将控制流直接串联来减少分发开销;栈顶缓存尝试将频繁访问的栈顶元素保留在寄存器中;超级指令选择性内联则通过让每个处理步骤做更多工作来分摊成本。

然后,我们深入了动态编译的世界。理解了为何需要动态编译、其关键的设计选择(编译单元、时机、优化级别),并通过一个简单的性能模型分析了编译的收益条件。

最后,我们剖析了一个简单JIT编译器的实现思路,包括如何将字节码转换为机器指令,并重点讨论了随之而来的代码缓存管理与垃圾收集器协作的挑战,特别是安全点栈图这两个关键机制。

这些知识为后续实现更复杂的编译优化和运行时特性打下了坚实的基础。下一节,我们将继续探索更高级的编译技术。

008:虚拟机设计与实现中的关键决策 🎯

在本节课中,我们将学习虚拟机(VM)设计与实现中的一系列核心决策和优化技术。课程内容基于Cliff Click在加州大学伯克利分校的讲座,涵盖了从编译器选择、垃圾回收策略到内存模型和性能调优等多个方面。我们将深入探讨这些决策背后的权衡,以及它们如何影响虚拟机的性能、可移植性和复杂性。


概述

虚拟机是现代计算平台的核心组件,它负责执行托管代码(如Java字节码),并提供内存管理、线程调度等服务。设计一个高效的虚拟机涉及众多决策,这些决策相互关联,共同决定了系统的最终表现。本节课程将系统性地介绍这些关键决策点,帮助初学者理解虚拟机内部的工作原理。


核心概念与决策

编译器架构选择

上一节我们介绍了虚拟机的整体架构,本节中我们来看看编译器部分的关键决策。虚拟机的性能很大程度上取决于其即时编译(JIT)和解释器的设计。

以下是几种常见的编译器架构选择:

  • 纯解释器(C或汇编实现):易于实现,但执行速度慢。使用汇编语言实现的解释器通常比C语言版本快约2倍。
  • 模板JIT(Stage 0):为每个字节码快速生成简单的机器码。启动快,但生成的代码质量较低。
  • 轻量级优化JIT(Stage 1):进行常量折叠、死代码消除等基本优化,配合线性扫描寄存器分配器。
  • 重量级优化JIT(Stage 2/C2):进行包括图着色寄存器分配、循环优化在内的全方位深度优化,目标是生成与C语言性能相当的代码。

公式/代码示例:性能权衡
最终性能 = f(编译速度, 代码优化程度, 启动开销)

选择哪种架构取决于目标场景:追求快速启动(如手机应用)可能选择解释器或模板JIT;追求峰值性能(如服务器应用)则必须投资于重量级优化编译器。


垃圾回收(GC)策略

垃圾回收是托管运行时的标志性特性。GC策略的选择深刻影响着应用程序的暂停时间、吞吐量和内存占用。

以下是垃圾回收设计中的关键决策点:

  • 回收算法:标记-清除、标记-整理、分代收集等。
  • 并行与并发:并行GC使用多线程加速回收过程;并发GC允许垃圾回收与应用程序线程同时运行,以降低暂停时间。
  • 精确与保守:精确GC可以准确识别所有指针,从而能够移动对象进行内存整理;保守GC可能将某些整数误判为指针,因此无法移动对象。精确收集器配合碰撞指针分配,能带来5%-10%的性能提升。
  • 暂停时间目标:从网络服务的亚秒级响应,到高频交易的微秒级延迟,不同的目标需要不同的GC算法。

核心挑战:实现一个兼具高吞吐量和低暂停时间的并行并发垃圾回收器极其复杂,需要大量的工程投入。


线程与内存模型

在多核时代,虚拟机的线程实现和内存模型(Memory Model)至关重要,它们保证了多线程程序的正确性和性能。

上一节我们讨论了GC,本节中我们来看看与之紧密相关的线程与同步问题。

以下是相关的关键决策:

  • 线程模型:使用操作系统原生线程还是用户态(绿色)线程?原生线程更强大但更重;绿色线程更轻量但可能受限于操作系统调度。
  • 安全点(Safepoint):线程可以安全暂停、以便VM执行垃圾回收或反优化等操作的位置。通过“轮询”机制实现,在现代CPU上开销极低。
  • 内存模型:定义了多线程通过内存进行通信的规则。Java内存模型规定了volatilesynchronized等关键字的语义,指导编译器和CPU进行指令重排序。
  • 锁实现:偏向锁、轻量级锁、重量级锁等多级锁机制,旨在降低无竞争或低竞争情况下的同步开销。

代码示例:内存屏障

// 写操作后需要屏障,确保写操作对其它线程可见
store_release(&data, value);
// 读操作前需要屏障,确保读到最新值
value = load_acquire(&data);

本地方法调用(JNI)的复杂性

从托管代码(如Java)调用本地代码(如C/C++)看似简单,实则充满陷阱,是虚拟机中最为复杂的边界之一。

以下是本地方法调用涉及的主要挑战:

  • 调用约定适配:Java和C/C++可能使用不同的寄存器来传递参数(例如浮点数),需要进行转换。
  • 对象句柄传递:不能将直接的对象指针传递给本地代码,因为GC可能移动对象。需要传递“句柄”,并在调用前后进行固定和释放。
  • GC协作:在本地调用执行期间,必须允许GC发生。这需要在调用前注册栈帧和寄存器状态,以便GC能够准确找到所有根对象。
  • 内存模型与原子性:注册和注销GC状态的指令必须正确使用内存屏障,以防止与GC线程发生数据竞争。

核心思想:由于虚拟机无法预知本地代码的行为(可能长时间阻塞、持有指针等),因此必须采取一系列防御性措施,这导致了显著的调用开销。


关键优化技术

在长期实践中,一些优化技术被证明具有极高的价值。

以下是几个被反复验证的关键优化:

  • 内联缓存(Inline Cache):将虚方法调用的目标方法地址缓存起来,通过比较接收者类型进行快速分发。对于单态调用点,其性能接近静态调用。
    • 多态内联缓存(Polymorphic Inline Cache):扩展为一个小型跳转表,处理有限数量的不同类型。
  • 激进内联(Aggressive Inlining):消除方法调用开销,并允许跨调用边界进行优化。这是获得峰值性能的关键。
  • 图着色寄存器分配(Graph-Coloring Register Allocation):与线性扫描分配器相比,能更好地处理复杂情况,生成更优的代码,尤其是在激进内联之后。
  • 定制化编译(Customized Compilation):根据调用点的具体接收者类型生成特化版本的代码,从而消除动态分发。
  • 代码补丁(Code Patching):支持在运行时安全地修改已生成的代码,用于实现内联缓存、反优化等。

代码示例:内联缓存伪代码

# R1: 接收者对象
# R2: 缓存的目标类
load R3, [R1] // 加载对象的类
cmp R3, R2    // 与缓存类比较
jne miss_path // 不匹配则跳转到慢路径
call cached_method // 匹配则直接调用缓存方法

经验教训:应做与不应做

基于HotSpot等虚拟机的开发经验,可以总结出一些宝贵的经验教训。

以下是一些值得再次采用的最佳实践:

  • 使用安全点进行协作式抢占:便于线程执行自我服务任务(如栈根枚举),并简化反优化。
  • 实现快速且鲁棒的寄存器分配器:以支持激进内联而不引起性能悬崖。
  • 将常量存储在表中而非代码中:便于GC移动对象,并简化代码补丁。
  • 压缩对象头:将哈希码、锁信息等压缩到单个字中,提升缓存利用率。

以下是一些应避免的决策:

  • 在C++中实现VM核心:C++对象的指针可能被GC移动,导致难以调试的复杂情况。
  • 使用“补丁并向前滚动”到达安全点:实现复杂且容易出错。
  • 过度使用适配器帧(Adapter Frames):可能导致栈帧膨胀和复杂的栈遍历逻辑。
  • 依赖泛型的调用者保存寄存器:增加了GC栈遍历的复杂性。

总结

在本节课中,我们一起学习了虚拟机设计与实现中的一系列关键决策。从编译器与解释器的权衡、垃圾回收策略的选取,到线程模型、内存模型以及本地方法调用的复杂实现,每一个选择都深刻影响着虚拟机的性能、功能与复杂度。我们还探讨了内联缓存、激进内联、图着色寄存器分配等核心优化技术,并总结了来自工业级虚拟机(如HotSpot)开发中的宝贵经验与教训。理解这些内容,是深入掌握虚拟机技术、并能够进行有效性能分析和调优的基础。

009:优化技术

在本节课中,我们将学习虚拟机中的核心优化技术,特别是内联优化,并了解如何通过自适应反馈驱动的优化将这些技术结合起来,以生成高效的代码。

上一节我们介绍了定制化和方法拆分。本节中,我们将深入探讨优化技术中的关键部分——内联,并看看如何通过自适应反馈框架来智能地应用这些优化。

内联:动态优化的核心

内联是动态优化中最重要的技术。它主要做两件事:首先,它移除了调用开销,但这并非最重要的部分。更重要的是,它极大地扩展了编译单元,让编译器有更多的代码可以分析,从而暴露更多的优化机会。你希望在优化时,手头有尽可能多的代码,以便榨取最大的性能。

显然,你只有在知道要调用什么时才能进行内联。这就是我们上周学习的静态绑定技术(如内联缓存)如此重要的原因。它们允许我们说:“哦,我认为我要调用这个。” 这些技术为我们现在开始猜测应该内联什么铺平了道路。这一点至关重要,因为如果做错了,可能会毫无意义地大幅膨胀代码,所以必须审慎地进行。优化技术与自适应反馈框架的结合,为我们提供了一种进行高质量内联的方法。

有趣的是,如果你回顾80年代和90年代关于静态编译器中内联的文献,你会发现许多人尝试过,但那些论文的结论往往是:内联让代码快了一点,但似乎没有太大区别。这是因为没有动态行为信息,很难知道应该内联什么以及何时停止。很容易内联错误的东西、过度内联或内联不足。而动态观察为我们提供了更好的依据,指导我们应该做什么。

内联的巨大优势

内联的巨大优势在于,我们可以获取要内联的方法的中间表示(IR)以及目标方法的IR,并将它们连接起来,开始进行跨方法的分析。这是在优化编译器的背景下进行的。现在,你可以连接控制流和数据流分析技术。特别是,当你看到之前是开放式的控制流(例如从闭包中抛出异常或进行非局部返回)时,如果你能将其内联到词法上下文中,或者内联到足够深的地方,你就可以将其直接转换为跳转指令。突然间,这些操作变得非常廉价。

特别是在闭包式语言中,一旦你将闭包内联到其词法上下文中,你就不再需要闭包了,这使得很多事情变得更加容易处理。当然,这也为更多优化(包括进一步的内联)打开了大门。一旦你内联了一个方法,你就知道了被内联方法的调用点,从而可以持续内联下去。

一个完整的优化示例

我认为没有什么比一个例子更能说明问题了。我将通过一个详细的例子来演示,这个例子摘自早期Self论文。它结合了我们上周看过的所有技术:定制化、拆分、公共分支和陷阱上的原始操作处理,以及内联。它从一个相当抽象、看起来效率低下的代码片段开始,通过反复应用这些技术,最终转换为几乎能达到的最佳代码。

示例方法:sum2

我们有一个名为 sum2 的Self方法。你向一个数字发送 sum2 消息并传入另一个数字(通常是整数),它会将两个数字之间的所有数字相加。非常简单。

其实现最终归结为一个如下所示的循环,这是一个 while 循环。在通常的表达中,你有一个带有 while 的循环和几个闭包,在闭包内部,你评估循环体,通过执行其代码块,然后递增控制变量,直到达到上限。

如果你编译并查看其字节码,你会看到:这里没有 goto,没有循环,这只是推送闭包、推送闭包、发送 while 消息。在语言层面,这里没有控制流,只有当你深入到底层实际执行操作的地方时,控制流才会出现。因此,从这段代码生成循环是一项不简单的任务,因为这里根本没有循环。

逐步优化过程

让我们逐步分析这个例子。

  1. 初始调用:我们从 sum2 调用开始。该方法看起来像这样:如果 sum2 接收一个上限,它有一个初始化为0的 sum 变量,然后执行一个从 self(这里是1)到上限的 do 循环,将索引加到 sum 上并重新赋值。

  2. 第一次内联:我们查找 do 方法的代码并内联它。内联后,我们得到了一些真正的控制结构,但由于它是用闭包表达的,所以还不是直接的循环。

  3. 处理条件测试:代码开头有一个测试 step == 0== 是另一个消息,定义在数字上,它调用一个底层的系统原语(整数相等性的真实定义)。如果由于某些原因(例如参数不是数字)原语调用失败,则会调用失败处理部分。我们内联这个 == 调用。因为我们在调用原语,编译器知道这个原语没有副作用,所以它可以在编译时调用它并得到结果 false

  4. 处理控制流ifTrue:ifFalse: 不是内置的控制结构,它是一个定义。定义在 truefalse 这两个单例对象上。为了处理这个控制流,我们必须再次内联。经过几次内联步骤,我们取出了 false 分支的代码块并执行它。

  5. 处理循环:现在我们需要处理 whileTrue:whileTrue: 被定义为一个无限循环的闭包。在循环内部,它测试条件;如果条件为真,则评估循环体;如果为假,则从该方法返回。一旦我们内联了它,这个带有返回的代码块就可以被转换为一个到循环开始的跳转指令,从而消除 while 循环。

  6. 处理算术和类型推测:现在我们需要处理比较操作符。这里我们需要进行推测,因为我们不知道参数的类型。我们基于“比较通常用于整数”的假设,将比较操作符拆分为两种情况:测试参数是否为整数标签,如果是,则使用整数比较;否则,进行完整的消息发送。我们还可以对 upperBound 的类型进行推测。

  7. 应用拆分技术:我们可以应用上周学的拆分技术,将控制流复制到合并点之后。特别是,我们可以看到比较操作只设置了一个用于控制流的变量。这可以重写为控制流本身。我们将测试移到控制流中,设置一个标志来传递结果,并开始重复分析。

  8. 分析算术操作:现在我们可以分析那些算术操作。我们推测它们都是整数。通过流分析,我们可以证明 i 是一个整数。我们可以安全地进行重写。因为Self具有任意精度算术,所以在算术操作后,我们必须测试溢出并处理它,必要时转换为大整数。

  9. 最终清理:经过一系列内联、常量折叠、控制流优化和清理后,我们最终得到了一个高效的循环结构。剩下的唯一不是C编译器级别效率的操作,是因为我们不知道传入参数的类型而必须进行的测试,以及因为语言处理任意精度而必须进行的溢出检查。

正如论文所指出的,考虑到动态类型和溢出处理,这段代码已经尽可能高效了。这来自1989年Craig Chambers的论文,并且编译器实际上在实践中做到了这一点。

自适应反馈驱动优化

现在,假设我们可以大规模应用这些优化,关键是要选择何时何地应用它们。如果应用不当,可能会适得其反。在即时编译环境中,我们无法在首次执行时就进行所有优化,因为那时我们知之甚少。我们不知道任何类型信息,也没有历史模式来指导内联。

因此,我们将先运行未优化的代码(使用简单编译器或解释器)一段时间,然后选择性地发现热点并应用优化编译器。这样做的优势是,我们可以观察正在发生的情况,并收集做出决策所需的信息。

分层编译与预测

想象一下分层编译的图表:解释器具有恒定的性能,编译器需要前期投入但之后会获得回报。现在,我们引入第二层编译:解释器、简单JIT编译器,以及一个更高级的优化编译器。我们的目标是等待代码运行一段时间后再启动第二层编译器,让第二层编译器通过更好的性能来分摊其编译成本。这个过程可以重复多次。

这一切都依赖于一个预测:过去是未来的良好预测。在实践中,这个假设是稳健的,因为人们编写程序的方式往往如此,程序行为在稳定后不太会发生剧烈变化。

测量与反馈

我们可以使用什么进行预测呢?基于已有的技术,有几种简单的方法:

  • 热点图:我们需要知道代码的哪些部分被执行得最多。我们可以在代码中放置计数器。每当控制流经过某个弧时,就递增计数器。你可以在方法入口放置计数器来计算方法被调用的次数,在循环回边或循环支配者处放置计数器来了解循环的内部热度。
  • 类型直方图:结合计数器和多态内联缓存(PIC)的想法,我们可以在每个PIC的出边上统计不同类型被调用的频率,从而构建类型直方图。这为内联决策提供了宝贵的类型信息。
  • 基于速率的测量:除了绝对计数,我们可能更关心代码当前的执行频率。一种技术是让一个后台线程定期将所有计数器除以一个常数(例如2),这相当于给计数器一个“半衰期”,让最近的执行记录拥有更高的权重,从而将其转变为指数衰减的速率测量设备。

触发与决策

最终,某些计数器会达到阈值并触发重新编译。此时,决定编译什么以及如何编译比计数部分更为关键。一个糟糕的选择是:我们在这个方法里触发了陷阱,就只重新编译这个方法。这是不好的,因为我们没有考虑任何周围的上下文。

我们希望将编译时间明智地投资在大小合适的代码块上——不能太大也不能太小。我们可以通过查看调用栈上的调用计数器来做到这一点。从当前帧开始,向上遍历调用栈,查看每个调用边的频率,直到发现明显的间断。频率下降的地方很可能是一个循环的外边界,频率过低的地方可能不值得编译。这样,我们就确定了要编译的“热”区域。

在像Self这样的系统中,你还会希望查看闭包的词法上下文(即它们的“家”),并内联到那里,因为这样可以消除闭包,带来巨大收益。

编译与优化

确定了编译单元(一个或多个方法组成的树)后,我们会查看根方法的所有内联缓存,分析其调用频率和类型,以决定第一层内联。对于调用频繁且类型特定的调用点,我们进行内联。然后,对于每个内联的方法,我们评估其调用点,直到达到某个限制或决定不再内联。

一旦所有方法通过IR连接起来,我们就可以运行所有经典的优化,如公共子表达式消除等。我们可以使用内部边上的类型和调用计数来指导资源分配决策,例如优先为某条路径分配寄存器,或者安排代码布局使快速路径直通而慢速路径旁路。

最后,我们需要注入安全点。这些点既是为了方便垃圾回收(能够在代码的选定位置找到所有指针),也是为了调试和去优化。

相关优化技术

在自适应优化的背景下,还有几种有用的优化技术:

  • 类层次分析:主要用于像Java这样的静态类型语言。其思想很简单:如果我们有一个类层次结构,一个方法 P 调用了另一个方法 M,而 M 在当前已知的类层次中没有被覆写,那么我们就知道这个调用一定是调用那个唯一的方法。这可以消除许多虚调用,将其转化为直接调用。但需要注意,在程序执行时可能会动态加载新的类并覆写 M,此时优化就不再有效,需要去优化处理。
  • 逃逸分析:当考虑的代码区域足够大时,逃逸分析非常有用。其思想是:分析代码区域内对象的分配,并尝试判断该对象是否“逃逸”出该区域。如果我们能确定对象的引用没有泄露到该代码区域之外,那么我们就不需要进行完整的堆分配,可以将其分配在栈上,或者进行标量替换(将对象解构为其字段,并像使用标量一样使用它们)。这可以减少垃圾收集器的负担,有时还能消除同步锁。

总结

本节课中,我们一起学习了虚拟机中的核心优化技术。我们从内联优化入手,了解了它如何通过扩展编译单元和暴露更多优化机会来提升性能。随后,我们通过一个完整的示例,看到了定制化、拆分、类型推测和内联等技术如何协同工作,将抽象的低效代码转换为高效的本地代码。

更重要的是,我们探讨了如何通过自适应反馈驱动优化来智能地应用这些技术。这包括使用计数器和多态内联缓存来收集运行时信息,基于调用栈分析确定编译范围,以及利用收集到的数据指导内联和经典优化决策。我们还简要介绍了类层次分析和逃逸分析等进阶优化,并提到了实现这些机制所需的支持技术,如安全点和去优化。

最终,这些技术共同构成了现代高性能虚拟机的基石,使得动态语言能够在特定场景下达到接近静态编译语言的执行效率。然而,这种复杂性也带来了性能预测性的挑战,这是我们在追求峰值性能过程中所付出的代价。

010:Dart语言的设计与实现历程 🚀

在本节课中,我们将跟随演讲者的视角,回顾他参与设计和实现Dart编程语言的历程。我们将了解Dart诞生的背景、其设计哲学、核心特性,以及如何从过去的面向对象平台(如Beta、Self、Strongtalk)和现代语言(如JavaScript)中汲取经验教训。课程将涵盖语言设计、虚拟机实现、性能优化以及在现代计算环境(Web、移动端、物联网)中的应用。


概述:从历史经验到新语言设计

演讲者首先介绍了自己的背景。他是一名谷歌的软件工程师,过去五年主要致力于Dart语言的设计与实现工作。本次分享将探讨如何从过去的面向对象平台中学习,并将一些想法融入Dart的设计中。这是他首次参与语言设计团队,目标不仅是提升性能,更是为了创造一种更优的语言体验。


早期经验:Beta、Self与Strongtalk

上一节我们介绍了演讲者的背景,本节中我们来看看影响他设计思路的几个早期项目。

Beta语言:模式与逆向控制流

演讲者最早接触的是大学时期设计的Beta语言。它的核心是一个名为“模式”的统一概念,可用于方法、类等抽象。一个有趣的控制流特性是,子类可以通过 inner 调用将控制权传递给父类,这与常见的 super 调用方向相反。这允许创建一些有趣的控制结构,例如在循环中反复调用 inner

核心体验:这是演讲者首次接触虚拟机,并负责实现垃圾回收器。他的第一个任务是将现有的汇编语言编写的GC扩展为分代式GC,虽然艰难但充满乐趣。

设计反思:Beta语言并非基于花括号,其模式模型可能过于复杂,导致开发者有时难以区分一个模式是用于方法、闭包还是类。这被认为是其未能走出学术圈的原因之一。

Self语言:极简语义与激进优化

随后,演讲者参与了Self项目。这是一种天生“低效”的原型基于语言,其设计深受Smalltalk启发,但拥有极简的语义。

核心技术:该项目最酷的部分是其实现技术,特别是自适应编译和优化技术。它能在进行极致优化的同时,保持系统在“解释执行”的假象。这些技术(如定制化)后来被广泛应用于许多虚拟机中。

性能权衡:然而,Self系统被认为过于臃肿,消耗大量内存。原因之一是其“定制化”优化策略:对于继承层次顶部的某个方法,每个不同的子类类型都会生成该方法的一个副本。如果层次中有400个子类,就会产生400份代码副本,导致代码爆炸。

Strongtalk:可选类型与即时编译

接下来,演讲者加入一家初创公司,致力于将Self的技术应用于Strongtalk。Strongtalk本质上是一个带有可选类型系统的小型谈话系统,其类型系统主要用于运行前检查,而非运行时。

技术改进:团队确保不再使用Self式的定制化。他们实现了一个解释器,只在代码变“热”时才进行编译。解释器会在调用点收集类型信息,以便在优化时进行适当的内联。

时代变迁:随着Java的兴起,市场对Smalltalk的兴趣减弱。团队将Strongtalk虚拟机改造为能够执行Java字节码,这也就是为什么时至今日HotSpot虚拟机中仍存有一些Smalltalk的痕迹。


现代挑战:JavaScript与Dart的诞生

上一节我们回顾了早期的语言项目,本节中我们来看看催生Dart的现代挑战——JavaScript。

演讲者于2006年加入谷歌,其启动项目是组建一个团队,打造一个快速的JavaScript实现(即V8引擎)。当时的JavaScript性能低下,阻碍了Web应用的发展。

JavaScript的优点与问题

积极面:JavaScript极大地降低了编程门槛。开发者可以直接在浏览器中查看、修改代码并重新加载,无需复杂的工具链,这吸引了大量新程序员。

核心问题:然而,JavaScript也存在一些根本性问题,促使团队思考新的解决方案:

  1. 可读性差:程序通过动态执行一系列表达式来构建,难以直接从源码看清程序结构,通常需要启动调试器。
  2. “继续执行”语义:拼写错误、访问不存在的属性只会返回 undefined,程序会继续运行。隐式的类型转换也加剧了这个问题。演讲者更希望尽早抛出运行时错误。
  3. 可扩展性不足:缺乏静态类型,接口不清晰,代码可以动态修改。在谷歌,团队使用Closure编译器,通过注释添加类型信息来进行“摇树优化”和类型检查,但这并不理想,JavaScript更多被当作一种“汇编语言”使用。

Dart项目的启动目标

基于以上问题,大约五年前,团队启动了Dart项目。目标是创造一门新语言,它需要:

  • 易于学习:对于有Java、C#或JavaScript背景的程序员,应在几小时内掌握。
  • 提升生产力:清晰的程序结构、可预测的交付时间。
  • 开发周期快:修改代码后能立即看到效果,延迟超过半秒就会影响创造力。
  • 良好的工具支持:支持重构、代码导航等。
  • 高性能:快速的启动和稳定的运行时性能。
  • 浏览器兼容:必须能高效地编译为JavaScript,以兼容所有现代浏览器。同时需要支持DOM,保证安全性。

设计约束:由于必须运行在浏览器中并编译为JavaScript,语言设计受到限制。例如,无法实现Smalltalk中“非局部返回”这样的酷炫特性,因为无法在JavaScript中高效实现,只能用较慢的异常机制代替。


Dart语言核心特性详解

上一节我们了解了Dart诞生的原因和目标,本节中我们深入探讨其核心设计特性。

设计哲学:融合与简化

Dart的设计并无太多全新概念,而是博采众长:

  • 易于学习:类、单继承、混入、接口。
  • 词法作用域:清晰的变量作用域。
  • 执行模型:单线程执行,但可以创建多个“隔离体”并通过消息传递通信。
  • 可选静态类型:这是其关键创新之一。

可选类型系统

Dart的类型系统是可选的,灵感来源于Strongtalk。

代码示例:无类型与有类型对比

// 无类型版本
makePoint(x, y) => new Point(x, y);

// 有类型版本
Point makePoint(num x, num y) => new Point(x, y);

类型标注(变量、参数、返回类型)在运行时完全被忽略。虚拟机进行自适应编译和优化时,不依赖于这些静态类型信息。

设计意图

  1. 帮助理解:类型作为代码的文档,帮助程序员理解程序。
  2. 可检查的文档:在“检查模式”下运行程序时,可以验证从无类型代码到有类型代码的边界调用是否正确。
  3. 渐进采用:允许在实验阶段编写无类型代码,随着代码成熟再逐步添加类型。也支持团队内不同角色(如讨厌类型的设计师和喜欢类型的工程师)协作。

关于“健全性”:Dart的类型系统是“不健全”的,但执行是安全的(不会崩溃)。这种故意的不健全是为了避免像Java中协变、逆变那样的复杂概念,让普通程序员更容易使用泛型。系统会执行一些隐式转换,而非要求显式类型转换。

构造函数简化

Dart提供了简化的构造函数语法。

代码示例:多种构造函数

class Point {
  num x, y;
  Point(this.x, this.y); // 简化构造函数,参数直接初始化字段
  Point.polar(num radius, num angle) // 命名构造函数
      : x = radius * cos(angle),
        y = radius * sin(angle);
}

var p1 = new Point(2, 3);
var p2 = new Point.polar(5, pi/4);

级联操作符

灵感来源于Smalltalk,提供了一种便捷的链式调用语法,无需每个方法都返回 this

代码示例:级联调用

querySelector(‘#button’)
  ..text = ‘Confirm’
  ..classes.add(‘highlight’)
  ..onClick.listen((e) => window.alert(‘Confirmed!’));

异步编程支持

为处理浏览器单线程和I/O不阻塞的问题,Dart引入了基于 FutureStreamasync/await 语法,灵感来自C#。

代码示例:同步风格的异步代码

import ‘dart:io’;

// 同步风格的异步方法
Future copyStream(Stream input, StreamSink output) async {
  try {
    await for (var data in input) {
      await output.add(data);
    }
  } finally {
    await output.close();
  }
}

async 方法在遇到 await 时会立即返回一个 Future,并将后续代码包装为续体,待操作完成后由调度器唤醒执行。这恢复了正常的控制流,使代码更易读,但代价是栈追踪信息会消失,调试时可能感觉像是“自发激活”。


实现策略与未来方向

上一节我们介绍了Dart的语言特性,本节中我们看看其实现上的关键策略和未来发展规划。

快速启动:快照技术

为了应对JavaScript启动时需要解析执行整个源码文件的问题(如Gmail可能需时1秒),Dart采用了快照技术。

实现方式:在编译时,程序除了被读取外不执行任何操作。首次执行就是 main 函数的第一条语句。通过“摇树优化”移除未使用的代码,并禁止 eval 等动态代码生成。Dart虚拟机使用平台无关的二进制快照来加速应用加载。

性能提升:读取快照比读取Dart源码有10倍的加速比。实现简单:启动程序,序列化堆,生成二进制快照。这对于移动设备省电至关重要。

发展历程与版本迭代

Dart现已是一个开放标准。

  • 第一版(2014):基础语言。
  • 第二版(2014年12月):增加了枚举、异步支持 (async/await)、延迟加载。
  • 第三版(2015):增加了空断言操作符 (?.)、泛化 tear-off(如 new Point# 获取构造函数的闭包)。

多平台拓展

项目重心已从最初的Web扩展到移动和物联网。

  1. Web:持续改进JavaScript编译输出,尝试定义更紧凑、与JS互操作性更好的Dart子集。
  2. 移动Flutter 框架,用于构建高性能、跨平台(Android/iOS)的移动应用,UI部分用Dart编写。
  3. 物联网Fletch 项目,一个紧凑的Dart VM,目标是在资源受限的设备(如Cortex M7 MCU,320KB RAM,512KB Flash)上运行,提供安全的第三方代码执行环境,取代C/汇编。

反思与挑战

设计遗憾

  1. 初期过于关注Web,限制了语言设计,并导致对隔离体和并行化的重视不足。
  2. 从JavaScript继承的一个小问题:p.toString 返回一个闭包,而 p.toString() 才执行方法。这容易导致错误(混淆字段和方法)。因此增加了显式的tear-off语法,希望未来能弃用前一种。

行业观察:编程语言世界充满“信仰之争”。演讲者鼓励创新和尝试新语言,这是提升生产力的唯一途径。学术界也应多使用实验性教学语言,而不仅仅是工业界流行的语言,以培养学生对语言如何工作的深层思考。


问答环节精要

在课程最后,演讲者回答了学生们的一些问题。

关于性能:Dart本地VM相比V8,通常有2倍的性能提升,主要得益于语言更简单,无需像JavaScript那样进行大量动态检查。

关于生产力:难以量化,但有团队在尝试用JavaScript重写大型应用失败后,使用Dart成功按时交付,这间接证明了其生产力优势。

关于类型推断:考虑过但未实现。自动添加类型注释可能带来混淆,更理想的是在IDE中基于程序运行信息提供类型建议。

关于编译目标:不编译为asm.js,因为需要在其上实现整个VM(GC等),且与DOM交互困难。正在关注WebAssembly等更有前景的技术。

关于语言设计团队规模:初期核心设计者宜少不宜多(Dart是两人),以保证一致性。开放征集建议后,评估提案是否契合整体语言设计需要大量时间。

关于异步的隐式化:考虑过但非常复杂。async/await 在代码中提供明确标记是重要的,因为调用异步方法会立即返回,只有该激活被挂起。

关于线程模型:如果重来,可能会加入协程或轻量级线程,这比 async/await 的“同时在两处继续”概念更易理解。同时会避免像Java那样将同步原语(synchronized, wait, notify)内置到所有对象中。

关于V8没有解释器:初期为了简单和快速实现(4个月),只采用了编译模式。后来证明可行。但现在V8团队已引入解释器等多层执行引擎,复杂度大大增加。

关于虚拟机开发心得

  • 从零开始:享受从头构建的乐趣,可以专注于单一目标,例如将VM内部数据结构也放在对象堆上,简化内存管理。
  • 简化设计:例如在V8中,规定垃圾回收不能中断VM代码执行,从而避免了复杂的“句柄”机制。
  • 成功的代价:流行的VM会变得极其复杂(如HotSpot有数百万行代码),需要支持各种用例,背离最初的简洁设计。
  • 语言选择:C++是不错的实现语言,但应限制使用特性(如避免RTTI、模板、多重继承)。

关于统一虚拟机:试图用一个VM(如JVM、CLR)运行所有语言,对于动态语言(如JavaScript)往往性能不佳(可能慢10倍)。为特定语言量身定制VM更容易创新和榨取性能。

关于语言设计者是否需要懂实现:非常需要。性能至关重要,且语言特性需要与实现协同,才能提供流畅的编程体验(例如,在Smalltalk中,你能“感受”到程序的执行)。

关于元编程提示编译器:历史证明这通常是个坏主意。程序员通常不知道如何优化,且优化策略依赖于具体硬件。应保持语言简单,让优化器自适应工作。

关于推广新语言:最好的机会是在全新的领域(如物联网)。如今人们对新语言的期望很高(需要完整的库、卓越的性能、IDE支持等),这需要大量资源,通常只有大公司才能承担。


总结

本节课中,我们一起学习了Dart编程语言的设计与实现之旅。我们从演讲者在Beta、Self、Strongtalk等项目中的早期经验出发,探讨了JavaScript的优缺点如何催生了Dart。我们详细分析了Dart的核心特性,特别是其可选类型系统简化构造函数级联操作符以及async/await异步支持。我们还了解了其实现上的关键策略,如快照技术以实现快速启动,以及其向移动(Flutter)和物联网(Fletch) 领域的拓展。最后,通过问答环节,我们深入了解了语言与虚拟机设计中的权衡、挑战以及演讲者对编程语言生态的深刻见解。Dart体现了在继承历史经验、应对现实约束与追求开发效率之间的一种平衡实践。

011:保守式垃圾收集与性能评估 🧠

在本节课中,我们将学习一种替代性的垃圾收集方法——保守式垃圾收集,并探讨如何评估和比较不同垃圾收集器的性能。


保守式垃圾收集

上一节我们介绍了精确式垃圾收集的实现细节。本节中,我们来看看一种不同的方法。

实现垃圾收集器可能非常困难。使用我们目前描述的方法,你必须确保处理每一个细节。你必须找到存储活动对象引用的每一个位置,尤其是在使用移动收集器时。如果遗漏了任何一个指针,当对象被重定位时,该指针将无法得到更新,从而导致程序出错。

如果你正在构建编译器,你需要跟踪编译器使用的所有位置以及虚拟机本身的引用源。你必须将跟踪信息构建到编译器中,以识别在垃圾收集点或栈槽中哪些寄存器可能存有引用。

此外,你可能希望复用现有的、不具备这种跟踪能力的编译器。或者,你可能需要将引用传递给外部代码,而你无法跟踪外部代码将如何处理这些引用。

一种方法是进行“猜测”,这被称为保守式收集。

保守式扫描的工作原理

以下是保守式扫描的基本步骤:

  1. 扫描内存,查看字的内容,而无需确切知道这些字中存储的是什么。
  2. 如果看到一个看起来像是对象引用的值,就假设它是。
  3. 假设该值所指向的对象因此是存活的。

这被称为保守式扫描。当你看到一个值时,你保守地假设它可能是一个堆引用。通常,你会查看字。如果该字包含一个看起来像是堆地址范围内的值,你就假设它是堆内的一个地址,并且它所引用的对象是存活的。

在保守式系统中,因为你无法控制编译器或外部代码如何处理指针,你可能还需要假设那些看起来指向对象中间位置的引用也会保持该对象存活。

在实践中,通常会混合使用两种方法,因为进行一定量的精确扫描通常并不太难。当你实现堆和自己的对象时,应该不难找出其中的引用位置。跟踪栈或外部代码中的引用则要困难得多。因此,这两种方法经常混合使用。但对于任何一个特定的字,你都需要做出决定:是保守处理还是精确处理。如果是精确的,你就确切知道该字的内容;如果是保守的,你只是进行猜测。

保守式收集的缺陷

这种方法存在一些陷阱。

首先,任何时候你保守地找到一个对象的引用,都意味着你不能移动该对象。因为它可能是一个整数,或者一些与该对象无关的随机值,你不能随意重写程序中的数据。因此,每当做出这种决定时,被引用的对象就必须驻留在该地址,它被该引用“固定”了,即使它可能不是一个真正的引用。如果你试图构建一个也需要处理某些对象不可移动的收集器或压缩器,这会大大增加实现的复杂性,并可能对性能产生负面影响。你可能需要在循环中进行异常测试,判断对象是否被固定,这相当于在系统中引入了碎片化机制。

其次,你可能会遇到垃圾无法回收的问题。当你发现这些值中的一个时,它会引用一个对象并保持其存活,即使它实际上可能不是一个引用,而只是恰好看起来像引用的随机数据。因此,保守式扫描声称是安全的,因为它不会过早地回收对象。但由于它可能被看起来像指针的东西所欺骗,它可能会让对象存活的时间比应有的更长。如果该对象又连接到许多其他对象,那么未被回收的垃圾量可能是无限的。这取决于概率以及你是否幸运。

随着堆占据地址空间的比例越来越大,这个问题会变得更糟。例如,如果你的堆在32位系统中占用2GB,而地址空间是4GB,那么任意一个随机位值指向该堆的概率大约是二分之一。显然,如果切换到64位系统,概率会向另一个方向倾斜。

第三,那些看起来像引用的东西可能不在你的控制范围内。在实现精确系统时,一个典型的技巧是当某个东西被删除时,要么将其移出扫描边界,要么用看起来没有指针的东西显式覆盖它。但在保守式系统中你无法这样做,因为例如,即使一个值已经死亡,编译器也可能将其溢出到栈上,它可能仍然看起来像一个指向死亡栈槽中对象的指针,但你不知道它已经死亡,因为你没有构建跟踪这些信息的机制。

尽管声称安全,但也存在可能导致过早回收的情况。保守式系统的典型实现依赖于它只查看字对齐的字值。如果你通过某种方式隐藏或伪装指针,使其看起来不像指针,那么垃圾收集系统就会被欺骗。例如,如果你进行标记或移位操作,特别是带有标记位的移位表示,这会导致问题,因为它看起来不像是指向保守式收集器的指针。如果你以非对齐的方式存储引用,或者对地址算术进行任何创造性的技巧,也可能导致问题。如果你将指针存储到垃圾收集器不会查看的地方,例如写入文件或进行压缩,保守式收集器也会被欺骗。

最令人担忧的是,编译器优化可能会造成这种情况,而你几乎没有控制或发现的能力。例如,想象一个执行对象复制的循环。编译器完全可以将看起来像指针的东西优化为基于某个公共基址的偏移量。现在,如果你中断这个循环并在寄存器中查找指针,你不会看到任何看起来像源或目标指针的东西。唯一的指针是指向堆基址的,其余的都是索引。因此,如果你要进行保守式收集,你必须以某种方式确保这种情况不会发生。你需要知道编译器会进行哪些优化,或者编写测试来暴露这个问题,并在每次更新编译器时进行测试。

保守式收集的应用与风险

尽管如此,保守式收集仍然被广泛使用。最著名的使用这种风格的收集器是Boehm-Demers-Weiser垃圾收集器。它被用于许多语言实现,因为它易于使用:你只需将其链接进去,像调用真正的malloc一样调用它,但事实上你调用的是该收集器中的malloc重新实现,并且你不需要调用free,因为收集器会负责释放。

事实上,最早的JDK 1.0和1.1使用了非常相似的收集器来收集Java堆。但下面的程序表明这可能不是一件好事。

int slow_sum(int n) {
    int[] a = new int[1000000];
    int h = a.hashCode(); // 在早期JDK中,hashCode返回对象地址的整数表示
    if (n == 0) return 0;
    return n + slow_sum(n-1);
}

在这个程序中,我们有一个递归函数slow_sum,它分配一个大数组,然后立即丢弃对该数组的引用。通过简单检查可知,这里最大的活动数据只有一个数组。然而,在早期的JDK中,hashCode返回对象地址的整数表示。因此,当你存储整数哈希码时,你保持了对象的存活。在这个特定的函数中,随着递归栈的构建,每个栈帧都有一份在该帧中分配的数组的哈希码副本,它们都被保守式收集器保持存活。因此,在精确的JDK上可能只需要几兆字节就能运行的程序,在保守式收集的JDK上可能会耗尽数百兆字节的内存。

我个人对这种做法感到非常不安。我的建议是:不要这样做。我无法认为这种方法足够正确,可以在实践中真正使用。这让我想起一句话:“这一定是我以前不熟悉的‘安全’一词的某种其他用法。”


垃圾收集器性能评估

上一节我们讨论了保守式收集的优缺点。本节中,我们来看看如何衡量和比较不同垃圾收集器的性能。

垃圾收集器有许多不同的方面可以衡量。以下是重要的几个指标:

  • 吞吐量/开销:衡量收集器在执行其任务时的原始时间效率。
  • 内存占用:衡量除了活动数据外,收集器有效运行所需的额外空间。
  • 暂停时间:收集器导致的计算中断时间。
  • 能耗:收集器运行所消耗的能量。

没有单一的优劣指标,你必须在所有这些方面(可能还有其他方面)进行比较。

吞吐量与开销

假设我们有一个固定的赋值器工作负载。在运行应用程序的总挂钟时间中,有多少可以归因于垃圾收集器?

如果你有一个简单的、旧式的批处理收集器串行实现,比如标记-清扫,你可以在不收集的情况下运行,然后进行收集,然后再运行。那么计算起来很容易:红色部分是垃圾收集运行的时间,绿色部分是赋值器运行的时间。你可以用计时器代码包裹红色部分,将所有时间相加并进行计算,这很简单。

如果你想测量吞吐量,一种方法是计算我们在所有运行中回收了多少空间,除以回收该空间所花费的时间,这给出了回收率,并为我们提供了一种比较垃圾收集器回收效率的方法。值得注意的是,如果你的程序运行时间足够长,进行多次垃圾收集,那么分配和回收会趋于相同。因此,在长时间运行的程序中,你可以使用分配作为代理。

不幸的是,对于比这更复杂的情况,测量变得非常困难。例如,对于引用计数,你如何衡量引用计数的开销?你不能在每个递增和递减操作周围放置计时器,因为计时测量本身会增加大量时间,你会得到错误的答案。对于读屏障或写屏障也是如此。一种可能性是与没有垃圾收集器的系统进行比较,看看它慢了多少。但问题在于,没有垃圾收集器的系统在内存耗尽之前不会运行很长时间。

有一个技巧,在过去微架构更可预测的时代更适用。其思想是,如果你有一些低开销的操作,比如卡表标记,它是幂等的。你可以执行两次,并测量执行一次和两次的时间,这可以很好地估计类似卡表标记的成本。但现代微架构会使很多事情变得复杂,第二次执行的成本可能不同。因此,即使这个技巧也常常无法给出正确答案。

在实践中,人们采用一种不太令人满意的方法:实现两种不同的垃圾收集器,然后测量整个系统使用它们时的挂钟时间。花费时间较少的那一个开销较小。你不知道零点在哪里,因此不知道你离最优有多近,但这确实提供了一种比较的方法。需要注意的是,在并行系统上测量时,必须给予它们相同数量的资源,以提供公平的指标。

内存占用

内存占用的测量则完全不同。内存占用不是一个因变量,而是一个你可以控制的变量。你设定系统使用的内存量,并在该分配下运行,观察其表现。因此,确定系统对内存占用的响应的唯一方法是使用不同的分配进行多次运行。

一个常见的报告指标是堆大小与最大活动数据集的比值。因为1.0是你的绝对下限,高于这个值是多少?是两倍,一点五倍,还是更多?这是通常的报告方式。显然,吞吐量会随着内存占用的变化而变化。

暂停时间

像标记-清扫这样的批处理收集器,在调整到合适大小并给予足够堆空间时,将具有极佳的吞吐量。但代价是,你会遇到与堆大小成正比的长时间暂停,这通常使其不适合交互式使用或其他对延迟敏感的应用。

比较暂停时间的一个简单方法是测量最大暂停时间或平均暂停时间。但问题在于,这没有考虑暂停的聚集性。例如,暂停时间均匀分布的系统,与暂停时间聚集在一起的系统,给人的感觉完全不同。从外部感知来看,那是一个更长的暂停。

因此,我们需要一种方法来解释这一点。一个更好的暂停指标是最小赋值器利用率。其思想是,我们取一个时间窗口,将其滑动覆盖整个时间线,并查看在该时间窗口内最坏情况下的利用率,然后报告在该窗口内花费在赋值器上的时间比例。

几年后,有人提出了一个略有不同的定义,称为有界赋值器利用率。其思想是,我们报告特定窗口或任何更大窗口的最小值。这消除了那些奇怪的、非单调的峰值。这是一个相当好的指标。

唯一的问题是,在许多系统中,大部分暂停时间很短,但偶尔会有很长的暂停。那么最小赋值器利用率会完全被偶尔的长暂停所扭曲。因此,有时仍然值得绘制暂停时间分布图,即特定持续时间的暂停数量与持续时间的关系图。

综合比较

我们可以尝试在一个图上综合比较这些指标。例如,绘制堆大小与吞吐量的关系图。我们可以标记出没有垃圾收集器和无限内存时的系统吞吐量,这大致是我们能达到的最佳水平。然后,标记出最小堆大小和系统耗尽物理内存的点。

对于引用计数实现,其曲线可能从某个点开始(例如,在快速系统中,可能花费约四分之一的时间在引用计数上),然后大致保持平坦,直到达到分页点后性能急剧下降。

对于标记-清扫系统,如果只给予最小内存量,性能会很差,吞吐量很低。随着内存增加,吞吐量会迅速上升到峰值,然后保持稳定。峰值可能相当不错。最终,随着TLB未命中的发生,性能开始下降,到达分页点时急剧下降。

对于半空间系统,其性能下降的“悬崖”会更早出现(大约在物理内存的一半),下降更陡峭,但由于分配器更好,可能具有稍好的峰值性能。


总结

本节课中,我们一起学习了保守式垃圾收集的原理、优缺点及其风险。保守式收集通过猜测内存中的值是否为引用来工作,虽然实现简单,但可能导致对象无法移动、垃圾无法回收,甚至被编译器优化所欺骗,因此需要谨慎使用。

我们还探讨了评估垃圾收集器性能的多个维度:吞吐量、内存占用、暂停时间和能耗。没有单一的“最佳”收集器,选择时需要根据应用场景在这些指标之间进行权衡。最小赋值器利用率等指标能更好地反映暂停对用户体验的影响。目前,在吞吐量、内存占用和暂停时间这三个方面都表现出色仍然是一个未完全解决的问题,而关于垃圾收集能耗的研究则相对较少。

012:用高级语言实现虚拟机 🧠

在本节课中,我们将探讨一个核心问题:为什么以及如何用高级语言(如Java)来构建高性能的虚拟机。我们将分析传统使用C/C++的痛点,介绍“元循环”实现方法,并深入剖析几个著名的研究型虚拟机案例。


概述:为何要改变?

上一节我们讨论了虚拟机的基本组件。本节中,我们来看看实现虚拟机本身的编程语言选择。传统上,虚拟机(VM)大多使用C或C++编写,但这带来了诸多挑战。

使用C/C++构建虚拟机主要源于两个原因,其中更重要的一个是为了避免使用C/C++本身带来的各种问题。在开发中,你可能已经遇到了使用C/C++的诸多麻烦。

以下是不按特定顺序列出的一些主要问题:

  • 缺乏类型和内存安全:C/C++不是类型或内存安全的语言。在编写垃圾回收器(GC)等组件时,这或许是优点,因为你需要打破规则并进行底层的位操作和内存操作。但在编写编译器的大部分代码(可能占90-95%)时,你并不需要这种能力。缺乏安全性使得开发变得非常困难,因为错误往往要到运行时才能发现,这影响了开发效率。
  • 依赖未定义行为:使用C/C++构建虚拟机通常需要依赖某种未指定的编译器特性或实现细节。例如,早期Self虚拟机使用标签指针(tagged pointers),这完全不符合语言标准。如果换用不同的C++编译器,代码可能无法工作。这带来了可移植性和稳定性的问题。
  • “黑盒”编译器与内省困难:使用C/C++意味着你依赖于一个“黑盒”的提前编译(AOT)编译器,你无法轻易窥探其内部。例如,如果你想获取栈上所有指针的位置信息,编译器通常不会提供,也没有直接的API。虽然有人尝试使用ELF调试信息等方法来获取,但通常不够可靠,不足以构建一个健壮的垃圾回收器。
  • 运行时代码生成困难:通常无法通过C/C++编译器在运行时生成代码。要么没有API,要么编译器速度太慢,不切实际。因此,你最终还是需要自己构建编译器。
  • 调用约定与内存模型不匹配:当你构建了自己的编译器或解释器后,就必须处理你的编译器与C编译器之间调用约定的适配问题(例如浮点数的处理位置)。此外,语言的内存模型也可能不匹配。C编译器不会为你处理栈溢出检查等问题,如果你的语言需要栈安全,调用C运行时库会非常棘手。
  • 抽象层级不当:C语言对于虚拟机的大部分实现来说过于底层,但对于某些需要精细控制指令序列的部分(如内联缓存),它又不够底层,常常不得不借助汇编代码。

综上所述,用C/C++构建一个高性能、可靠的虚拟机需要极高的技巧和大量的工作。

因此,许多人尝试了另一条路线:用其他语言(通常是更高级的语言)来编写虚拟机。这些语言具有更好的数据结构和安全性,在编写编译器时可以利用这些特性提高开发效率,并能构建出更优雅的抽象。理想情况下,这种语言既能提供高级抽象,又能在需要时进行底层控制,并自动统一处理指针定位、安全点插入、调用约定桥接等繁琐细节。

另一种不直接相关但值得一提的思路是:在现有虚拟机之上,用高级语言构建堆栈式语言实现。即复用他人的VM(如JVM、CLI)来映射自己的语言。这在理论上很好,但在实践中,当目标语言与底层VM语义不匹配时,往往会牺牲性能或引入巨大的复杂性。


核心方法:元循环实现与自举编译

为了获得高性能,同时又能用更合适的语言表达,最常尝试的路径是构建所谓的 “元循环解释器”

不过,这里描述的方法具有特定的编译器架构。你也可以通过用语言自身编写其解释器并在自己的VM上运行来实现元循环(例如80年代初Smalltalk的参考实现,或90年代末用Java编写的Java实现)。但这会带来巨大的性能损失(可能慢100到1000倍),并非我们想要的方向。

另一种思路是避免手写C代码,而是从某种高级语言生成C代码,然后编译C。这利用了C的普遍性和可移植性,但并未解决底层控制等问题。Squeak VM(90年代末为Smalltalk开发)是这方面的先驱。它使用一个名为Slang的Smalltalk受限子集来编写VM定义,然后将其高效地翻译成C,再编译成二进制。开发周期在可移植的Smalltalk环境中进行,构建时则生成C代码。但同样,由于C编译器在运行时太慢,Squeak后来的即时编译器(JIT)并未走C生成路线。

我们真正要讨论的是如何获得一个性能良好的元循环实现,这意味着需要复杂的编译技术,同时用更高效的语言表达。

采用的方法是使用一种架构,其中用于表达VM的同一门语言的编译器,既可以静态地构建VM本身,也可以在运行时动态地编译运行在该VM上的应用程序

让我通过一个具体例子(以Java为例)来演示这是如何做到的:

  1. 假设我们有一个预先存在的Java字节码编译器(图中绿色部分),它已将自身编译为字节码。
  2. 我们首先用Java编写一个编译器,它接收Java字节码并生成机器码(这是一个二进制编译器)。
  3. 我们通过已有的字节码编译器运行这个新编译器的Java源代码,得到其字节码。
  4. 在VM上运行这个字节码,并同时输入我们的VM源代码。由于这是一个生成机器码的编译器,它将接收VM源代码并生成二进制可执行文件(即VM本身)。
  5. 现在,在这个二进制VM内部,就包含了我们刚刚编写的那个编译器。当应用程序字节码在VM上运行时,VM内部的这个编译器可以在运行时生成机器码来执行应用程序。

这种方法的优势在于:

  • 统一的编译器:VM和应用程序使用相同的编译器,意味着调用约定完全统一。
  • 内联与软边界:如果保留足够信息,VM代码可以被内联到应用程序中,VM原语与应用程序之间的调用可以是“软”边界,对编译器优化开放。
  • 公共框架:安全点、指针跟踪等机制可以一次性构建到编译器中,VM源代码大部分无需关心这些细节。

当然,也存在一些挑战:

  • 运行时系统与垃圾回收器的实现:无法用标准Java编写垃圾回收器,需要一些“不安全”的语言扩展来操作内存。
  • 在垃圾回收语言中编写垃圾回收器:这非常棘手,必须使用一个避免分配或严格约束分配的子集,以免自相矛盾。

实例分析:Jikes RVM

最著名的例子无疑是 Jikes RVM(最初在论文中称为Jalapeño),由IBM在90年代末开发,作为一个研究平台。它完全用Java编写,采用了上述框架。

其关键特点包括:

  • 用Java编写:整个Java VM用Java实现。
  • 内存管理工具包(MMTK):一个非常灵活、丰富的GC系统接口,支持多种垃圾回收算法的混合与匹配。
  • 多编译器:至少包含一个基线JIT编译器用于简单执行,以及一个优化编译器用于动态优化。
  • 镜像构建:构建时,所有Java代码被编译并连同初始堆对象一起转储到一个堆镜像文件中。运行时,一个用C写的小型启动器加载此镜像。
  • 树摇(Tree Shaking):在生成镜像时,通过静态分析移除未使用的代码、方法、字段等,使最终VM更小更快。

语言扩展:如何获得“不安全”能力

既然你在实现语言本身,你可以添加任何需要的特性,只要最终用户看不到这些扩展。

通常通过以下三个组件实现:

  1. 添加原始数据类型:用于访问需要操作的机器级实体(如机器字、指针)。
  2. 添加内部方法(Intrinsics):在这些类型/类上添加方法,编译器能识别其特殊含义并直接生成对应的机器指令。
  3. 类型包装与注解:用类型和注解将不安全代码清晰地包裹和标记出来,确保其意图明确且不会泄漏到不应出现的地方。编译器通常会被修改,使得这些特性在最终VM中不对最终用户应用程序开放。

注解还可以用于驱动内联、提供优化提示(因为VM是静态编译的,需要良好的AOT优化),以及引导启动过程(区分在镜像构建时和运行时执行的代码)。在编写GC时,也需要注解来指导编译器不要在某些位置插入安全点等。


更多案例:Klein与Maxine VM

另一个风格迥异的例子是 Klein VM,一个用Self语言编写的Self虚拟机。其目标不同,侧重于将完整的Self世界对象导出并嵌入到最终的启动镜像中。它的一个突出贡献是调试体验:它利用Self世界已有的反射代理机制,允许一个Self VM(调试器)检查和修改另一个正在运行的Self VM(被调试目标)的状态,提供了比原始内存转储友好得多的调试环境。

Maxine VM 则是一个用Java编写的Java研究虚拟机,深受Klein启发。其架构图包含了许多熟悉的组件:垃圾回收器、基线编译器、优化编译器、去优化、栈行走、线程、锁、本地方法等。

Maxine的几个有趣创新点包括:

  • 模板JIT:基线执行不是通过解释器,而是通过模板JIT。这些模板本身用Java编写,并带有注解,由编译器提前处理生成机器码,同时生成连接模板所需的元数据。
  • 代码片段(Snippet):为了解决优化编译器中“元编写”的难题(即手写IR拼接代码),Maxine引入了Snippet技术。它允许用相对高级的Java代码(加上注解)来表达低级操作。构建镜像时,这些代码被编译成通用的IR片段;运行时,编译器根据上下文将其特化并内联到正在编译的方法中。这大大提高了代码的可读性和可维护性。
  • 检查器(Inspector):这是一个强大的进程外调试器,专门用于调试Maxine VM自身。它在构建镜像时生成丰富的元数据,使得检查器能够连接到正在运行的世界,即使底层VM已崩溃,也能以高级的、面向对象的方式查看内存、寄存器、栈帧等状态,并支持修改和继续运行。

对比与总结

有人可能会问:为什么不直接创建一门全新的语言来实现VM,而非要用带注解的Java呢?

主要优势在于,你可以利用一门成熟、被广泛采用的语言所带来的丰富工具链(IDE、代码分析、重构、文档生成等)。Maxine(以及下周将讲的Truffle)非常小心地只使用纯Java机制(如注解)进行扩展,避免语法扩展,这样所有现有工具都能正常工作。这避免了重新构建一整套工具生态的巨大成本。


本节课中,我们一起学习了用高级语言实现虚拟机的动机、核心的“元循环”自举编译方法,并深入分析了Jikes RVM、Maxine等研究型虚拟机的具体实践与创新。关键在于通过统一的编译器框架和精心的语言扩展,在提高开发效率与抽象层次的同时,不牺牲对系统底层的必要控制能力,从而构建出既可靠又高性能的托管运行时系统。

013:元追踪与RPython 🚀

概述

在本节课中,我们将学习一种构建虚拟机(VM)的创新方法——元追踪(Meta Tracing),以及用于实现解释器的RPython语言。我们将探讨如何通过将语言语义与即时编译(JIT)基础设施分离,来简化复杂动态语言虚拟机的构建过程。


虚拟机构建的传统挑战 🏗️

上一节我们介绍了课程背景,本节中我们来看看传统虚拟机构建面临的困难。

许多现代语言(如Java、.NET、Python、JavaScript)都通过虚拟机执行。实际生产环境中使用的大多数虚拟机都是手工用C/C++编写的,由大型或小型团队维护。

虚拟机并非简单的软件,它们编写起来可能非常繁琐,需要很长时间来排除错误,并且在人力成本上也可能很高昂。对于像Python或JavaScript这样非常复杂的动态语言来说,尤其如此。


性能目标与JIT编译 ⚡

当然,虚拟机指令集的一个非常重要的目标是性能。由于解释器对于虚拟机所运行的大型应用程序来说通常不够快,因此虚拟机需要某种即时编译系统来将性能提升到足够的水平。

这对于动态类型语言来说尤其重要,因为在运行前无法(或很难)通过查看程序来确定其类型信息。


理想的虚拟机架构图 🗺️

那么,虚拟机的架构是什么样的呢?我画了一个理想化的图表。虚拟机当然需要一种方式来表达它要运行的语言的语言语义。然后,有几个漂亮的方框:一个是JIT编译器,它基本上会在运行时将语言程序转换为机器码,并考虑运行时反馈等因素;另一个是优化器,它接收JIT使用的任何中间表示,并应用一系列转换步骤,以期进一步提高性能。所有这些都嵌入在我称之为“通用运行时”的东西中,其中包含垃圾收集器、与操作系统的集成、线程模型等。我不会过多讨论运行时部分,所以这部分画得比较模糊。

这是一个很好的理论模型,但在实践中,这种漂亮的组件化模型并不完全成立。


现实中的复杂纠缠 🌀

现在,我们离开理想化的图片,看看一个真实的JIT(例如V8或SpiderMonkey)是什么样子。实际上,它们是非常庞大和复杂的工程成果,由许多人(有时是数百人)维护多年,在某些情况下甚至长达数十年。其架构通常非常复杂,系统的不同部分完全纠缠在一起。

这张奇怪的图表试图展示这一点:运行时通常与语言语义深度集成,而语言语义又通常与JIT编译器深度交织。当然,优化器和语言方法也混在一起,因为优化器需要知道语言的工作原理等等。

这种巨大的、纠缠不清的混乱意味着实际上很难改变虚拟机所运行的语言。所以,如果要集成一个新的语言特性,仅仅实现它就可能很麻烦,因为可能有些优化依赖于该特性不存在,或者存在其他完全被遗忘的随机交互。这对于仍在快速发展的语言来说尤其是个问题,JavaScript就是一个例子。实际上,有一些新的JavaScript特性,V8并没有特别高效地实现,因为这样做会非常困难,需要对引擎的其余部分进行非常深入的(或者说,非常烦人的)更改。


基础设施重复与理想目标 🔄

另一个问题是,当然,并非只有一个虚拟机。有许多不同的虚拟机,它们之间绝大多数完全不共享任何基础设施。因此,每个虚拟机都一次又一次地实现了垃圾收集器的所有部分、优化器后端的所有部分等等。

如果能够摆脱这种纠缠不清的混乱,转向更像我在开头展示的那种漂亮的理想化方框的架构图,那将是非常酷的。今天演讲的内容就是关于这个目标。


RPython项目的目标 🎯

这个项目的目标基本上是分离实现虚拟机时的不同关注点。

一方面,需要有一种方式来表达所实现语言的语言语义。另一方面,可以想象很多JIT编译问题完全独立于这些语言语义。可以有一套通用的优化,可以在大量虚拟机之间共享。

但当然,认为所有优化都能以完全独立于语言的方式表达是不现实的。因此,可能还需要一些语言特定的优化。但我们的基本理念是,这四样东西(语言语义、通用JIT、通用优化、语言特定优化)可以被分离和重用。

这确实是RPython项目的目标。


RPython:受限的Python 🐍

那么,我们的武器是什么?RPython代表“受限的Python”(Restricted Python)。

它基本上是一种为编写解释器而设计的编程语言。它被称为受限的Python,是因为Python本身是动态类型的,而RPython以某种方式受到限制,使得可以分析整个程序的源代码,找出程序中所有变量的类型,然后将该程序转换为C代码。

所以,基本上,RPython是Python的一个子集,可以编译为C。因此,使用RPython实现语言的方法是:用RPython编写该语言的解释器。然后,翻译工具链会获取你的解释器,生成基于C的虚拟机。

在这个转换过程中,会向真实的解释器添加许多特性,而这些特性在解释器中并不显式存在。最明显的一个是垃圾收集器。解释器本身是用RPython编写的,这是一种类似C的垃圾收集语言。这意味着在将RPython程序翻译为C程序时,垃圾收集器需要存在,并且它来自某个地方。它基本上来自一个转换步骤,该步骤在将解释器转换为C程序的同时,也“缝合”了与垃圾收集器的边界结构。

我们已经在许多解释器中使用了这种语言,其中最成熟、也是我今天主要要讲的一个叫做PyPy,它是一个用RPython编写的Python解释器,显然这也是项目名称的由来。

实际上,RPython项目在时间顺序上恰恰相反。最初的项目是PyPy项目,在PyPy项目的开发过程中,越来越清楚地认识到,最初仅作为实现工具而实现的RPython语言,对于实现其他语言的解释器也非常有用。

正如我们已经暗示的,这是一个相当古老的项目,大约有13年历史了,它获得了相当多的资助,许多人年投入其中。


元追踪:让RPython变得有趣的关键 🔑

到目前为止,我告诉你的内容实际上并不那么有趣。基本上,RPython是一种可以用来编写解释器然后编译成C的语言。到目前为止,这就像许多其他可以编译的语言一样。

但让RPython变得有趣的是,我们能够在将解释器转换为C的过程中,集成JIT编译。

实现这一目标的方法被称为“元追踪”(Meta Tracing)。关于元追踪如何工作,我将在接下来的几张幻灯片中更详细地解释,但其基本原理是:你获取这个用RPython编写的解释器,将其编译为C,在将RPython解释器转换为C的同时,另一个组件被插入到最终的C可执行文件中,这就是“元追踪器”(Meta Tracer)。它基本上是一个通用的、可重用的即时编译基础设施,可以用于基本上任何用RPython编写的解释器。

因此,元追踪器包含了许多你所期望的通用JIT编译基础设施。例如,它有针对各种机器架构(如Intel、ARM、Power等)的后端代码生成器;有与目标操作系统的集成(JIT编译器需要的,例如从操作系统获取可执行内存页等);还有与垃圾收集器的集成也被插入其中。

这种将JIT编译器插入最终可执行文件的过程大部分是自动的,但并非完全自动。解释器作者需要通过向解释器添加少量源代码注解来帮助这个过程,以告诉该过程解释器的工作原理。但这实际上不需要很多代码,可能只需要添加大约10行左右的注解,就能实现JIT的插入。

换句话说,我们需要告诉这个过程解释器的主分发循环(micro-dispatch loop)在哪里,以及该循环中哪个变量存储了程序计数器。


元追踪如何工作:重温追踪式JIT 📝

让我们更详细地谈谈元追踪是如何工作的。你们读过HotPath论文,所以应该知道大部分内容,但我还是会再概述一下。

当D. H. Park发明追踪式JIT时,其中一个很酷的想法是:如果你有一个语言的解释器,可以添加一个非常简单的组件到解释器旁边,即追踪器,使得可以使用该解释器为程序生成机器码。

这就是追踪器的基本思想。你获取解释器,在旁边添加追踪器和优化器,它们可以帮助你不仅解释程序,还能从程序中生成机器码。

其工作方式是:你首先像通常那样,通过解释来运行程序。解释器愉快地执行程序的字节码,但同时,它也会进行一点性能分析,以找出程序的哪些部分(特别是循环)执行得频繁。

追踪式JIT的另一个重要见解是:为了让程序长时间运行,通常会有某个循环运行很多次。因此,追踪式JIT的思路是,它试图将其编译工作特别集中在程序中的这些热门循环上。

当解释器开始运行程序时,它基本上会做一些分析,以确定程序中的哪些循环是频繁执行的。它通常以非常简单的方式做到这一点,例如为每个循环设置一个计数器,然后每次进入循环时增加计数器。如果计数器超过某个阈值,它就会说:“哦,我已经在解释器中运行这个循环一千次了,它一定很重要,所以可能值得花一些编译精力在这个循环上。”

一旦达到阈值,解释器就会告诉旁边的追踪器:“这里正在发生重要的事情,可能值得开始观察我在做什么。”

然后发生的是:解释器继续运行。它继续执行那个循环的下一次迭代,但追踪器也被启用。追踪器所做的基本上就是记录解释器为运行该循环而执行的操作。通常,记录的形式非常简单,就是解释器执行的字节码列表。

当循环结束时(即再次到达循环开始处),追踪器会说:“好了,我现在记录了这个循环的一次迭代。现在我要把它转换成一段机器码,优化它,转换成一段对应于我所记录内容的机器码。”

实际上,作为这种方法的一个副作用,发生的一件非常有趣的事情是:你会自动获得所有在该循环中调用的函数的内联。因为对于追踪器来说,循环中有一个调用指令完全无关紧要。它会简单地忽略那个调用指令,而是记录运行被调用函数时执行的操作(字节码操作)。通过这种方式,所有从该循环调用的函数都通过追踪被简单地内联了,这意味着追踪式JIT在构建方式上,内联得非常激进。


追踪示例与守卫(Guard) 🛡️

让我们看一个例子,因为刚才的说明可能有点抽象。

这是一个完全虚构的类似Python语言的示例。我们假设这个指令序列发生在一个频繁执行的循环中(周围有某个循环,但我想展示一个小的例子)。JIT确定追踪这部分很重要,于是开始追踪。

在追踪过程中,变量x的值是-1

追踪器生成的中间表示看起来像这样。有很多守卫(Guard)指令,我来解释一下它们是什么,因为实际上要执行操作x < 0(假设这是一个动态语言),解释器首先需要查看该指令参数的类型。所以首先,它检查x是一个整数。为了确保下次构建追踪时也是如此,它插入了一个类型守卫,说:“确保x是一个整数。”然后,对另一个操作数0也发生同样的事情,所以有另一个类型守卫说:“确保0是一个整数。”(这显然是整数,但追踪器还是记录了它。)

然后我们执行比较,接着有一个基于比较结果的if分支,所以我们再次记录一个守卫:在循环追踪期间,x小于0(因为x-1,小于0)。然后我们进入ifelse分支,所以我们再次检查x是整数,检查2是整数,然后做加法。在加法之后,我们检查x是整数,检查3是整数,然后做另一个加法。

我们已经可以看到这里有很多冗余。我们检查了常量的类型(0的类型显然总是整数,所以这些可以被优化器移除)。然后我们反复检查x的类型。在我们看到x是整数之后,我们不需要再次检查。优化器会将其转换为更短的形式,也可能将两次加法融合在一起,比如直接加5(先加2,再加3)。

但那里引入的重要部分是检查x是整数且小于0的守卫。


守卫的作用与失败处理 ❌

正如我们所见,每次追踪器在循环中遇到某种条件时,都需要将该条件转换为守卫。原因是,如果我们后来将追踪转换为机器码并运行它,该追踪只记录了一条线性路径,其中根本没有控制流。因此,如果我们执行追踪机器码,并且其中一个if语句走了与追踪时不同的路径,那么该机器码对于整个执行过程实际上无效。所以,所有可能发生分歧的情况都需要用守卫来检查,以确保控制流确实与追踪记录时相同。

因此,如果这些守卫中的一个失败,那么生成的机器码的执行实际上无法继续,因为如果只是遵循某个我们可能只见过一次的控制流,那将是一个坏主意。

在守卫失败的情况下,机器码的执行停止,取而代之的是,从守卫失败的点重新启动追踪器,走另一条路径。

实际上,一个令人惊讶的事情是(这并不一定显而易见),大多数守卫永远不会失败。如果你对真实系统做一些统计,看看有多少守卫曾经失败,结果发现90%到95%的守卫从未失败。而在那些失败的守卫中,很大一部分也只失败几次。


处理频繁失败的守卫与侧追踪 🔀

当然,有时循环中的条件两边被采用的概率相等。对于这些条件,守卫会经常失败。

追踪方法也需要处理这种情况。其思路是:如果你有一个经常失败的守卫,你基本上会增加某种计数器来记住这个守卫失败了很多次。如果该计数器超过另一个阈值,比如说:“哦,这个守卫已经失败了50次,它一直失败。我们需要做点什么,因为回到解释器然后解释,效率太低了。”

那么发生的是:追踪器将从守卫失败的点开始追踪,产生另一段追踪,即另一条穿过循环的线性路径。然后为该路径生成机器码,并修补原始的机器码,使得原来在守卫失败时跳回解释器的代码,现在跳转到新的机器码片段。通过这种方式,逐步地,追踪器将覆盖循环中越来越多的控制路径,直到最终,几乎所有实际被采用的路径都被追踪覆盖,那么我们实际上就不再需要解释器了。


虚拟机状态转换图 🔄

关于追踪器如何从解释器转换到JIT代码,可能有点令人困惑。在下一张幻灯片中,我画了一个小的状态图,展示了带有JIT的虚拟机可能处于的状态。

当你启动虚拟机并开始运行程序时,正如我所说,程序最初总是被解释的。所以我们从左上角开始,运行程序直到解释器的性能分析器说:“哦,那边的那个循环似乎很重要。”在这一点上,虚拟机转换到追踪组件。它仍然继续运行解释器,但在解释器旁边也有追踪器记录解释器在做什么。这一直持续到解释器再次运行完一个完整的循环迭代(即追踪对应于一个循环)。然后追踪器将其交给优化器,优化器将优化并生成机器码。

虚拟机接下来要做的是运行该循环的下一次迭代,所以我们可以立即从优化组件跳转到我们刚刚生成的机器码,跳转到那里并立即运行我们刚刚追踪的循环的下一次迭代,希望是高效的机器码。

只要控制流不偏离,我们就可以愉快地执行该循环的高效机器码版本。当然,通常不会永远如此。所以在某个时刻,要么循环结束(由守卫处理),要么遇到另一个未覆盖的路径(由另一个守卫处理)。

在某个时刻,我们将离开右侧的单一追踪执行,守卫失败,然后回到解释器。

基本上缺少两条边。一条边很明显:当我们运行时,在某个时刻,我们遇到一个已经为其生成追踪的循环。然后我们可以直接从解释器跳转到我们之前生成的追踪的执行。当然,这需要修补追踪并将其连接到循环,以便当我们稍后遇到相同的循环时,可以重用我们已有的追踪。

另一条缺少的转换是:我们确实追踪了一个守卫失败,运行一个追踪,守卫失败,我们已经知道这个守卫经常失败。然后我们直接从运行追踪转换到从该失败守卫开始的路径的追踪,追踪它直到遇到我们知道的其他东西,然后基本上修补进那个侧追踪。


元追踪:解决传统追踪的问题 🧠

所以,这是传统追踪如何工作的基本思想。我认为它与典型的基于方法的编译方法(其中编译单元是单个方法)有很大不同。

向前思考,这种方法有意义吗?有什么问题吗?

我有一个问题。我读过很多相关论文,但从未见过这个问题的答案,也许你可以回答。你描述的情况在单个解释元素没有任何复杂内部控制流的情况下工作得很好。但是,如果你想象一种情况,比如Smalltalk,其中一个字节码(或一个原语)有更复杂的内部逻辑,并且你想追踪穿过该字节码或原语实现的路径,你如何使追踪适应这种情况?还是你根本就不这样做?

这是一个非常好的问题,它也直接引向了元追踪的酷炫之处。我认为有两个答案。传统追踪器通常做的是:对于许多字节码,它们有特殊的逻辑,说:“我知道如果我追踪这个字节码,我还应该记录一些额外信息,因为这个字节码有一些内部控制流,我需要将该字节码‘展开’成一些语法上的东西。”一个很好的例子就是加法操作,在某种映射中,它可能对应一个方法调用,但通常有一条快速路径。所以,你基本上想把它展开成:“哦,它恰好是整数吗?那就走这条特殊路径;如果不是,就执行普通的带类型检查的方法。”但基本上,实现方式是在追踪器中,为每个有内部控制流的字节码设置一段特殊的逻辑。

实际上,这是一个非常好的过渡点,它将很好地引出下一张幻灯片。


元追踪的核心思想:追踪解释器本身 🔍

元追踪的想法有点烧脑,但当你开始思考它时,它确实有道理。追踪式JIT的问题是:我们必须为每个解释器编写一个新的追踪器,这是痛点。而且我们必须在追踪器中复制很多字节码的语义。

那么我们做什么呢?我们不把追踪器放在解释器旁边,而是把追踪器放在解释器下面的一层。这意味着我们使解释器的实现对追踪器完全透明。

这意味着我们实际上不追踪用户程序的迭代,而是追踪解释器源代码中执行的操作。我们追踪那里发生的事情。追踪现在将不再包含字节码,而是包含对应于解释器源代码及其内部发生的事件的操作,这意味着那里的操作要精细得多。

既然你们看过练习,就知道简单的解释器实际上是大循环。特别是字节码解释器,是一个巨大的、无尽的循环,只是说“给我下一个字节码”,然后有一个基于字节码的switch语句,然后你跳转到某个地方。这太棒了,追踪这个循环是完美的,因为我们有一个循环,并且我们把所有时间都花在这个循环里。所以,追踪健康的一个条件已经满足:我们有一个循环。

但问题是,穿过那个循环的控制流实际上并不非常可预测。假设我们在字节码解释器中执行某个程序,我们执行一个加法字节码。下一个字节码也是加法字节码的概率非常低。穿过分发循环的控制流在迭代之间并不遵循非常可预测的路径。所以看起来我们陷入了困境。看起来追踪解释器是一个糟糕的主意,因为我们永远不会停留在同一个追踪上。

其见解是:我们实际上并不只追踪字节码分发循环的一次迭代,而是追踪许多次。我们追踪足够多的迭代,使得追踪的长度与用户程序中的一个循环相匹配。

这意味着,当解释器执行某种“这里是循环开始”的提示时,我们开始追踪。我们追踪字节码分发的许多次迭代,穿过许多不同的字节码,直到遇到一个跳转回我们开始追踪的那个循环开头的指令,然后我们停止。

这意味着我们的追踪现在包含对应于解释器源代码级别操作的事件,但追踪的长度仍然对应于用户程序中的循环。这就是为什么我想强调这一点。通过这种方式,我们获得了可预测的执行概要,因为既然循环在解释器级别运行,我们将有可预测的控制流。

我们再次获得了一个追踪,我们很可能可以停留在该追踪上很多次迭代,这对应于用户程序的许多字节码的一次通过。


解释器作者的注解:提供必要信息 🏷️

你如何检测?你对最终目标语言一无所知。那么你怎么知道你在重新开始?当然,我们做的是:我们要求解释器作者提供一定程度的帮助。

我们要求解释器作者做的是,确实给我们一些关于解释器的信息。这又是一件非常神奇的事情,因为它将帮助我进入下一张幻灯片。

我们要求解释器作者做的是,确实给我们一些关于解释器的信息。我们知道解释器有点不透明,但很可能字节码分发循环不会是解释器中唯一的循环。解释器的状态中可能还有其他循环。所以,不一定是我们应该广泛地找出解释器中的哪些循环对应于用户程序中的循环。

因此,我们要求解释器做的是,基本上在源代码中添加一个小注解,说:“这是你应该关注的循环。”这是我们需要解释器作者提供的一条信息。

我们需要解释器作者提供的另一条信息是:标记那些可能导致用户程序中循环关闭的指令。


注解示例:标记循环与跳转 🎯

以下是需要添加到解释器中的两个基本注解。

作为示例,opcode函数(它加载一个整数,然后有一个用于每个指令的switch语句)在微分发循环的开头,你需要说:“这里启用JIT。”有一个JIT函数需要调用,这告诉JIT:“这是循环开始的地方。”

然后,在分支指令中,你需要调用另一个函数。这只对那些构成循环的分支调用。分支看起来就像它们所做的向后跳转。因为在其他情况下,重复执行指令可以来自标记。而这只有在程序计数器在某个时刻减少时才会发生。所以基本上,一个分支,我们只需要在分支指令处调用JIT,如果跳转是向后的。

有了这些,JIT就有足够的信息来进行性能分析,因为它基本上可以在你调用JIT的地方,为看到的程序计数器值保留一个计数器,以查看该循环已经执行了多少次。这是它能做的第一件事。它能做的唯一一件事是,如果计数器达到阈值,它可以开始追踪,直到我们遇到另一个返回的向后分支。因为,基本上,我们执行该循环的许多次迭代,直到我们遇到另一个返回的向后分支。


优化器的作用:移除解释器开销 🧹

采用这种方法,生成的追踪也必然包含从流中获取下一个字节码的指令。所以,即使你在一个循环中,你仍然要获取每个字节码,并有一个守卫来确保它是你之前看到的同一个字节码。所以你一开始就面临这个问题。

然后,优化器的工作就是摆脱那个开销。对于那个问题,这有点明显。因为字节码流是不可变的。这意味着如果你有一个字节码流,并且你在一个固定的程序计数器处读取一条指令,你可以常量折叠那个读取。然后,用于确定正确字节码的switch语句也可以被常量折叠。通过这种方式,你可以摆脱所有操作程序计数器和字节码流的操作。


追踪收集与机器码生成 🛠️

关于在你收集了用户程序循环的追踪之后,追踪JIT如何知道开始执行本地代码,我仍然有点困惑。基本上,每次追踪器遇到一个向后分支时,它会检查该分支的目标是否对应于追踪的开始。如果是这种情况,我们就关闭追踪。在这种情况下,我们基本上会在追踪的末尾添加一个跳转,说“跳回开头”,我们将该追踪交给优化器,然后有一段粘合代码会跳转到新生成的机器码。我们总是可以立即使用该机器码。我们仍然处于刚刚追踪的那个循环中。

那么,你是否将字节码中的索引映射到某个东西?是的,是的,是的。对于一个程序计数器,我们保留一个条目计数和一个地址(如果我们已经为该循环生成了机器码)。所以在解释器中,当我们遇到这个循环头时,有一些代码(可能是一个服务)会说:“我已经有该地址的代码了吗?”如果有,它就直接跳转过去。


处理动态特性:以eval为例 🤔

在像带有eval的JavaScript这样的语言中,会有另一种机制来处理它。实际上,不清楚这是否是个问题。要么它是一个问题,因为你需要涉及eval;如果你经常这样做,效率就不会很高。

但元追踪器实际上没有eval的问题,因为它会简单地追踪到eval中,并且可能很快就会发现内联这里的所有内容绝对没有好处。所以,它只会生成一个调用,基本上转到eval的代码那里。

回到你关于位操作的问题,我不太确定追踪到位操作中是否有意义,因为它包含另一个复杂的循环。所以不清楚通过内联所有这些你会获得什么好处。但实际上,如果你提醒我,Poam小组写过一篇关于他们基于RPython的Smalltalk的论文,其中确实有一些有趣的实验。


优化器:线性追踪的优势与逃逸分析 🏃

正如我所说,追踪器包含许多你所能想到的典型编译器优化,比如常量折叠、深度访问优化、死代码消除、强度削减等等。它们都以一种令人惊讶的方式存在。有趣的是,它们都相当容易实现。使追踪式JIT有趣的事情之一是,优化阶段实际上非常简单,因为优化器只需要处理完全线性的机器码追踪片段,所以优化器永远不需要处理任何复杂的控制流。

因此,优化器基本上所做的只是对追踪进行一两次从上到下或从下到上的遍历,基本上只是从那里移除操作。这使得优化阶段比在基于方法的JIT中要简单得多,因为在基于方法的JIT中,你需要处理控制流合并、跳回和跳转到完全不同的地方等情况。

唯一与其他许多JIT不同的优化是逃逸分析。该优化在动态语言的上下文中非常重要,因为动态语言倾向于分配大量内存。我认为你们练习中使用的示例语言也有这个特性,原因很简单:像整数、浮点数这样的原始对象需要被装箱,这意味着它们需要在堆上有一个小的堆单元,在那里存储它们的值,以使它们看起来像其他对象。

这意味着每个算术操作实际上都需要查看两个这样的堆单元,读取它们的内容,执行操作,然后向垃圾收集器请求一块新的内存来存放结果。但事实是,如果你将这些内存单元用于原始值,它们通常具有非常有限且完全预定的生命周期。

如果你看一个简单的算术表达式,比如a * b + c,这一点就变得完全明显。如果你在解释器中运行它,并且它们都是整数,会发生的是:解释器将检查b的类型,检查c的类型,读取内容,执行乘法,然后在堆上创建一个新的装箱结果。然后将其传递给下一个操作(加法),该操作将再次进行同样的操作:检查类型,执行加法,创建新的装箱结果。但此时,整个表达式的结果产生了,b * c表达式的装箱结果就死了,所以当然,摆脱它是值得的。

因此,我们在RPython中拥有的一个非常重要且非常语言特定的优化,基本上是一条可以穿过追踪的路径,它查看追踪中发生的所有分配,并且可以移除那些具有短预定生命周期的分配。这意味着,特别是,像大型算术表达式这样的算术中间结果可以完全被移除。这意味着,如果你在解释器中查看主要是算术计算,将会产生大量垃圾(内存垃圾),但在追踪之后,这些分配中的大多数被简单地移除,从而提高了性能。


语言特定提示:超越语义的优化 💡

但问题是,通用优化实际上无法利用JIT不知道的语言的许多属性。

那么,为什么使用这个(元追踪)是个好主意呢?我希望这些内容对学生来说足够清晰。JIT编译器的强大之处在于能够查看程序的当前运行情况,并将该信息反馈到编译中。这是我们目前描述的系统做得不多的事情。它做了一点,因为它确实选择了在当前运行中经常发生的控制流,但它并没有真正做太多。

它不能这样做的原因是,虽然语言的语义对追踪器来说是完全清楚的,但追踪器仍然不知道语言在实践中使用的典型方式。这实际上不是一个完全显而易见的观点。人们可能会认为,追踪器可以观察解释器,追踪器完全知道语言如何工作。但仍然没有足够的信息来实际进行任何好的优化,因为语言使用的许多方式不一定从语义中显而易见。

在大多数面向对象语言中,一个非常重要的优化是观察到大多数方法调用点是单态的,这意味着对于大多数调用点,一个非常具体的方法实现被调用。这实际上是一个非常令人惊讶的结果,它根本不存在于该面向对象语言的语义中。理论上,可以想象每个调用点都会调用数千个不同的方法。但这似乎不是任何现实世界程序的情况。

因此,我们需要一种方式,让语言实现者能够将这些关于语言在实践中如何使用的期望,反馈回JIT机制。为此,因为该信息实际上并不存在于源代码中,所以基本上有第二组提示。第一组提示是标记循环和跳转,第二组提示负责编码关于语言的知识,这些知识超出了如何执行语言,而涉及语言通常如何被使用。


方法调用优化示例:单态调用点 🎯

正如我所说,在一个方法调用点,通常总是调用相同的方法。我认为你们很快也会在自己的练习中遇到面向对象语言的这个共同属性。

这里是一个非常粗略的草图,展示了在面向对象语言解释器中方法发送的样子。基本上,发送由两部分组成:一部分是查找,它接收我们发送消息的对象的类和方法名称,然后沿着该类的继承层次向上或向下遍历,查看每一层是否存在该名称的方法,并返回第一个找到的函数指针。我在这里省略了细节,比如这是如何工作的。

然后,这里有另一个函数sent,它在解释器级别实际实现消息发送。它接收一个对象、消息名称以及一些参数对象。它的实现方式是:它获取对象的类,然后在类上查找给定名称的方法,得到一个方法对象,然后在解释器级别,它将使用某种机制实际调用那个具体的方法。

根据统计数据我们知道,在程序解释中调用sent的特定位置,我们看到的类很可能总是相同的。我们可以通过提示来传达该信息。我们称之为promotepromote基本上是说:我期望对promote的调用参数在该程序点只包含非常少量的值。它告诉JIT:“我相当确定在这个位置,当你实际运行追踪时,你只会看到一个、两个或少量几个类。”

因此,在解释过程中,promote只是被忽略。但在追踪时,追踪器将使用promote并在该点插入一个守卫。该守卫说:检查变量class包含这个值,它用于与变量比较的值是追踪期间看到的具体的运行时类。所以,promote基本上是一种能够将运行时值反馈到追踪中的机制,因此它基本上可以使变量值常量化。当然,这只有在变量确实只取少量值时才有效;否则,你会引入一个总是失败的守卫,这从来都不是好事,但也不是一个好主意。


常量折叠与可折叠注解 📌

现在我们知道类的值是常量,但我们用它做什么呢?查找仍然可能非常复杂。但假设我们有一个非常简单的语言,你实际上不能在运行时改变继承层次结构,不能在运行时添加新方法,这意味着类是完全不可变的。在这种情况下,我们可以添加另一个提示。

该提示在下一张幻灯片上,它基本上是说:如果追踪器看到对lookup的调用带有常量参数,它可以完全移除该调用,并用追踪期间看到的结果地址替换它。我们只能这样做,是因为我说过,在这个特定语言中,你不能添加新方法。

现在我们处于一个非常酷的情况,因为现在为发送生成的代码包含一个守卫,检查我们是否仍然拥有追踪期间看到的类,然后直接跳转到缓存的方法。通过这两个提示,我们基本上实现了一种内联缓存。但内联缓存基本上只是你可以用这两个提示表达的最简单的东西。它们实际上非常强大,在某些方面,几年后我们仍然在发现有趣的新用法。但构建模块是:我期望这个变量只取少量值;以及一种定义你自己的“可折叠”函数的方式,如果你用常量参数调用它,那么用所有参数进行常量折叠是安全的。

所以,foldable注解就像一个纯度注解。我们特意没有称它为pure。它最初被称为pure,但实际上有一个非常合理的用例将其添加到有副作用的函数中。例如,函数本身有某种记忆化或缓存。那么函数有副作用,它改变了运行时状态。但该副作用是可观察的:你第二次用相同的参数调用该函数,你会得到相同的结果返回。即使你实际分析该函数,你会发现它有副作用,分析会看到它确实停止了。所以,将其添加到并非真正纯的函数中实际上是没问题的。这就是为什么我们没有那样称呼它,但基本上,将其添加到你的函数中总是安全的。

另外,promote总是安全的,它可能生成更慢的代码,但会生成正确的代码。而foldable并不安全。事实上,我们有一个检查模式,你可以缓慢地运行程序并检查你所有的foldable注解是否正确。


案例研究:PyPy 🐍

现在我想更详细地谈谈两个小型案例研究,主要是关于PyPy。PyPy确实是整个项目启动的原因。也许对一些人来说不明显,Python很棒,但它有一个缓慢的实现,所有编写JIT的尝试都失败了,因为它太复杂了。你无法真正理解所有的优化规则。我们需要一些更聪明的方法。这基本上是所有这些上层工作的原始动机。

它基本上是我们对Python的RPython实现。当然,这也是项目名称的由来:Python in Python。它与Python 2.7非常兼容,所有纯Python 2.7程序都应该能在PyPy上运行,并且希望它们能快得多。

它有一个相当大的社区,我们有大约20个活跃的开发者,或多或少。他们兴趣多样:有些人做Web开发,有些人喜欢编写工具,等等。

它大约有60,000行RPython解释器代码,另外大约有1,000行提示。有一些模块是超级优化的。


性能优化历程:逐步添加提示 📈

当我们第一次成功插入JIT时,我们添加了两个提示:这里是微分发循环,这里是跳转。但从那时起,工作并没有停止。这并不意味着每个语言特性都是最优的。然后开始了一个持续的过程,基本上遍历所有语言特性,从极其常见的到越来越罕见的,持续多年。当有人发现一个程序运行不快时,我们查看追踪,说:“哦,它不快是因为某某查找慢。”然后我们在解释器中添加一些提示,编码关于字典查找在程序中通常如何使用的假设。然后全局查找变得快一点。然后我们找到下一个慢的特性,基本上,这是一个持续的、非常容易添加简单提示的过程,这些提示帮助不大,但你可以逐步改进你的语言,直到你对结果满意。

在资金和多样性的特定滑动尺度上,有各种点,你真的可以停下来,说:“我对相对于简单解释器获得的2倍性能提升感到满意,这对我来说足够了。”或者你可以继续下去,拥有越来越多这样的提示,拥有越来越多晦涩的特性,这些特性在一段时间后真的变得非常小众。

在PyPy中,我们现在大约有100个提示。我们自2008年或2009年以来就能够添加JIT。自那时起,我们一直处于重写、调整、在这里和那里添加一些东西并变得越来越好的过程中。现在我们关注的是相对晦涩的特性。

所有核心特性都得到了优化。我举一些例子,基本上是为了让你了解我们编码的假设类型。正如我所说,我们仍然在发现新的、我们可以用这些提示做的事情。


高级优化示例:列表的特殊表示 📊

方法查找我们讨论过,字典查找实际上与方法查找非常相似。基本上,关于全局查找的假设是:全局变量是常量。它们不一定是,你可以改变它们,但在实践中,大多数全局变量永远不会改变。这意味着我们希望以这样的方式添加提示,使得追踪真的只是立即使用正确的全局值,并附带一个守卫来检查该全局值没有改变。

然后还有一些相当复杂的东西。这是我们用这种方式做的更复杂的优化之一:我们对列表、字典和集合有一些非常高级、复杂的内部表示,这些表示在运行时动态变化,取决于特定列表的使用方式。

导致该优化的观察是:如果你有一个存储整数的列表,它通常只存储整数。一旦你创建了这样一个列表,后来添加一个非整数是非常罕见的。如果你实际查看整数列表在内存中的朴素表示方式,它实际上是一个指针数组,所有指针都指向存储整数值的小堆单元。在指针之后,是堆的方式。

因此,当你遍历这样的列表并对元素进行操作时,你需要解引用所有指针,需要进行类型检查。这效率不高。

所以我们做的是:我们添加了一个特殊的表示策略,它识别列表元素都是整数的情况,然后切换到一种更紧凑的表示形式,这种形式根本不使用装箱来存储列表内容,而是直接将整数值存储在列表本身的指针后面,并在列表上设置一个特殊标志,说:“我是一个整数列表。”然后,当你实际追踪一个操作该列表的循环时,你会得到更好的代码,因为你得到的代码不需要检查你刚读出的列表元素是否是整数,因为你已经知道所有元素都是整数。所以你得到的代码看起来更接近你用C语言编写的东西。

当然,如果有人向该列表添加非整数内容,你必须完全改变内部表示来处理需要存储非整数的情况。这代价很高,但我们也发现,这是一个非常罕见的情况。你创建一个巨大的整数列表,然后添加一个非整数内容,这种情况很少发生。


Python的复杂性:属性查找示例 🧩

正如我在演讲开始时提到的,我讨论的方法适用于复杂的动态语言。我想展示这张幻灯片的原因是,通常当我们谈论实现语言虚拟机时,我们谈论的是像Self或Smalltalk这样的语言,这些语言实际上相当简单。所以,一些字节码并非无关紧要,但它们不做太多工作。在Python中并非如此。Python有一些做大量工作的字节码。

这令人惊讶,因为Python看起来像一种相对简单的语言,当你只是了解它并编写一些脚本时,它看起来都很不错。但当你真正开始实现它时,你会发现Python在幕后实际运行时发生了什么。为了演示这个原理,这张幻灯片展示了一个稍微简化的步骤序列,该序列发生在每个属性查找中。

每次你在Python中调用一个方法或读取一个属性时,都会发生类似的事情。第一步是检查对象上是否有一个名为__getattribute__的特殊方法。如果存在,你只需调用它,就完成了。如果不存在(这是正常情况),你在对象的字典中查找属性n。如果存在,你就可以使用它。

如果它不在字典中,那么它就是实例字段。这意味着它一定在对象的类上的某个地方。因此,我们沿着对象类的方法解析顺序(MRO)向上走。Python有多重继承,所以有一种半复杂的算法,将所有可传递到达的基类按某种线性顺序排列。基本上,你沿着那个方法解析顺序走,查看每个类,看看方法是否在那个字典中。

如果你找到了属性,你并不直接返回它,而是调用该属性上的一个方法,这又会触发所有那些东西。如果你在那里查找,你调用另一个特殊方法。如果两分钟后它还在那里,你就调用它。只有在那之后,你才引发一个错误。所有这些都发生在一个字节码内。

如果你编写一个传统的JIT,在你的追踪中,你只会看到调用。而你的工作是从中生成高效的机器码,遵循所有这些步骤,这显然是完全不可能的。基本上,这些细节再次变得无关紧要,但我只是想稍微说明一下,为什么我认为,在我看来,如果没有更多的时间和金钱,手动为Python编写一个JIT真的不那么容易。有太多的细节需要考虑。


性能基准测试 📊

因为采用元追踪方法,这种混乱根本不是问题,因为元追踪器会简单地跟随解释器穿过所有这些步骤。你可以添加一些提示。最终,在常量折叠和不可达代码移除之后剩下的代码通常非常简单。但你不需要制定任何关于如何生成良好代码的复杂规则,也不需要查看任何地方。

实际上,我们必须提供以使这些东西快速的

014:Truffle框架与元循环编译

概述

在本节课中,我们将探讨一种构建虚拟机(VM)的全新方法。这种方法由Oracle Labs的研究团队开发,旨在通过共享核心组件来降低为不同语言构建高性能虚拟机的复杂性和成本。我们将介绍Truffle框架及其背后的元循环编译(Meta-Circular Compilation)思想,并了解如何利用它来高效地实现动态语言。


性能现状与实现挑战

上一节我们讨论了传统虚拟机实现技术的演进。本节中,我们来看看当前不同语言实现的性能差异及其背后的原因。

我根据“计算机语言基准测试游戏”网站的数据绘制了一张图表。该图表展示了多种语言实现(选取了主流的前20种语言)在最佳性能配置下的表现,并将性能数据归一化处理。

请注意:这些基准测试结果需要谨慎看待。因为许多测试程序并不符合某些语言的典型使用场景。例如,用R语言编写的测试程序可能完全不是R语言的实际应用方式。尽管如此,图表仍揭示了一些普遍趋势。

当我们以对数尺度(而非线性尺度)绘制性能数据时,可以看到性能分布非常广泛。一些语言表现良好,JavaScript处于中间位置(如果现在重测,其表现可能会更好)。图表右侧则 broadly speaking 是所谓的“脚本语言”。

例如,SmallTalk和R语言的性能表现远差于其他语言,几乎差了一个数量级。对于R语言而言,这主要是因为基准测试并未利用其强大的统计库。在实际使用中,用户通常会调用用C或Fortran编写的高性能原生库来执行核心计算。但如果用纯R编写新的算法,其性能就会落入图表所示的低性能区间。

那么,为什么会出现这种性能鸿沟呢?我认为历史上的实现流程是主要原因。

以下是实现一个高性能VM通常需要经历的步骤及对应的成本估算(基于对数尺度):

  • AST解释器:类似于课程初期练习中的解释器。实现难度较低,可能需要数月时间,但性能一般。
  • 简单JIT编译器:为一个真正的语言(而非简单的教学语言如F)实现一个可维护的JIT编译器,可能需要一年的努力。
  • Self式优化系统:能带来数倍的性能提升,但代价可能是10人年的工作量。
  • 达到顶尖性能(如HotSpot):所需的工作量近乎无限。据估计,HotSpot VM可能是数百人年的成果。

另一种衡量方式是代码行数。HotSpot大约有300万行代码。即使为一个简单语言实现一个性能尚可的VM,也可能需要数十万行C++代码,并且复杂度会迅速上升。

因此,很少有语言拥有高性能VM:首先,具备此能力的人才不多;其次,巨大的投入成本令人望而却步。

核心问题在于,传统上每种语言都需要独立的实现,组件之间复用程度很低


现有复用方案的局限性

一种复用方案是将一种语言实现在另一种语言的运行时之上(即“Amtrak”式堆叠)。但这通常并不成功,会导致性能不佳、复杂度极高或兼容性等问题。

以JRuby(在JVM上运行的Ruby)为例。其峰值性能比C实现的Ruby解释器快大约10倍,这不错。但与运行在相同JVM上的等价的Java代码相比,其性能仍有5到10倍的差距。更糟糕的是,JRuby的实现极其复杂(约200万行Java代码),因为它需要绕过底层Java实现所做的各种假设。它甚至有自己的动态编译器来生成字节码。

这催生了JVM近年来最重要的语义扩展之一:invokedynamic字节码指令。它旨在更好地支持动态语言。然而,即使引入了invokedynamic,性能提升也非常有限(最多2倍),距离理想状态仍有很大差距。

这里存在一个根本性的矛盾:假设你想同时实现静态语言和动态语言。

  • 如果将静态语言(如Java)作为底层,它缺乏对动态语言所需特性(如类型标记、动态对象布局、开放调度等)的原生支持。invokedynamic是朝此方向迈出的一步,但显然还不够。
  • 反之,如果将动态语言系统作为底层,它又缺乏对机器级类型和虚表调度等静态语言优化机制的支持。

因此,在单一静态或动态基础上堆叠另一种类型的语言,总会存在语义失配

另一个问题是,现有的高性能VM通常模块化程度不高,组件深度耦合。为了榨取最后一滴性能,优化措施往往横跨多个模块(如垃圾回收器与编译器相互影响),难以解耦复用。

特别是优化编译器,它通常是VM中最复杂的部分。有趣的是,优化编译器的大部分内部机制(如优化框架和中间表示IR)其实是与语言无关的。那么,能否构建一个可复用的、语言无关的优化核心,并通过语言相关的前端和特定优化来适配不同语言呢?这正是我们接下来要探讨的方向。


Truffle框架:基于AST解释的元循环编译

我将介绍一种由Oracle Labs团队开发的方法,它试图解决上述问题,提供一个框架,使得大部分复杂组件可以在不同语言间复用,同时允许独立编写语言前端并获得优异的性能。

我们从哪里开始呢?让我们回到AST解释器。

还记得这张幻灯片吗?它直接来自本课程的第一个幻灯片。关于AST解释器的两点是:解释执行很慢,但易于编写和推理。如果能编写一个AST解释器并让它运行得很快,那将是定义语言语义的理想方式

那么,我们需要什么来让它运行得快呢?显然,我们需要编译它,需要为原始程序中的结构生成优质代码。

我们需要:

  1. 程序本身(因为编译解释器本身无法得到好性能)。
  2. 对语言中每个元素在可执行形式下含义的理解。
  3. 一种将程序元素与其语义结合,并生成代码的方法。

如何从解释器得到代码?
回顾我们最初编写的简单AST解释器,它通过分派AST节点类型来递归求值表达式。

假设我们有之前常用的表达式 b = 2 * a + 1,以及对应的解释器代码。我们可以对其进行抽象解释:在编译时遍历已知的AST结构,但无法求值所有表达式(因为它们依赖运行时值)。我们可以提取出动态部分,并将它们组合成一个可编译的表达式。

这个过程大致如下:从赋值节点开始,根据其语义(求值右侧并赋值给变量),我们已知变量b,可以填充这部分。对于右侧的加法,我们继续抽象解释其子树结构,用+操作填充,并递归处理其子节点。直到遇到常量(直接保留)和变量(转换为变量查找)。最终,我们得到了一个去除了解释循环、可直接编译的表达式代码。

这个思想并不新鲜,它被称为部分求值:根据求值时刻已知的结构,对解释器进行求值,将剩余部分留待后续求值。将解释器与部分求值器结合,就能得到一个编译器。要编译不同的语言,我们只需要为该语言编写一个新的解释器(用宿主语言编写),并为该宿主语言编写一个部分求值器。

这个理论自20世纪70年代(如1971年Futamura的论文)就已存在,但并未催生出特别高效的动态语言实现。原因在于,当我们尝试对动态语言解释器进行部分求值时,情况要复杂得多。之前的例子很简单,因为我们只有一种类型。一旦涉及动态类型、类型标记等,生成的代码效率提升有限,仅仅移除了解释循环。

我们需要的是:生成优质的用户程序代码,这需要语言元素的语义和用户程序本身,同时还需要在运行时收集程序实际行为的信息,用以预测未来行为,并优化我们从解释器中选择哪些部分放入编译代码。

因此,我们将结合:

  • AST解释器:天然定义了每个语言元素的语义。
  • AST内部的性能分析与特化:收集生成代码所需的热点类型信息。
  • 部分求值器/编译器:它不仅查看解释器代码,还查看原始程序的AST结构,以指导代码生成。

我们将使用去优化这一“大锤”来处理预测错误的情况。


特化与状态转移

我们如何在AST解释过程中收集类型信息并进行特化呢?我们不希望在每次解释时都进行显式的多路类型分派,而是依赖历史行为预测未来。

设想一段动态语言加法操作的伪代码。在传统解释器中,我们需要在整数加法、字符串连接和用户自定义操作之间进行显式的类型分派。

我们的做法是:将这个具有通用语义的节点分解为不同的状态——例如,整数加法状态、字符串加法状态和通用状态。然后,根据该节点过去的执行历史,用我们发现的合适的具体状态节点替换通用节点。我们依赖这种历史记录,并优化节点间的通信以加速解释,同时附带收集所需的类型信息。

具体流程如下:

  1. 初始时,加法节点处于“未初始化”状态。
  2. 首次执行时,如果参数是整数,则特化为“整数加法”节点;如果是字符串,则特化为“字符串连接”节点。
  3. 如果已处于某个特化状态(如整数加法),但遇到了另一种类型的参数(如字符串),则回退到“通用”加法节点。

在AST树中如何工作?
假设我们有表达式 b = 2 * a + 1,且a当前存储整数。

  • 初始时,所有内部节点都是“未初始化”的浅灰色。
  • 求值消息从根节点传入。常量2和变量a(其值被装箱或标记)被求值并返回。
  • 乘法节点发现两个子节点都返回整数,于是将自身特化为“整数乘法”节点(变为蓝色),并返回结果6
  • 加法节点收到整数61,也将自身特化为“整数加法”节点(变为蓝色),并返回结果。
  • 下次再执行此表达式时,整数加法节点会期望其子节点返回整数,并传递相应的求值消息(如evalInt)。子节点可以直接返回未装箱的整数。如果假设正确,整个求值过程将在未装箱的原始值上进行,速度更快。

当类型假设失败时怎么办?
假设之后我们将一个字符串赋值给变量a

  1. 再次求值时,整数加法节点要求其子节点返回整数。
  2. 常量2可以返回整数2
  3. 变量a的查找节点无法返回整数,它必须通过异常等方式进行“带外”通信。
  4. 乘法节点捕获异常,发现自己无法处理,于是将自身替换为“通用乘法”节点。
  5. 加法节点同样捕获异常,也将自身替换为“通用加法”节点,并使用装箱值完成求值。

在伪代码中,每个节点类需要知道其父节点(以便在树中替换自己),并响应不同的求值请求(如evalevalIntevalString)。如果evalInt请求无法满足,则抛出异常。

节点会有不同的子类来表示不同状态(如AddNodeIntAddNodeStringAddNode)。初始时实例化未初始化节点,当它看到整数时替换为IntAddNode,看到字符串时替换为StringAddNode。通过这种方式,AST树根据观察到的类型联合“着色”。


Truffle DSL:用注解实现特化

显然,手动编写所有特化版本和状态转移的样板代码是极其繁琐的。我们需要一种领域特定语言(DSL)来简化VM构建。

幸运的是,通过结合Java语言及其注解机制、编译器指令(由于我们实现自己的编译器,可以赋予程序元素特殊语义),我们可以在不发明全新语言的情况下获得所需能力。由此产生的DSL称为Truffle DSL

工作流程

  1. 从包含特定注解的Java代码开始。
  2. 调用javac编译器,并启用相应的注解处理器。
  3. 注解处理器在编译过程中被调用,它会查看代码中的注解和AST,生成额外的源代码(如所有特化节点类及其转移逻辑),然后交回给javac继续编译。
  4. 最终输出可运行的产物。所有这些处理都在构建时完成,运行时没有任何开销

生成的源代码会被写入文件,你可以在调试时查看它们。

核心API与概念

  • Node类:所有AST节点的基类。它管理树结构、父子导航、复制替换,并支持关联源代码位置。
  • 类型系统:你需要用注解定义宿主语言中值的实现类型(如int, String),并可以指定类型检查和转换规则。
  • @Child注解:标记AST节点中的子节点字段。DSL处理器在遍历AST时会识别这些字段,并尝试内联和优化。
  • @Specialization注解:用于声明特化方法。你只需按类型编写不同的特化方法(如处理两个整数、两个字符串),并添加此注解。DSL会自动构建状态转移图。
    • 你可以在特化上添加守卫条件(guards)。
    • 使用@TruffleBoundary注解提示编译器不要内联某个方法。
  • Frame(帧):用于实现被解释语言的调用栈。应尽可能使用VirtualFrame。如果遵循规则(不将其存储在堆上或逃逸出方法),编译器会将其优化为真正的栈帧。MaterializedFrame则是实际的对象,性能较差。
  • RootNode:AST的根节点,是进入解释执行的入口点。通过它可以创建CallTarget,调用后者即可开始执行。
  • 控制流:使用Java的控制流结构(如ifwhile)和异常来实现非本地控制流(如return)。在编译后,同一编译单元内的异常处理会优化为跳转指令。

方法内联与去虚拟化
当Graal编译器处理Truffle AST时,它会进行激进的内联,遍历@Child字段连接的结构,寻找并内联execute方法。虚拟帧被优化为栈帧,同一编译单元内的异常处理变为跳转。最终,节点间的分派和解释开销完全消失,生成高效的机器码。

多态内联缓存
如果一个节点(如函数体)被多条具有不同参数类型的调用路径调用,简单的特化可能会使其总是退化为通用节点。Truffle框架通过性能分析计数,当发现某条调用路径是“热路径”时,会克隆该AST子树(重置为未初始化状态),并将其内联到热路径的调用点。这样,热路径就可以进行独立的特化,不受冷路径类型污染。


Graal编译器:运行时编译

Truffle框架生成的是一大堆Java代码,可以在标准JVM上运行(但性能一般)。要获得高性能,需要运行在GraalVM上。

GraalVM包含一个用Java编写的Graal编译器。HotSpot VM在大约两年前扩展了开放编译器接口(JVMCI),允许像Graal这样的Java编写的编译器注册到HotSpot中,并由VM调用它来编译和安装代码到HotSpot的代码缓存中。

执行流程

  1. AST开始以Java代码形式在HotSpot上解释执行。
  2. Java代码中嵌入的计数器会记录执行情况。
  3. 当某个计数器超过阈值时,触发编译请求。
  4. Graal编译器介入,它知道这是一次Truffle编译。
  5. Graal假设此时AST已经稳定(类型信息趋于固定),开始遍历AST,根据AST的类型结构指导激进的内联,并利用分析得到的概率信息进行优化决策。
  6. 生成高质量的机器码。如果之后假设被违反(如类型变化),则触发去优化,回退到解释执行并重新收集信息。

性能调优提示
调优的目标不是加速解释器本身(它只运行几千次),而是为编译器提供提示,使其生成更优的代码。编译后的代码可能比解释器快100倍。

  • @CompilationFinal:注解字段,提示编译器该字段在编译后不会再改变,可以进行常量传播等优化。
  • Deoptimization API:如TruffleRuntime.getRuntime().createDeoptimization(),用于在遇到意外路径时显式去优化并废弃当前编译代码。
  • 性能分析对象
    • BranchProfile:用于标记极不可能执行的分支。编译器可能会优化掉该分支代码,仅放置一个去优化陷阱。
    • ConditionProfile:用于收集布尔条件的真假概率,编译器根据此概率进行优化。
  • Assumption类:用于管理编译期假设的依赖。在代码中调用assumption.check()。如果其他地方使该假设失效(assumption.invalidate()),运行时系统会找到所有依赖此假设的编译代码并将其废弃。这常用于处理函数重定义等动态行为。
  • DynamicObject:用于实现动态语言的对象(类似哈希表)。结合Shape概念,可以优化具有稳定结构(属性集合和类型)的对象,将其访问优化为固定偏移量访问。

系统架构与应用前景

基于Truffle和Graal的系统架构如下:

  • 顶层:用Truffle DSL编写的各种语言实现(纯Java)。
  • 中间层:Truffle框架(纯Java)及其注解处理器。
  • 底层:Graal编译器 + HotSpot JVM(通过JVMCI集成)。

此外,还有一个称为Substrate VM的变体。由于Truffle语言实现通常遵循封闭世界假设(无动态类加载、反射等),我们可以使用Graal进行静态提前编译(AOT),对整个栈进行树摇(Tree-Shaking)优化,消除未使用的代码,最终生成一个不依赖HotSpot的独立原生可执行文件。这能带来毫秒级启动时间和百KB级别的内存占用,非常适合嵌入式和特定部署场景。

当前进展

  • JavaScript:实现完整,性能与V8引擎处于同一量级。
  • Ruby:实现进展良好,在纯Ruby代码上比CRuby解释器快数倍,甚至可以通过内联C语言代码片段,超越“Ruby+C扩展”组合的性能。
  • RCPython等语言实现也在进行中。
  • 多语言互操作:由于共享同一底层IR和编译器,不同语言间的互调用和内联可以非常高效,消除了传统FFI的边界开销。

目标
回顾最初的性能分布图,我们的目标是让所有语言实现都能落入高性能区间(底部带状区域)。虽然由于安全性和动态性等语义开销,脚本语言可能永远无法完全达到C语言的性能,但我们希望将性能差距控制在小的整数倍内,而不是数量级差距。目前,JavaScript和Ruby的实现已接近这一目标。


总结

本节课我们一起学习了一种构建虚拟机的创新方法——基于Truffle框架的元循环编译。其核心思想是:通过编写一个AST解释器来定义语言语义,利用注解驱动的DSL自动生成特化和状态转移代码,最后借助Graal编译器在运行时将解释器逻辑与用户程序AST部分求值,生成高效机器码

这种方法的关键优势在于:

  1. 复用性:Graal优化编译器作为语言无关的核心被复用。
  2. 开发效率:语言实现者只需关注语言语义(AST解释器)和关键特化,无需从头构建复杂的编译器和运行时。
  3. 高性能:通过激进内联和基于分析的优化,能获得接近传统手写高性能VM的性能。
  4. 多语言互操作:共享基础架构使得不同语言间的无缝高效互操作成为可能。

这为快速创建高性能语言实现提供了新的可能性,有望改变长期以来每种语言都需要独立投入巨大资源构建专属VM的局面。

015:Truffle实现、并发与项目概览

在本节课中,我们将学习如何在Truffle框架中实现对象和方法,探讨虚拟机中的并发问题,并了解Oracle实验室中基于Truffle和Graal的相关项目概览。


对象与方法的Truffle实现

上一节我们介绍了Truffle的基本概念。本节中,我们来看看如何在Truffle中实现动态对象和方法。

Truffle最近新增了对动态对象的支持,这在脚本语言(如Ruby、JavaScript)中很常见,允许在运行时动态添加或删除属性。

核心概念是DynamicObject类。它能追踪运行时对象的“形状”,即属性的数量、名称和类型。当形状发生变化时(通常不频繁),系统可以记录这些形状并在它们之间转换,通过缓存实现快速的读写操作,就像在基于类的语言中实现一样。

底层每个对象都有一个StorageObject。可以将其视为一个常规对象,但在对象头中有一个共享引用(类似于映射),指向描述数据布局方式的元数据。由于是在Java中实现,需要静态区分基本类型和对象引用,因此存储区被划分为存放基本类型的区域和存放对象引用的区域(以便垃圾回收器识别)。形状信息告诉你如何查找。

如果对象变得非常大(例如动态添加了许多属性),由于StorageObject是固定大小的Java对象,可以使用对象存储区来引用扩展数组,以容纳更多的基本类型和对象引用。与为特定语言定制的VM直接实现相比,这会在空间和性能上带来一些开销,但在通用的Java实现中,这是能做到的最佳方案。

形状与形状转换

当你向对象添加或删除属性时,会生成(或查找已存在的)新形状,对象随之从一个形状转换到另一个形状。

形状转换示例:

  1. 创建一个空对象:其底层实现是一个对象头和一个指向空形状的引用,存储区为空。
  2. 向字段x(假设为int类型)写入:生成一个新形状,描述对象在基本类型存储区的第0个位置有一个int类型的x字段。对象更新为此形状。
  3. 添加另一个字段y:生成包含额外描述的新形状,对象再次更新。
  4. x字段从int改为String:生成一个新形状,描述x作为String存储在引用区。对象更新,其形状引用指向新的属性位置。

形状本身是不可变的。只有底层对象会改变,并且形状会被规范化(即相同结构的形状是唯一的)。这意味着形状的身份(identity)可以用于相等性比较。

转换映射用于连接形状,以便在需要添加属性、更改类型或删除属性时,能快速找到下一个形状(如果该转换已存在)。首次需要一些计算来创建形状,之后则不需要。

形状的完整结构

每个形状有一个前驱(说明如何到达此形状),形成一个链条。形状还包含指向其后继(链条中的后代)的转换引用。对于每个字段(在此模型中称为属性),有一个Property对象,它提供查找用的键(名称)、在存储区中的位置以及一些相关属性(例如,某些属性可以是隐藏的或仅限内部使用)。还有一个单独的Allocator对象,用于跟踪底层存储的空间使用情况,并在需要时调整大小(例如生成扩展数组)。

所有这些都可以通过子类化来重新实现。此外,属性可以标记为“共享”,此时该属性直接存储在形状中,而不是每个对象的底层存储中,这对于所有实例间共享的不可变数据很有效。

形状树与缓存

形状形成一棵树,根节点始终是空形状。当你插入或删除字段时,就沿着这棵树向下遍历。附加在每个形状上的表用于查找下一个形状(如果已经见过)。

示例形状树:
执行一段JavaScript代码后,可能会得到包含不同属性组合(如xy均为字符串,或x为字符串、y为整数)的形状树。当创建一个对象时,它获得一个形状;当赋值一个新属性时,如果该转换已存在,则只需遍历树找到新形状并更新对象引用。

为了使其快速,你需要进行内联缓存。Truffle提供了一种自动为你构建内联缓存的方法,以加速分发。如果你的代码变得多态(处理过多不同类型),它会自动将内联缓存泛化为多态内联缓存。

在解释器中,你进行类型测试和特化。对于对象属性访问,你可以编写带有特化的简单类,系统在分发操作时会自动添加适当的分发逻辑以形成内联缓存。

内联缓存示例(属性访问):
初始时使用未初始化的缓存。随着遇到更多类型,会添加关联的直接属性获取代码。如果变得多态(例如超过3种不同类型),则会回退到通用情况。

你可以使用@Cached注解和守卫(guard)来编写特化,限制缓存持有的变体数量,并指定回退的通用版本。


简单语言示例与底层代码生成

为了更具体地理解,我们可以查看Truffle的“简单语言”示例。这是一个用于演示Truffle特性的极简语言。

运行简单语言程序:
你可以克隆代码库,下载Graal二进制文件,然后运行示例程序。例如,一个循环程序在最初几次迭代较慢(解释执行),然后Truffle会进行优化并触发编译,后续迭代速度会大幅提升。

通过添加-Dgraal.PrintAssembly等标志,可以查看由Graal为优化方法生成的汇编代码。这让你能看到实际生成的机器指令。

分析生成的汇编(以x86为例):
生成的代码包含一些特定于托管环境的指令:

  • 栈溢出检查:通过测试栈指针下方的内存页来实现。如果访问未映射的页会触发信号,处理器在信号处理程序中抛出栈溢出异常。
  • 安全点指令:例如test指令,用于在需要停止线程进行垃圾回收时设置陷阱。通过保护特定内存地址实现,当线程尝试执行该指令时会陷入陷阱,从而安全地停止线程。
    这些指令确保了在托管运行时环境中的正确性和可控性。

编译流水线与图可视化:
你可以使用Ideal Graph Visualizer工具查看Graal编译器的中间表示(IR)图。编译过程包括多个阶段:

  1. 内联与部分求值:生成包含大量固定守卫节点的图。
  2. 逃逸分析:分析并消除不必要的对象分配(如帧对象),将对象访问虚拟化为寄存器操作,大幅减少节点数量。
  3. 各种优化:如公共子表达式消除、锁消除、循环优化等。
    通过这些阶段,高级的Truffle节点图被逐步转换为更低级、更详细的IR图,最终经由寄存器分配器等阶段生成机器码。

编译器指令与性能剖析

Truffle和Graal提供了编译器指令,允许你在代码中区分是在解释器还是编译代码中运行,从而进行不同的操作。

性能剖析与推测优化:
一个关键的优化技巧是基于运行时信息进行推测。例如,在除法运算中,如果发现除数通常是某个常数(如1),你可以进行推测优化。

基本推测模式:

  1. 使用@CompilationFinal注解标记一个推测值字段。
  2. 在方法中,检查运行时值是否与推测值匹配。
  3. 如果匹配,执行优化后的快速路径(例如,除以1就是原值,或除以2的幂次可用移位优化)。
  4. 如果不匹配,则转移到解释器,并更新推测值。

处理多态情况:
简单的推测在值频繁变化时会导致性能下降(总是回退到解释器)。更健壮的实现需要处理多种状态:

  • 未初始化状态:首次执行时捕获值。
  • 单态推测状态:值稳定时使用快速路径。
  • 泛型状态:当值变化时,回退到未优化的通用路径,并可能在一段时间后重新尝试推测。
    Truffle提供了PrimitiveValueProfile等库来帮助你更优雅地实现这种值剖析和缓存行为,自动处理多态情况。

与Java对比:
对于同样的算法,用Java实现并手动进行类似的推测优化会非常笨拙,因为Java是静态类型语言。而在Truffle上实现的动态语言,通过运行时剖析和即时编译,可以在推测成功时获得巨大加速(10倍或更多),在推测失败时也至少不会比通用实现慢。


虚拟机中的并发

到目前为止,我们主要讨论的是单线程语言实现技术。然而,并发是一个重要领域。

并发与否的考量:

  • 单线程与联邦化:许多项目通过运行多个独立的VM进程(联邦化)来利用多核,例如在网络服务中,让多个独立VM进程处理请求,而不是一个庞大的多线程VM。这在硬件规模巨大、需要跨节点扩展或容错时是常见选择。
  • 多线程共享内存:当需要高效通信和极致性能时,在共享内存池上进行多线程编程是无法替代的。

语言并发模型:
实现并发时,需要考虑语言本身的并发语义(如Actor模型、监视器锁)以及底层硬件的内存模型。

调度:

  • 协作式调度:在单核时代,VM通过协作式调度在多个用户级线程间复用单个线程。这在没有真正并行硬件时提供了并发抽象。
  • 并行硬件上的调度:在真正的并行硬件上,需要处理抢占式调度。在硬件和操作系统层面更好地支持协作式调度是一个研究课题。

解释器与并发:
解释器本身较慢,并行化解释器不是主要目标。关键是要确保并发环境下解释器不会变得特别慢。一些技术(如字节码重写)在并发环境下存在安全问题(重写操作非原子性)。在Truffle中,AST是每个线程独立的,这避免了问题。

线程与性能反馈:
一个开放的研究问题是多线程如何与性能反馈(profile feedback)交互。不同线程可能生成不同的性能数据,如何合并这些数据以生成优化代码,或者是否为不同线程生成独立代码,是一个值得探索的领域。


对象同步与锁优化

对象同步(尤其是监视器锁)在Java等语言中至关重要。为了使其高效,进行了大量优化研究。

锁优化目标:
大多数程序中的锁是无竞争的(仅被一个线程获取)。同一个线程经常重入锁(嵌套的同步方法)。竞争情况相对较少。因此,优化重点是使无竞争锁和重入锁非常快。

HotSpot中的锁实现(基于2006年论文):
对象头字中的标记位编码锁状态:

  1. 无锁:对象未被锁定。
  2. 轻量级锁:优化无竞争获取和重入。
  3. 重量级锁:使用互斥量等操作系统原语处理竞争。

轻量级锁(Thin Lock)流程:

  • 第一次对无锁对象执行monitorenter时,线程将对象头字复制到栈帧的保留槽中,然后通过CAS操作将指向该栈槽的指针(并设置标记位)写入对象头。如果成功,则获取锁。
  • 同一线程后续的重入操作,只需检查对象头中的指针是否指向当前线程的栈范围即可,无需额外操作。
  • 退出时,根据栈帧中的锁记录释放锁,并将原始头字复制回对象。

偏斜锁(Biased Locking)进一步优化:

  • 锁可以“偏斜”于单个线程。对象头中存储线程ID。当偏斜的线程获取锁时,几乎无开销。
  • 如果其他线程尝试获取这个已偏斜的锁,则需要撤销偏斜。这会触发安全点,停止所有线程,遍历持有锁的线程的栈,填充锁记录,并将锁状态降级为轻量级锁。这个过程开销较大。
  • 如果对特定类型的对象频繁发生撤销,则会进行批量撤销,禁用该类型的偏斜锁。

其他技巧:

  • HotSpot会尝试证明monitorentermonitorexit是配对的。如果证明成功,则生成编译代码;如果无法证明,则保持解释执行,确保正确性。

与事务内存的对比:
硬件事务内存(HTM)可以用于实现锁,并可能简化实现(避免偏斜锁的撤销开销)。已有一些研究,但在主流JVM中的采用情况需要查证。


并发环境下的代码管理

代码修补(patching)在并发环境下非常棘手,因为硬件和操作系统设计时并未充分考虑动态代码修改。

挑战:

  • 流水线:即使在单处理器上,修改代码后需要清空处理器流水线,防止已预取的旧指令被执行。
  • 多处理器与指令缓存:需要确保其他处理器的指令缓存(I-cache)中的旧代码被清除。如果缓存是非一致的,则需要显式的缓存维护指令,这可能很复杂且缺乏及时性保证。
  • 内存模型重排序:代码写入可能被重排序,需要特定的代码修补序列(例如,按特定顺序写入指令)来保证无论如何重排序,总能看到一个有效的指令序列。

并行编译:
高性能VM通常有一个编译队列。应用程序线程触发编译请求放入队列,由专用的编译器线程(或多个)在后台进行编译。编译完成后,再原子性地安装新代码。编译器在运行时需要重新检查其假设,因为程序状态可能已发生变化。

本地(Foreign)代码处理:
当调用本地(如C语言)库时,这些代码没有安全点,使用不同的调用约定。在并发VM中,通常需要在进入和退出本地代码时设置屏障。如果VM需要暂停线程(如进行垃圾回收),会在屏障处暂停它。如果本地代码尝试访问托管堆,也会在屏障处被阻塞,直到GC完成。


Oracle实验室项目概览:Truffle与Graal

最后,我们来了解一下Oracle实验室中围绕Truffle和Graal进行的研究项目全景。

技术栈层次:

  1. 高级语言运行时:基于Truffle实现JavaScript、Ruby、R等语言的快速解释器/编译器。
  2. 语言实现技术:如动态对象模型、部分求值研究。
  3. 高级编译器构造:在Graal层进行的优化,如逃逸分析、锁消除、SSA形式、向量化等。
  4. 底层编译器构造:寄存器分配器、指令调度等,旨在为特定硬件(如SPARC)生成最优代码。

主要语言实现:

  • JavaScript:一个高度兼容且性能优异的实现。
  • Ruby:基于Truffle的Ruby实现,致力于兼容性和性能,并尝试用Ruby重写核心库以提高兼容性。
  • R:最具挑战性的实现之一。R语言语义复杂(所有数据都是向量、参数惰性求值、运算符可重定义、值不可变等),对优化系统压力巨大,但也存在巨大的优化潜力(可达百倍加速)。
  • C/LLVM位码:通过Truffle解释和编译C代码,旨在实现安全执行(如防止缓冲区溢出)以及更好地与动态语言集成(消除调用边界)。

Graal编译器:
Graal是一个用Java编写的高性能即时编译器,采用Apache 2.0许可证开源。

  • 核心目标:支持激进优化,适合包含大量守卫(guard)的动态语言图;拥有目前最好的逃逸分析(包括部分逃逸分析);采用模块化设计,易于添加自定义优化阶段。

相关资源与未来:

  • 简单语言:Truffle的示例语言,是学习和实验的良好起点。
  • 职业机会:Oracle实验室在多个地点招聘实习生和研究人员,涉及整个技术栈的各个层次。
  • 愿景:通过Truffle和Graal技术栈,降低语言实现门槛,提升动态语言性能,并促进多语言互操作。

总结

本节课中我们一起学习了多个主题:

  1. Truffle中的对象模型:了解了如何使用DynamicObject和形状(Shape)在Truffle中实现高效的动态对象,以及如何通过内联缓存优化属性访问。
  2. 底层代码生成:通过简单语言示例和汇编输出,观察了Truffle/Graal如何将高级节点编译优化为机器码,并认识了安全点等托管环境特有的指令。
  3. 性能剖析与推测优化:学习了如何使用编译器指令和值剖析在运行时进行推测优化,以显著提升热点路径的性能。
  4. 虚拟机并发:探讨了在VM中实现并发时面临的挑战、权衡和优化技术,包括调度、锁优化(轻量级锁、偏斜锁)以及并发环境下的代码管理难题。
  5. 项目概览:了解了Oracle实验室中基于Truffle和Graal的丰富项目生态,涵盖了从JavaScript、Ruby到C语言的多语言实现与优化研究。

这些内容展示了现代托管运行时和语言实现技术的深度与广度,以及如何通过即时编译和运行时优化来弥合动态语言的灵活性与高性能之间的鸿沟。

016:开发者工具与并发垃圾回收

概述

在本节课中,我们将学习开发者工具在编程语言生态系统中的重要性,以及并发垃圾回收(GC)的基本概念和挑战。课程内容分为两部分:首先,我们将从开发者工具的角度回顾整个课程,探讨如何构建高效、无妥协的工具;其次,我们将深入探讨并发垃圾回收的复杂性、分类以及实现中的关键问题。


第一部分:开发者工具的重要性与设计理念

上一节我们介绍了虚拟机与托管运行时的核心技术。本节中,我们来看看开发者工具在编程语言生态系统中的角色。

开发者工具的缺失与挑战

编程语言实现通常专注于性能优化,但工具支持往往被忽视。语言用户(即程序员)面临一个困境:他们需要调试器、性能分析器等工具来高效工作,但这些工具在语言实现初期常常缺失或不完善。

以下是开发者工具面临的典型挑战:

  • 优化与调试的冲突:编译器的目标是消除不必要的信息以优化性能,而调试器恰恰需要这些信息来重建执行状态。
  • 平台依赖性与可移植性:工具往往依赖于特定平台,缺乏可移植性。
  • 动态优化的复杂性:在运行时进行优化的环境中,跟踪和调试变得异常困难。
  • 思维模式的差异:性能工程师关注机器效率(让代码运行更快),而工具开发者关注人的效率(节省程序员时间)。这两种思维模式存在根本性的分歧。

构建无妥协工具的新思路

为了克服上述挑战,我们需要重新思考工具与运行时平台的集成方式。核心思想是将工具支持深度嵌入语言实现平台,使其成为优化过程的一部分,而非对立面。

两个关键理念是:

  1. 将工具钩子内置到平台中:在语言实现平台中直接构建支持工具(如调试事件)的机制。确保这些机制能够与优化协同工作,而不是阻碍优化。
  2. 构建语言无关的核心工具:开发提供相同基础服务的工具核心,使其适用于任何在平台上实现的语言。语言实现者只需提供少量额外信息,就能获得可工作的调试器或其他工具。

Truffle/Graal 平台的优势

Truffle/Graal 平台为实现上述理念提供了独特机会:

  • 清晰的执行模型:基本执行状态是一个用 Java 编写的 AST 解释器,所有数据结构(栈、帧等)都清晰可用。
  • 语言无关的优化:优化代码专注于树重写和特化,与语言无关。
  • 关键机制:去优化:平台为了自身目的(如推测优化)已经内置了去优化能力。工具可以利用这一内部机制来访问执行状态,而不是强加外部要求。这使得调试器等工具能够作为平台的参与者无缝集成。

在这种架构下,断点条件等特性可以被重写并反馈到运行时,经过充分优化后,其运行速度可以与周围代码一样快。当没有工具监听时,相关的框架代码可以被内联优化直至消失,从而实现接近零的运行时开销

工具生态系统的实践

基于上述理念,已经在 Truffle/Graal 上构建了一个工具生态系统:

  • 内置插装框架:该框架现已深度集成到代码库中。
  • 多种客户端工具
    • NetBeans IDE 集成:支持对 Truffle 实现的语言(包括 JavaScript、Ruby 等)进行跨语言调试。
    • 命令行调试器:一个类似 GDB 的命令行调试客户端,演示了调试 API 的使用。
    • King‘s College 的 Eco 编辑器:一个支持多语言编辑和可视化运行时分析(如代码覆盖率热图)的研究型编辑器。

第二部分:并发垃圾回收

上一节我们探讨了开发者工具的设计。本节中,我们将转向运行时系统的另一个核心课题:并发垃圾回收。

基本术语与分类

首先,明确几个关键术语:

  • 并行收集:多个垃圾回收线程并行工作,但在回收期间,所有用户线程(Mutator)必须被暂停。
  • 并发收集:垃圾回收线程与用户线程同时运行。

传统的“停止世界”(Stop-the-World)收集器在回收时会暂停所有应用线程。并行收集器通过使用多个回收线程来缩短暂停时间,但应用线程仍需在回收阶段被暂停。并发收集器的目标是进一步减少或消除这种暂停。

并行垃圾回收

在并行收集中,主要挑战在于效率,而非正确性,因为回收器与用户线程不会真正并发执行。

关键问题包括:

  • 线程挂起与恢复:需要高效地挂起所有用户线程,并在回收完成后及时恢复。这通常通过安全点机制实现,确保线程在可安全扫描其状态的位置被挂起。
  • 工作负载平衡:由于遍历的对象图形状未知,需要采用工作窃取等策略来平衡各回收线程的工作负载。
  • 内存访问局部性:遍历对象图会导致大量缓存未命中。可以通过预取等技术来缓解。

常见的并行回收算法包括并行标记、并行复制和并行清扫。其中,并行清扫可以与用户线程真正并发执行,因为它只处理已标记为死亡的对象。

并发垃圾回收

并发收集器允许回收线程与用户线程同时运行,这是实现低延迟应用的关键。然而,其正确性和实现复杂度急剧增加。

核心挑战

  1. 正确性:对象图在回收器遍历的同时被用户线程修改,极易出错。
  2. 效率:回收器必须能跟上用户线程的分配速度,避免内存耗尽。
  3. 屏障开销:需要在用户线程的读写操作中插入屏障代码,以维护回收器所需的不变式。这些代码必须非常轻量,否则会显著拖慢程序运行。
  4. 及时性:不能任由垃圾长期滞留,否则需要更大的堆空间。

三色抽象与不变式

并发回收算法通常基于三色抽象来推理:

  • 白色:尚未被回收器访问到的对象(可能存活,也可能死亡)。
  • 灰色:已被回收器发现,但其引用的对象尚未被扫描。
  • 黑色:已被回收器扫描完毕的对象。

回收过程就是从根集出发,将对象从白色染灰,再染黑的过程。并发修改可能导致一种典型的错误:用户线程将一个指向白色对象的引用存储到黑色对象中,同时从灰色对象到该白色对象的所有路径都被删除。如果回收器未能察觉,这个存活的白色对象就会被错误回收。

防止此错误需要维护以下两种不变式之一:

  • 强三色不变式:禁止黑色对象引用白色对象。任何导致此情况的写操作都必须先将白色对象着色(变灰或变黑)。
  • 弱三色不变式:允许黑色对象引用白色对象,但必须确保通过灰色对象最终能到达该白色对象(即不破坏所有路径)。

算法分类与示例

根据对用户线程(赋值器)的着色、新分配对象的颜色以及所使用的屏障类型,可以对并发算法进行分类。

一个经典的例子是 Baker 的算法(一种增量复制算法):

  • 回收开始时,将用户线程视为黑色。
  • 用户线程通过读屏障来保证正确性:每当它尝试读取一个指向“来自空间”的指针时,读屏障会拦截该操作,将被引用的对象复制到“目标空间”,并更新指针。这样,用户线程永远只看到“目标空间”的对象。
  • 用户线程实际上在驱动对象的复制。回收器则可以增量地进行复制工作。
  • 最大的暂停时间被限制在复制一个对象所需的时间内。

总结

本节课中我们一起学习了:

  1. 开发者工具的视角:认识到工具对于程序员生产率至关重要,并探讨了通过深度集成与平台协同优化来构建“全功能、无妥协”工具的新路径。
  2. 并发垃圾回收:理解了并发 GC 在减少应用暂停时间方面的价值,以及其实现面临的巨大挑战。我们学习了基于三色抽象的正确性模型,以及通过维护强/弱三色不变式来设计并发回收算法的基本方法。

无论是构建让程序员更高效的工具,还是实现不影响应用响应的垃圾回收器,核心都在于关注整个系统中人的因素与机器效率的平衡。

posted @ 2026-03-29 09:26  布客飞龙II  阅读(1)  评论(0)    收藏  举报