JVM笔记(八)高效编译(3)

1,101 阅读14分钟

五、即时编译器的中间表达形式

1. 中间表达形式(IR)

在编译原理课程中,我们通常将编译器分为前端和后端。其中,前端会对所输入的程序进行词法分析、语法分析、语义分析,然后生成中间表达形式,也就是 IR(Intermediate Representation )。后端会对 IR 进行优化,然后生成目标代码。

如果不考虑解释执行的话,从 Java 源代码到最终的机器码实际上经过了两轮编译:Java 编译器将 Java 源代码编译成 Java 字节码,而即时编译器则将 Java 字节码编译成机器码。

对于即时编译器来说,所输入的 Java 字节码剥离了很多高级的 Java 语法,而且其采用的基于栈的计算模型非常容易建模。因此,即时编译器并不需要重新进行词法分析、语法分析以及语义分析,而是直接将 Java 字节码作为一种 IR。

不过,Java 字节码本身并不适合直接作为可供优化的 IR。这是因为现代编译器一般采用静态单赋值(Static Single Assignment,SSA)IR。这种 IR 的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。

y = 1;
y = 2;
x = y;

举个例子,上面这段代码所对应的 SSA 形式伪代码是下面这段:

y1 = 1;
y2 = 2;
x1 = y2;

在源代码中,我们可以轻易地发现第一个对 y 的赋值是冗余的,但是编译器不能。传统的编译器需要借助数据流分析(具体的优化叫reaching definition),从后至前依次确认哪些变量的值被覆盖(kill)掉。

不过,如果借助了 SSA IR,编译器则可以通过查找赋值了但是没有使用的变量,来识别冗余赋值。

除此之外,SSA IR 对其他优化方式也有很大的帮助,例如常量折叠(constant folding)、常量传播(constant propagation)、强度削减(strength reduction)以及死代码删除(dead code elimination)等。

示例:
x1=4*1024 经过常量折叠后变为 x1=4096
x1=4; y1=x1 经过常量传播后变为 x1=4; y1=4
y1=x1*3 经过强度削减后变为 y1=(x1<<1)+x1
if(2>1){y1=1;}else{y2=1;}经过死代码删除后变为 y1=1

部分同学可能会手动进行上述优化,以期望能够达到更高的运行效率。实际上,对于这些简单的优化,编译器会代为执行,以便程序员专注于代码的可读性。

SSA IR 会带来一个问题,那便是不同执行路径可能会对同一变量设置不同的值。例如下面这段代码 if 语句的两个分支中,变量 y 分别被赋值为 0 或 1,并且在接下来的代码中读取 y 的值。此时,根据不同的执行路径,所读取到的值也很有可能不同。

x = ..;
if (x > 0) {
  y = 0;
} else {
  y = 1;
}
x = y;

为了解决这个问题,我们需要引入一个 Phi 函数的概念,能够根据不同的执行路径选择不同的值。于是,上面这段代码便可以转换为下面这段 SSA 伪代码。这里的 Phi 函数将根据前面两个分支分别选择 y1、y2 的值,并赋值给 y3。

x1 = ..;
if (x1 > 0) {
  y1 = 0;
} else {
  y2 = 1;
}
y3 = Phi(y1, y2);
x2 = y3;

总之,即时编译器会将 Java 字节码转换成 SSA IR。更确切的说,是一张包含控制流和数据流的 IR 图,每个字节码对应其中的若干个节点(注意,有些字节码并没有对应的 IR 节点)。然后,即时编译器在 IR 图上面进行优化。

我们可以将每一种优化看成一个独立的图算法,它接收一个 IR 图,并输出经过转换后的 IR 图。整个编译器优化过程便是一个个优化串联起来的。

2. Sea-of-nodes

HotSpot 里的 C2 采用的是一种名为 Sea-of-Nodes 的 SSA IR。它的最大特点,便是去除了变量的概念,直接采用变量所指向的值,来进行运算。

在上面这段 SSA 伪代码中,我们使用了多个变量名 x1、x2、y1 和 y2。这在 Sea-of-Nodes 将不复存在。

取而代之的则是对应的值,比如说 Phi(y1, y2) 变成 Phi(0, 1),后者本身也是一个值,被其他 IR 节点所依赖。正因如此,常量传播在 Sea-of-Nodes 中变成了一个 no-op。

Graal 的 IR 同样也是 Sea-of-Nodes 类型的,并且可以认为是 C2 IR 的精简版本。由于 Graal 的 IR 系统更加容易理解,而且工具支持相对来说也比较全、比较新,所以下面我将围绕着 Graal 的 IR 系统来讲解。

尽管 IR 系统不同,C2 和 Graal 所实现的优化大同小异。对于那小部分不同的地方,它们也在不停地相互“借鉴”。所以你无须担心不通用的问题。

为了方便你理解今天的内容,我将利用 IR 可视化工具Ideal Graph Visualizer(IGV),来展示具体的 IR 图。(这里 Ideal 是 C2 中 IR 的名字。)

public static int foo(int count) {
  int sum = 0;
  for (int i = 0; i < count; i++) {
    sum += i;
  }
  return sum;
}

IR 图: 这里面,0 号 Start 节点是方法入口,21 号 Return 节点是方法出口。红色加粗线条为控制流,蓝色线条为数据流,而其他颜色的线条则是特殊的控制流或数据流。被控制流边所连接的是固定节点,其他的皆属于浮动节点。若干个顺序执行的节点将被包含在同一个基本块之中,如图中的 B0、B1 等。

基本块直接的控制流关系: 基本块是仅有一个入口和一个出口的指令序列(IR 节点序列)。一个基本块的出口可以和若干个基本块的入口相连接,反之亦然。

在我们的例子中,B0 和 B2 的出口与 B1 的入口连接,代表在执行完 B0 或 B2 后可以跳转至 B1,并继续执行 B1 中的内容。而 B1 的出口则与 B2 和 B3 的入口连接。

可以看到,上面的 IR 图已经没有 sum 或者 i 这样的变量名了,取而代之的是一个个的值,例如源程序中的 i<count 被转换为 10 号 < 节点,其接收两个值,分别为代表 i 的 8 号 Phi 节点,以及代表输入第 0 个参数的 1 号 P(0) 节点。

关于 8 号 Phi 节点,前面讲过,它将根据不同的执行路径选择不同的值。如果是从 5 号 End 节点进入的,则选择常量 0;如果是从 20 号 LoopEnd 节点跳转进入的,则选择 19 号 + 节点。

你可以自己分析一下代表 sum 的 7 号 Phi 节点,根据不同的执行路径都选择了哪些值。

浮动节点的位置并不固定。在编译过程中,编译器需要(多次)计算浮动节点具体的排布位置。这个过程我们称之为节点调度(node scheduling)。

节点调度是根据节点之间的依赖关系来进行的。举个例子,在前面的 IR 图中,10 号 < 节点是 16 号 if 节点用来判断是否跳转的条件,因此它需要排布在 16 号 if 节点(注意这是一个固定节点)之前。同时它又依赖于 8 号 Phi 节点的值以及 1 号 P(0) 节点的值,因此它需要排布在这两个节点之后。

需要注意的是,C2 没有固定节点这一概念,所有的 IR 节点都是浮动节点。它将根据各个基本块头尾之间的控制依赖,以及数据依赖和内存依赖,来进行节点调度。

这里的内存依赖是什么一个概念呢?假设一段程序往内存中存储了一个值,而后又读取同一内存,那么显然程序希望读取到的是所存储的值。即时编译器不能任意调度对同一内存地址的读写,因为它们之间存在依赖关系。

C2 的做法便是将这种时序上的先后记录为内存依赖,并让节点调度算法在进行调度时考虑这些内存依赖关系。Graal 则将内存读写转换成固定节点。由于固定节点存在先后关系,因此无须额外记录内存依赖。

3. Gloval Value Numbering

下面介绍一种因 Sea-of-Nodes 而变得非常容易的优化技术 —— Gloval Value Numbering(GVN)。

GVN 是一种发现并消除等价计算的优化技术。举例来说,如果一段程序中出现了多次操作数相同的乘法,那么即时编译器可以将这些乘法并为一个,从而降低输出机器码的大小。如果这些乘法出现在同一执行路径上,那么 GVN 还将省下冗余的乘法操作。

在 Sea-of-Nodes 中,由于只存在值的概念,因此 GVN 算法将非常简单:如果一个浮动节点本身不存在内存副作用(由于 GVN 可能影响节点调度,如果有内存副作用的话,那么将引发一些源代码中不可能出现的情况) ,那么即时编译器只需判断该浮动节点是否与已存在的浮动节点的类型相同,所输入的 IR 节点是否一致,便可以将这两个浮动节点归并成一个。

public static int foo(int a, int b) {
    int sum = a * b;
    if (a > 0) {
        sum += a * b;
    }
    if (b > 0) {
        sum += a * b;
    }
    return sum;
}

我们来看一个实际的案例。在上面这段代码中,如果 a 和 b 都大于 0,那么我们需要做三次乘法。通过 GVN 之后,我们只会在 B0 中做一次乘法,并且在接下来的代码中直接使用乘法的结果,也就是 4 号 * 节点所代表的值。 我们可以将 GVN 理解为在 IR 图上的公共子表达式消除(Common Subexpression Elimination,CSE)。

这两者的区别在于,GVN 直接比较值的相同与否,而 CSE 则是借助词法分析器来判断两个表达式相同与否。因此,在不少情况下,CSE 还需借助常量传播来达到消除的效果。

六、Java字节码(基础篇)

操作数栈

Java 字节码是 Java 虚拟机所使用的指令集。因此,它与 Java 虚拟机基于栈的计算模型是密不可分的。

在解释执行过程中,每当为 Java 方法分配栈桢时,Java 虚拟机往往需要开辟一块额外的空间作为操作数栈,来存放计算的操作数以及返回结果。

具体来说便是:执行每一条指令之前,Java 虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java 虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中。 以加法指令 iadd 为例。假设在执行该指令前,栈顶的两个元素分别为 int 值 1 和 int 值 2,那么 iadd 指令将弹出这两个 int,并将求得的和 int 值 3 压入栈中。 由于 iadd 指令只消耗栈顶的两个元素,因此,对于离栈顶距离为 2 的元素,即图中的问号,iadd 指令并不关心它是否存在,更加不会对其进行修改。

Java 字节码中有好几条指令是直接作用在操作数栈上的。最为常见的便是 dup: 复制栈顶元素,以及 pop:舍弃栈顶元素。

dup 指令常用于复制 new 指令所生成的未经初始化的引用。例如在下面这段代码的 foo 方法中,当执行 new 指令时,Java 虚拟机将指向一块已分配的、未初始化的内存的引用压入操作数栈中。

  public void foo() {
    Object o = new Object();
  }
  // 对应的字节码如下:
  public void foo();
    0  new java.lang.Object [3]
    3  dup
    4  invokespecial java.lang.Object() [8]
    7  astore_1 [o]
    8  return

接下来,我们需要以这个引用为调用者,调用其构造器,也就是上面字节码中的 invokespecial 指令。要注意,该指令将消耗操作数栈上的元素,作为它的调用者以及参数(不过 Object 的构造器不需要参数)。

因此,我们需要利用 dup 指令复制一份 new 指令的结果,并用来调用构造器。当调用返回之后,操作数栈上仍有原本由 new 指令生成的引用,可用于接下来的操作(即偏移量为 7 的字节码,下面会介绍到)。

pop 指令则常用于舍弃调用指令的返回结果。例如在下面这段代码的 foo 方法中,我将调用静态方法 bar,但是却不用其返回值。

由于对应的 invokestatic 指令仍旧会将返回值压入 foo 方法的操作数栈中,因此 Java 虚拟机需要额外执行 pop 指令,将返回值舍弃。

  public static boolean bar() {
    return false;
  }
 
  public void foo() {
    bar();
  }
  // foo 方法对应的字节码如下:
  public void foo();
    0  invokestatic FooTest.bar() : boolean [24]
    3  pop
    4  return

需要注意的是,上述两条指令只能处理非 long 或者非 double 类型的值,这是因为 long 类型或者 double 类型的值,需要占据两个栈单元。当遇到这些值时,我们需要同时复制栈顶两个单元的 dup2 指令,以及弹出栈顶两个单元的 pop2 指令。

除此之外,不算常见但也是直接作用于操作数栈的还有 swap 指令,它将交换栈顶两个元素的值。

在 Java 字节码中,有一部分指令可以直接将常量加载到操作数栈上。以 int 类型为例,Java 虚拟机既可以通过 iconst 指令加载 -1 至 5 之间的 int 值,也可以通过 bipush、sipush 加载一个字节、两个字节所能代表的 int 值。

Java 虚拟机还可以通过 ldc 加载常量池中的常量值,例如 ldc #18 将加载常量池中的第 18 项。

这些常量包括 int 类型、long 类型、float 类型、double 类型、String 类型以及 Class 类型的常量。

常数加载指令表: 正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java 虚拟机会清除操作数栈上的所有内容,而后将异常实例压入操作数栈上。

局部变量区

Java 方法栈桢的另外一个重要组成部分则是局部变量区,字节码程序可以将计算的结果缓存在局部变量区之中。

实际上,Java 虚拟机将局部变量区当成一个数组,依次存放 this 指针(仅非静态方法),所传入的参数,以及字节码中的局部变量。

和操作数栈一样,long 类型以及 double 类型的值将占据两个单元,其余类型仅占据一个单元。

public void foo(long l, float f) {
  {
    int i = 0;
  }
  {
    String s = "Hello, World";
  }
}

以上面这段代码中的 foo 方法为例,由于它是一个实例方法,因此局部变量数组的第 0 个单元存放着 this 指针。

第一个参数为 long 类型,于是数组的 1、2 两个单元存放着所传入的 long 类型参数的值。第二个参数则是 float 类型,于是数组的第 3 个单元存放着所传入的 float 类型参数的值。 在方法体里的两个代码块中,我分别定义了两个局部变量 i 和 s。由于这两个局部变量的生命周期没有重合之处,因此,Java 编译器可以将它们编排至同一单元中。也就是说,局部变量数组的第 4 个单元将为 i 或者 s。

存储在局部变量区的值,通常需要加载至操作数栈中,方能进行计算,得到计算结果后再存储至局部变量数组中。这些加载、存储指令是区分类型的。例如,int 类型的加载指令为 iload,存储指令为 istore。

局部变量区访问指令表: 局部变量数组的加载、存储指令都需要指明所加载单元的下标。举例来说,aload 0 指的是加载第 0 个单元所存储的引用,在前面示例中的 foo 方法里指的便是加载 this 指针。

在我印象中,Java 字节码中唯一能够直接作用于局部变量区的指令是 iinc M N(M 为非负整数,N 为整数)。该指令指的是将局部变量数组的第 M 个单元中的 int 值增加 N,常用于 for 循环中自增量的更新。

  public void foo() {
    for (int i = 100; i>=0; i--) {}
  }
  // 对应的字节码如下:
  public void foo();
     0  bipush 100
     2  istore_1 [i]
     3  goto 9
     6  iinc 1 -1 [i] // i--
     9  iload_1 [i]
    10  ifge 6
    13  return

综合示例

下面我们来看一个综合的例子:

public static int bar(int i) {
  return ((i + 1) - 2) * 3 / 4;
}
// 对应的字节码如下:
Code:
  stack=2, locals=1, args_size=1
     0: iload_0
     1: iconst_1
     2: iadd
     3: iconst_2
     4: isub
     5: iconst_3
     6: imul
     7: iconst_4
     8: idiv
     9: ireturn

这里我定义了一个 bar 方法。它将接收一个 int 类型的参数,进行一系列计算之后再返回。

对应的字节码中的 stack=2, locals=1 代表该方法需要的操作数栈空间为 2,局部变量数组空间为 1。当调用 bar(5) 时,每条指令执行前后局部变量数组空间以及操作数栈的分布如下:

Java 字节码简介

前面我已经介绍了加载常量指令、操作数栈专用指令以及局部变量区访问指令。下面我们来看看其他的类别。

Java 相关指令,包括各类具备高层语义的字节码,即 new(后跟目标类,生成该类的未初始化的对象),instanceof(后跟目标类,判断栈顶元素是否为目标类 / 接口的实例。是则压入 1,否则压入 0),checkcast(后跟目标类,判断栈顶元素是否为目标类 / 接口的实例。如果不是便抛出异常),athrow(将栈顶异常抛出),以及 monitorenter(为栈顶对象加锁)和 monitorexit(为栈顶对象解锁)。

此外,该类型的指令还包括字段访问指令,即静态字段访问指令 getstatic、putstatic,和实例字段访问指令 getfield、putfield。这四条指令均附带用以定位目标字段的信息,但所消耗的操作数栈元素皆不同。 以 putfield 为例,在上图中,它会把值 v 存储至对象 obj 的目标字段之中。

方法调用指令,包括 invokestatic,invokespecial,invokevirtual,invokeinterface 以及 invokedynamic。这几条字节码我们已经反反复复提及了,就不再具体介绍各自的含义了。

除 invokedynamic 外,其他的方法调用指令所消耗的操作数栈元素是根据调用类型以及目标方法描述符来确定的。在进行方法调用之前,程序需要依次压入调用者(invokestatic 不需要),以及各个参数。

  public int neg(int i) {
    return -i;
  }
 
  public int foo(int i) {
    return neg(neg(i));
  }
  // foo 方法对应的字节码如下:foo 方法对应的字节码如下:
  public int foo(int i);
    0  aload_0 [this]
    1  aload_0 [this]
    2  iload_1 [i]
    3  invokevirtual FooTest.neg(int) : int [25]
    6  invokevirtual FooTest.neg(int) : int [25]
    9  ireturn

以上面这段代码为例,当调用 foo(2) 时,每条指令执行前后局部变量数组空间以及操作数栈的分布如下所示: 数组相关指令,包括新建基本类型数组的 newarray,新建引用类型数组的 anewarray,生成多维数组的 multianewarray,以及求数组长度的 arraylength。另外,它还包括数组的加载指令以及存储指令。这些指令是区分类型的。例如,int 数组的加载指令为 iaload,存储指令为 iastore。

数组访问指令表: 控制流指令,包括无条件跳转 goto,条件跳转指令,tableswitch 和 lookupswtich(前者针对密集的 cases,后者针对稀疏的 cases),返回指令,以及被废弃的 jsr,ret 指令。其中返回指令是区分类型的。例如,返回 int 值的指令为 ireturn。

返回指令表: 除返回指令外,其他的控制流指令均附带一个或者多个字节码偏移量,代表需要跳转到的位置。例如下面的 abs 方法中偏移量为 1 的条件跳转指令,当栈顶元素小于 0 时,跳转至偏移量为 6 的字节码。

  public int abs(int i) {
    if (i >= 0) {
      return i;
    }
    return -i;
  }
  // 对应的字节码如下所示:
  public int abs(int i);
    0  iload_1 [i]
    1  iflt 6
    4  iload_1 [i]
    5  ireturn
    6  iload_1 [i]
    7  ineg
    8  ireturn