目录

说说 JVM 的 StackMapTable 检验

之后文章会全部转到飞书文档发布,此网页可能存在更新不及时的问题,飞书文档首页 zsqw123 Homepage

说说 JVM 的 StackMapTable 检验

前言

提示,使用飞书文档阅读体验更好:https://eqyrx3fg3l.feishu.cn/docx/T4eMdHy46opEiNxsmrzcryFbn0g

StackMapTable 是我认为 Java 字节码中最为复杂和难以理解的结构。在我看过的大部分对 Java 字节码结构的分析中,大多数都喜欢讲 Class、Method、Field 结构,讲 Const pool 结构等。而对于 Attribute 则是喜欢草草提一嘴,或者说只讲一些简单的 Attribute 以及 Code Attribute,对于我认为最复杂的 Attribute 结构:StackMapTable,网上的文字是不多的,我能找到最详细的描述就是 R 大的一些论坛回复,本文也希望能够展示我自己的理解,提供对于这一 Attribute 更多的理解思路。

StackMapTable 是 Java 6(Class 版本 50) 时代 JSR 202 引入的一个新 Attribute,并在 Java 7 变为强制实现,理论上它能够给字节码验证带来更大的性能收益:

The main difference is the introduction of a new verification scheme based on type checking rather than type inference. This scheme has inherent advantages in performance (both space (on the order of 90% savings) and time (approximately 2x)) over the previous approach. It is also simpler and more robust, and helps pave the way for future evolution of the platform.

官方说法是,加入这玩意能在字节码检验阶段,相对与过去 Type Inference 的方案(< Java6),带来 90% 的 RAM 空间节省和 2x 的速度提升。不过事实上这种收益有待进一步的论证,有如下原因:

  • Class 加载的耗时大户在 IO 操作而非真正的验证
  • Class 加载在整个应用程序生命周期仅执行一次
  • 它事实上牺牲了 Java 字节码的简洁性,著名的字节码操作工具 ASM 里面也有上千行针对其的特殊适配(甚至适配也不是 100% 完全 = =),同时也算是用字节码的文件占用空间换时间的一种提前计算的手段

过去 Type Inference 如何检查字节码

字节码验证做了很多部分,为了保证文章主题的聚焦,这里仅讨论对 Code Attribute 的数据流分析验证。

想要了解现在 StackMapTable 为什么要这么设计,如何带来的空间和性能收益,我们首先要了解过去的实现是怎样的,在 Java 6 之前,Code 包含的每一条 instruction 都要进行数据流分析,并且在分析时保存当前实时的堆栈信息,并在类型不匹配,或堆栈不平衡等错误情况出现时,抛出 Class 的 VerifyError,避免错误代码的执行。

上面这句话说起来简单,但具体还有需要要解释的细节:

Code 如何执行

我们首先需要了解 Code 块是如何执行,从而才能知道如何验证其是否能执行。

熟悉字节码规范或操作过字节码的同学都是知道的,Method 结构体包含一个 max_locals 和 max_stack,他们代表了在执行流程中最大会用到的 local variable (也可以叫做寄存器,在 DVM 字节码中的表现就是寄存器)的数量和最深的操作数栈(operand stack)的深度,之所以记录这个值就是为了在方法开始时分配这俩东西的空间。

在每个 Code 块被执行时,local variable 会根据方法签名分配对应的 local variables,操作数栈初始值为 0,比如对于下面的方法,在最开始分配的 local variables 就是两个槽 [Foo this, int a]

1
2
class Foo {
    void test(int a) {

对于非静态方法,local variables 的第一个槽保存的内容就是 this,随后依次追加方法传入的参数到 local variables 中。

接下来,Code 会按照每个 instruction 的规则(具体去看 JVMS)来决定对 local variables、operand stack 改动,同时也可能包含一些方法跳转等操作,下面是一个简单的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class Foo {
    void test(int a) {
        int b = 2;
        if (a > 0) {
            staticCall(a);
        } else {
            virtualCall(b);
        }
    }

    static void staticCall(int v) {}
    void virtualCall(int v) {}
}

test 方法对应的字节码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 0 iconst_2
 1 istore_2
 2 iload_1
 3 ifle 13
 6 iload_1
 7 invokestatic #2 <com/zsu/asm/Foo.staticCall : (I)V>
10 goto 18
13 aload_0
14 iload_2
15 invokevirtual #3 <com/zsu/asm/Foo.virtualCall : (I)V>
18 return

每条 locals 和 stack 的变化表如下:

指令 locals stack 解释
Initial State [Foo this, int a] []
0 iconst_2 [Foo this, int a] [int 2] 入栈常数 2
1 istore_2 [Foo this, int a, int b] [] 常数 2 转移到索引为 2 的 local variable(b)
2 iload_1 [Foo this, int a, int b] [int a] 读取 a 的值入栈
3 ifle 13 [Foo this, int a, int b] [] 如果栈顶值 ≤0,跳转到序号 13 的指令,否则继续执行后续指令
6 iload_1 [Foo this, int a, int b] [int a] 读取 a 的值入栈
7 invokestatic staticCall [Foo this, int a, int b] [] 将 a 作为参数传递给 staticCall
10 goto 18 [Foo this, int a, int b] [] 调用完成,跳转 return
13 aload_0 [Foo this, int a, int b] [Foo this] 入栈 this
14 iload_2 [Foo this, int a, int b] [Foo this, int b] 读取 b 的值入栈
15 invokevirtual virtualCall [Foo this, int a, int b] [] 使用 this 和 b 作为参数,调用 virtualCall
18 return [Foo this, int a, int b] []

每个指令具体会执行什么操作请详细参见:Chapter 6. The Java Virtual Machine Instruction Set

验证条件

详细也可以移步 Oracle 官方文档:4.10. Verification of class Files,这里仅简单说一些和 Code 检验相关的:

  1. 类型匹配。Java 是个强类型语言,JVM 也是强类型的,JVM 会检验 invokeXXX / set_field 之类语句的 argument 的类型(即 operand stack 上的类型)是否符合对应的函数需要的类型。
  2. 不发生栈溢出或下溢。比如在上面例子中,istore 时,如果存的位置超过 max_locals(溢出)则报错,又如 invokeVirtual 时,函数要求弹出 2 个堆栈,如果实际上堆栈只有 1 个元素(下溢)则报错
  3. 所有的 Instruction 都需要具有正确的参数列表,以及像 table_switch 等这些语句也要有正确的偏移量。
  4. 指针需要跳转到正确的位置:比如 goto 18 跳转到的是一个正确的地址。
  5. 控制流能够正常合并:比如前往 18 return 的有两条途径,一种是通过 10 goto 18 前往,另一种则是 3 ifle 13 之后,函数正常向下执行到 18 return。字节码验证要保证不管走了哪条途径,走到 18 return 的时候,locals 和 stack 是一致的或能够正常合并(后文数据流分析会解释合并)
  6. exception handler 所保护的代码范围必须是有效的。
  7. ……

需要注意的是,为保证效率,部分类的加载和验证流程会延迟到代码真正调用时,比如一个方法中出现了对 A 的 new 实例化操作,此时编译器不会花费时间去检验 A 是否存在与合理,但如果 A 实例赋值给了 B 类型的字段,此时验证器就需要确保 A 是 B 的子类。

Type Inference 是如何进行的

与执行字节码指令类似,不过执行时往往只会走到一些特定的路径,而验证需要尝试所有可能的路径,无论从哪个路径到达该点,都必须满足上面所说的验证条件(上面仅列举了一些,并非全部)

在官方的虚拟机规范文档中,使用了“变更位”(changed bit)来实现这个流程,这里我暂时忽略掉这个概念,感兴趣的可以查看官方规范文档。

  1. 设置初始状态,通过是否为静态方法,以及方法签名,推导出 locals

  2. 迭代分析直线代码(即不包含任何跳转逻辑的代码)是否满足验证条件,确保每个指令对 locals 以及 stack 的操作是安全的,比如不会类型匹配、参数列表匹配、不发生栈溢出或下溢等。并在继续执行下一条指令的分析之前,将当前指令对 locals、stack 数量或类型的影响附加上去。

  3. 分支跳转处理,对于直线代码来说,上一步指令加上指令影响就能得到对 locals 和 stack 的影响,但遇到存在分支跳转的指令(如 GOTO、IFEQ、IFLE 等)时,上一步的来源是不确定的,需要穷举所有上一步指令可能的 locals 和 stack,这里涉及到几个处理:

    1. locals 合并与 stack 合并:由于上一步指令存在多种情况,他们对 locals 和 stack 的影响也都不一定相同,因此合并后的分支需要对 locals 和 stack 中全部的类型进行合并(当然,如果数量不同的话会直接无法合并从而验证失败,我们也称其为栈不平衡,因此这里仅描述类型合并):

      1. 原始类型需要完全匹配,如果两个分支各自的 stack 为 [int] 和 [long],那么验证会直接失败。

      2. 引用类型会寻找最小公共超类型(first common supertype),如 String 和 Object 的合并结果是 Object,这一步理论上不会出现没有公共 supertype 的,毕竟 Java 类都继承 Object,不过合并后的类型可能会导致后续调用方法时传入了一个 Object,可能不是方法实际所需的,这时候字节码验证也会拒绝这段代码。

      https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/1a77c75fb2144715b8ac4695cd42169f~tplv-73owjymdk6-watermark.image?policy=eyJ2bSI6MywidWlkIjoiMjYwNDQwMTA1OTUyNTYifQ%3D%3D&rk3s=f64ab15b&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018&x-orig-expires=1721580002&x-orig-sign=NZq5tMK83xEcs3faIKkDz9zC1HE%3D

    2. 固定点迭代(Fixed Point Iteration):在 Java 的 while 或 for loop 中,一系列的指令常常会被执行多次,由于字节码验证需要走遍所有可能的分支,因此这表现出的情况就是许多 IFEQ 之类的跳转指令分支,在验证流程中被多次迭代,但事实上并不会这样无止境的进行下去。通过固定点迭代的算法,在每次循环后如果遇到了相同的指令,locals 和 stack 会进行合并操作,并且保存合并后的状态供下次使用。迭代会持续进行到 locals 和 stacks 的类型不再改变为止。

  4. 处理特殊指令

    1. 使用 new 创建实例时,由于 Java 的 new 并不是一条指令直接完成的,其涉及多条指令,因此验证器需要保证所有对于实例的获取比如在对象初始化完成之后,即 invokespecial <init> 执行完成之后

      1
      
          new MyClass(i, j, k);
      
      1
      2
      3
      4
      5
      6
      7
      
          // bytecode ↓
          new MyClass // allocate uninitialized space for My Class
          dup         // dup object for invokespecial call
          iload_1     // push i
          iload_2     // push j
          iload_3     // push k
          invokespecial MyClass.<init>()
      
    2. long / double 在 stack 和 locals 中都是占两个槽位的,因此要确保这些槽位不会被部分访问或部分写入,比如只能使用 dadd 这些字节码,对于类型无关的指令,也需要将 long 或 double 类型的槽位视为不可分割的整体,比如不能使用 pop / dup 这些指令,只能使用 pop2 / dup2

    3. 子程序调用,为了实现 try-finally 的结构,在 Class 版本号小于 50 的 Java bytecode 中,可以使用两种特殊指令 jsrret 来实现,不过 Java 编译器很早就不使用这样的方式来实现了,如果你看过 ASM 对于 JSR 和 RET 的 Frame 生成实现,你会发现 ASM 根本没实现:

      1
      2
      3
      
        case Opcodes.JSR:
        case Opcodes.RET:
          throw new IllegalArgumentException("JSR/RET are not supported with computeFrames option");
      

      不过 ASM 提供了对 JSR 这些指令 inline 功能,也算是能解决一部分问题。这俩指令在阅读规范后,看起来和 goto 差不多,不过 goto 就回不去了,jsr 是创了一个子程序并保存 returnAddress,执行完子程序还能回到原来栈上。字节码要检验的就是,如果将这段程序 inline 回到主程序中是否能保证通过检验,即需要找到所有可到达该指令的 jsr 并进行检查,因此这个验证过程也是较为消耗时间的。

所以总结一下,Type Inference 的缺点就是其在遇到条件分支时,需要遍历所有可能的分支进行类型合并与检查,并使用固定点迭代来对可能出现的循环做处理。所以这种方式十分消耗 CPU 和 RAM,我们需要在跳转时记录下全部 Instruction 的 stack 和 locals 状态,同时还要执行类型合并(即 Type Inference)算法。此外,子程序调用、new 指令创建实例的安全性也让这部分的检查效率进一步降低。

新方案下的字节码检验方式

我们在前文提到,对于直线代码的分析是简单的,只要一个个线性的操作 locals 和 stack 即可,但对于包含跳转指令的分析,就需要比对全部可能的上一条指令进行合并与验证,这消耗了较大的 CPU 时间和 RAM。

在新方案下,字节码中引入了 Frame 的新结构,它主要用于储存在每个跳转分支开始时的 locals 和 stack 的数量与类型,其实就是将验证流程的部分结果储存到 Frame 结构中。

相比过去的方案,编译器不再需要合并与推导类型,编译器只需要验证他们是否符合 Frame 中的记录即可,比如说对于两个不同分支 stack 分别为 [String] [Object] 的,过去需要算出他们公共类型是 Object(多叉树寻找公共祖先问题),而现在只需要验证 String 和 Object 都是 Object 或其子类即可,时间和空间复杂度都得到了大幅度的降低。这里其实也让我想到了 P = NP 问题,验证很简单,但求解很复杂 :)

下面就来介绍一下这个 StackMapTable 的结构。

StackMapTable

StackMapTable 是 Code 的一个 Attribute,它包含多个 stack_map_frame(也就是上文中的简称:Frame):

1
2
3
4
5
6
StackMapTable_attribute {
    u2              attribute_name_index;
    u4              attribute_length;
    u2              number_of_entries;
    stack_map_frame entries[number_of_entries];
}

full_frame

1
2
3
4
5
6
7
8
full_frame {
    u1 frame_type = FULL_FRAME; /* 255 */
    u2 offset_delta;
    u2 number_of_locals;
    verification_type_info locals[number_of_locals];
    u2 number_of_stack_items;
    verification_type_info stack[number_of_stack_items];
}

我们先介绍最简单的 full_frame,它就如我们上文所说,这里记录了 locals 和 stack 的信息,另外还包含 offset_delta 用来表示跳转的目标指令位置与上次 frame 中记录的 offset_delta 的 offset + 1,如果是第一个 frame,那么就是相对于 Code 块开始位置的偏移。

而对于 verification_type_info,它表示保存的是什么类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
union verification_type_info {
    Top_variable_info;
    Integer_variable_info;
    Float_variable_info;
    Long_variable_info;
    Double_variable_info;
    Null_variable_info;
    UninitializedThis_variable_info;
    Object_variable_info;
    Uninitialized_variable_info;
}

需要注意:

  • Integer、Float 等这些基础类型表示的事实上是其基础类型的 type info 而非包装类型
  • 对于包装类型,会使用 Object 索引到常量表中对应的类信息
  • 上文提到,每个 Long、Double 会占用两个槽位,因此对于这俩类型,verification types 的第一个槽位就填充 Long,另一个槽位就填充 Top_variable_info,后面我们的示例中也能够看到
  • 对于初始化还未完成的类型,则有 Uninitialized 的类型标记,其中包含 new 指令的位置,初始化完成后就会正常变成 Object 类型,当企图使用未初始化的类型调用非 的方法时,bytecode 验证就会出错。

下面以一个具体的例子来展示这些类型,对应的 locals(第一个列表)和 stack(第二个列表) 标注在对应的跳转语句之后:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ShowFrameSample {
    public void test(int a, Long b) {
    // initial state [ShowFrameSample, int, java/lang/Long] []
        var c = a + 10;
        var s = "";
        if (a > b) {
        // [ShowFrameSample, int, java/lang/Long, int, java/lang/String] []
            System.out.println(c);
        } else {
        // [ShowFrameSample, int, java/lang/Long, int, java/lang/String] []
            long d1 = b - a;
            if (d1 > 0) {
            // [ShowFrameSample, int, java/lang/Long, int, java/lang/String, long, top] []
                System.out.println(d1);
            } else {
            // [ShowFrameSample, int, java/lang/Long, int, java/lang/String, long, top] []
                System.out.println(a);
            }
        }
        System.out.println();
    }
}

从上面的例子中我们能看到:

  • 初始态由函数签名计算得来
  • stack 在跳转后通常都是空的
  • long 后面会紧接着 top

但这里也看到了,如果全部使用这样的 full_frame 会导致字节码体积暴增,这一堆类型记录体积感觉不小,于是 Frame 也有一些的压缩策略:

  • 对于直落分支,不进行记录:直落分支并不会产生 frame 的变化,比如对于下面这样的 bytecode,ifle 判断条件成功,执行跳转到的 13 位置,就需要编写 frame,而对于 offset 为 6 的位置,其位于 offset 3 的下一条指令,locals 和 stack 都是不会有任何变化的,没有必要在此处额外记录 frame,因为没有发生跳转。

    1
    2
    3
    4
    5
    
     3 ifle 13
     6 iload_1
     7 invokestatic #2 <com/zsu/asm/Foo.staticCall : (I)V>
    10 goto 18
    13 aload_0
    
  • Frame 可以进行增量记录,对常用的 locals 操作抽象为专门的 frame_type:见下文其余的 frame_type

  • stack 在跳转的时候通常都是空的,可以抽象几种 stack 是 empty 的 frame_type,之所以 stack 通常都是空的,是因为在 Java 语言中,跳转语句不能作为表达式使用,比如下面的语句调用在 Java 中就是不可以的(不过三目运算符也会)

    1
    
    callSth(a, if (b > 0) { doSomthing(); 2 } else { doSomthing(); 3 });
    

      在上面这个语句中,我们将变量 a 入栈,此时 if 体内看到的 stack 就是不为空的。不过当你尝试在 Java 中这么写的时候,编译器就会提示 Array initializer is not allowed here,不过这种写法在 Kotlin 中是很常见的。

接下来我们就来解释 bytecode 中这么多 frame_type 的作用和具体用途,并说说我对他们为什么存在的想法,之所以有这么多的 frame_type,是为了简化字节码的结构,能不用 extend 就不用,能不用 full_frame 就不用,简化在大多数场景下的 frame 结构体大小。

same_frame(_extended)

1
2
3
same_frame {
    u1 frame_type = SAME; /* 0-63 */
}

frame_type 在 0~63 代表当前 frame 的类型是 same_frame,并且其 offset data 就是 frame_type 的值

我刚开始看到 same frame, 我原本以为这玩意能让 locals 和 stack 和上一个 frame 都一样,但事实上我想多了,它的意思是 locals 和上一个 frame 相同,但是 stack 为空。

stack 为空是 Java 中非常常见的情况,因此后续的很多指令也都喜欢默认 locals 为空。我认为最主要的原因就是在 Java 中,通常指令跳转时,一个方法调用已经完成,不过如果你用了三目运算符的话就不是这样了:

比如这样的 Java 代码会退化到使用 full_frame(foo 是个成员函数):

1
foo(d1, d1 > 0 ? 1 : 2);

这里只有 foo 是成员函数的时候,会触发三目运算符 fallback 到 full_frame,因为此时方法已经入栈了 [this, d1] 两个对象,而对于深度为 2 的栈,没有能够直接记录的 frame_type。

这里我们也可以看看 Kotlin 编译后的结果,也是全退化成了 full_frame:

https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/1ba07938eb3c483ba7e8b45074d797ff~tplv-73owjymdk6-watermark.image?policy=eyJ2bSI6MywidWlkIjoiMjYwNDQwMTA1OTUyNTYifQ%3D%3D&rk3s=f64ab15b&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018&x-orig-expires=1721580002&x-orig-sign=VRQu5txB72n6qNnUDOxJSDn2nd8%3D

所以对于调用传参,我个人建议还是多开辟个 local variable 吧,尤其是是对于 Kotlin 这种不止三目运算符的语言,内部可以有更复杂的结构,而内部的全部跳转也都记录成了 full_frame,此时记录 frame 就可能会多不少 bytes,更重要的是代码嵌套变得更深了,让我这个“无嵌套”主义者看着很难受。


此外,对于 same_frame_extended 以及之后其他的以 _extended 结尾的,其用途就是当遇到了较大的 offset 时(超过 0~63 的范围),使用额外的 offset_delta 来记录。

1
2
3
4
same_frame_extended {
    u1 frame_type = SAME_FRAME_EXTENDED; /* 251 */
    u2 offset_delta;
}

后续其他的 _extended 也是相同的作用。

same_locals_1_stack_item_frame(_extended)

这个其实就是上面的一个补充,为了 this 或三目运算符的时候能有可能用得到,比如下面这样的代码就会在三目运算符的跳转目标产生 same_locals_1_stack_item_frame 的 frame_type,这个 stack 包含的类型就是 [int]:

1
int x = d1 > 0 ? 1 : 2;

chop_frame / append_frame

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
chop_frame {
    u1 frame_type = CHOP; /* 248-250 */
    u2 offset_delta;
}

append_frame {
    u1 frame_type = APPEND; /* 252-254 */
    u2 offset_delta;
    verification_type_info locals[frame_type - 251];
}

这两个 frame 表示的 stack 都是 empty,不同点在于 chop_frame 是对去掉最后的 251 - frame_type 个 locals,而 append_frame 是在 locals 最后添加 frame_type - 251 个 locals,并在结构体中包含这几个 locals 的类型。

同样的,在添加或删除超过 3 个 locals 的时候,也会降级到 full_frame


至此,StackMapTable 的结构就已经基本解释完毕,但我不禁想到,难道其他语言没有类似的设计吗?

.NET / C# 是怎么做的?

作为各方面都和 Java 很像的 C# 语言,我猜测其也有可能会有类似的设计,因此我去看了 .NET 的 JIT Runtime 实现:RyuJIT,.NET 使用 RyuJIT 作为其 CLR JIT Runtime,CLR 实际执行 CIL(也叫 MSIL),目前 CIL/CLR 通过 ECMA 进行了标准化,详见 ECMA-335,其与 JVM 的编译和执行流程也较为类似:

https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/cc9033a0b36b40aa92f7138e50c54828~tplv-73owjymdk6-watermark.image?policy=eyJ2bSI6MywidWlkIjoiMjYwNDQwMTA1OTUyNTYifQ%3D%3D&rk3s=f64ab15b&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018&x-orig-expires=1721580002&x-orig-sign=UyuvLsDWUFaDy0EoImD6HSmkU08%3D

在 ECMA-335 中,事实上并没有规定 CIL 要具有 StackMapTable 这个结构,不过我们能否能找到类似功能的结构呢?CIL 中是否也有类似的类型检查?接下来我们就看看 CIL 规范中是如何规定的:

CIL 规定了如下的几种验证级别:

  • Syntactically correct CIL:符合 ILAsm 定义的基本语法
  • Valid CIL:在 ECMA-335 Partition III 中定义了一些 CIL Instruction 的规范,只有 Instruction 语句符合这些规范才能叫做 Valid CIL
  • Typesafe CIL:ILAsm 只是 IL 到 Assembly code 的转换器,其并不是完整的 Compiler,因此其并不进行任何类型检查功能,仅作转换作用。所以 Typesafe CIL 指的就是类型检查安全的 CIL
  • Verifiable CIL:VES 还会有除了类型检查以外的其他的验证算法

https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/cb8617ef0c0941829d8b06ad5ad7f37d~tplv-73owjymdk6-watermark.image?policy=eyJ2bSI6MywidWlkIjoiMjYwNDQwMTA1OTUyNTYifQ%3D%3D&rk3s=f64ab15b&x-orig-authkey=f32326d3454f2ac7e96d3d06cdbb035152127018&x-orig-expires=1721580002&x-orig-sign=Jx7YZMTRrxCAxov3kC90sX7SLZw%3D

Aside from these rules, this standard leaves as unspecified:

  • The time at which (if ever) such an algorithm should be performed.
  • What a conforming implementation should do in the event of a verification failure.

Ordinarily, a conforming implementation of the CLI can allow unverifiable code (valid code that does not pass verification) to be executed, although this can be subject to administrative trust controls that are not part of this standard. A conforming implementation of the CLI shall allow the execution of verifiable code, although this can be subject to additional implementationspecified trust controls.

根据规范,CLR 实现是允许执行未验证执行的,并且对于验证失败的代码,也没有相关的规定怎么处理。其实 .NET 的 Runtime 也没做强制的类型检查,而对于 CIL 级别,那就更是没有储存这个东西的专门的结构了。有趣的是,抛出 InvalidProgramException 事实上是最好的情况,执行没有充分检测的 CIL 代码会出现各种错误的内存访问,并且它们不一定会抛出错误。

C# 在编译期做了这些检查,有兴趣可以去看 C# 的编译器实现 Roslyn,官方的代码风格和文档都比较好,你大概率能得到所有你需要的东西:The Roslyn .NET compiler。同时也有一些针对 CIL 的检查器,如 PEVerify / ILVerify,他们能够去做更详细的检查,而不是在 JIT Runtime 去检查这些。

所以为什么 C# & .NET 的设计不去检查这类型是否正确以及其他更多的可验证步骤呢?有很多原因(民间猜测):

  • 使用 C++/CLI 转化的 CIL 存在一些 C++ 代码非常常见的情况,比如直接拿指针进行函数调用,作为兼容多种语言的格式,没有必要一定需要像 C# 一样遵守很强的类型约束规范。

  • 信任和运行时性能的取舍,CLR 信任 CIL 的执行正确已经在各自语言的编译期得到了保证,语言自身也可以选择用 ILVerify 等工具进行检查,没有必要在运行期消耗性能去检查类型是否正确。此外,这个信任也包含对各种 IL 操纵工具的信任。

    • 事实上,JRE 非常的不信任字节码,它会将字节码认为是完全不可信任的代码。
  • Oracle 的…专利?

甚至也有人会质疑,C# 为什么不干脆使用 AOT,还用什么 JIT,直接编译成机器码不香吗?还要什么检查?但 AOT 和 JIT 都是取舍,这里就不去做过多的展开了……

参考

其他