什么是引发/生成空引用异常背后的CLR实现?

6
我们在编码/开发过程中经常会遇到这个特定的、最常见的异常。我的问题不是关于为什么会有这个异常(我知道当我们尝试访问引用变量的属性时,它实际上指向null时就会出现),而是关于CLR如何生成NULL REFERENCE EXCEPTION。
有时我被迫思考识别空引用的机制(也许null是内存中的保留空间),然后CLR如何引发异常。CLR是如何识别和引发这个特定的异常的?操作系统在其中扮演了什么角色吗?
我想分享一个关于它的最有趣的说法:
"null实际上是CLR所知道的永久保留的内存空间,所有的访问都是被禁止的。因此,当找到那个空间的引用时,默认情况下通过OS生成访问被拒绝的异常,由CLR解释为NULL Reference Exception。"
我没有找到任何支持上述说法的文章或帖子,因此很难相信它。也许我错过了深入挖掘细节或其他原因,我期望Stackoverflow是一个最适合我得到最好回答的平台。

3
你没有找到任何关于那个引用声明的文章或帖子?那么它是从哪里来的呢? - Damien_The_Unbeliever
在与我的同事们内部讨论时... - Sumeet
这篇文章可能包含一些指向正确方向的信息。 - O. R. Mapper
2
你知道CLR如何实现“任何东西”吗?你需要知道吗? - H H
1
事实上,这里的一个实用教训是,所有方法调用和字段访问都会检查 null 值,这是基于操作系统对所有内存访问所做的一些非常高效的实现,因此我们不应编写复杂的尝试来避免它们。 - Jon Hanna
显示剩余8条评论
3个回答

11

它不一定需要这样做(可能会有明确的检查),但它通过捕获访问冲突异常来工作。

一个.NET对象将被转换为本机对象:它的字段变成了按特定方式布局的一块内存,它的方法被编译成本机代码方法,并创建了虚拟方法重载机制的v表或其他。

  1. 因此,访问一个字段意味着找到对象的地址,加上成员的偏移量,并读取或写入所引用的一块内存。

  2. 调用虚拟方法意味着找到对象的地址,找到其方法表(在对象内设置偏移量),找到方法的地址(在表内设置偏移量)并使用传递对象地址(this指针)调用该地址处的方法。

  3. 调用非虚拟方法意味着使用传递的对象地址(this指针)调用方法。

显然,如果在问题地址上没有实际对象,情况1和2将以某种方式出错,而情况3将起作用(但可能反过来导致情况1或2)。这两种情况主要出错方式如下:

  1. 它可能访问任意一位不属于我们类型的内存,导致各种精彩且难以跟踪的错误(.NET代码通常不会导致这种情况)。

  2. 它可能访问一个受保护的任意内存位,导致访问冲突。

您可能从C、C++或ASM编码中了解第二种情况。如果没有,您可能仍然看到过程序崩溃并在其垂死之息中谈论在某个地址处发生的访问违规。如果是这样,您可能已经注意到,虽然给出的地址几乎可以是任何东西,但它最常见的是0x00000000或像0x00000020这样非常低的数字。这些是由于尝试取消引用空指针而引起的代码(无论是访问字段还是调用虚拟方法(这本质上是根据您获取的内容进行访问,然后调用))。

现在,由于前64k内存始终受到保护,在.NET中将null指针取消引用将始终导致第二种情况(访问冲突),而不是第一种情况(任意内存被误用并导致奇怪的“核心探戈”错误)。

.NET的情况与此完全相同(或者更确切地说,与它生成的jitted代码相同),但是如果(A)访问违规发生在小于0x00010000的地址处,并且(B)这样的违规被发现它是由已经jitted的代码引起的,则它将转换为NullReferenceException,否则它将转换为AccessViolationException

我们可以使用不取消引用的代码来模拟此操作,但它会访问受保护的内存(我们只会读取,因此如果我们偶然碰到未受保护的内存,结果不会太奇怪!):

以下代码将引发AccessViolationException:

unsafe
{
  int read = *((int*)long.MaxValue - 8);
}
以下代码会引发 NullReferenceException 异常:
unsafe
{
  int read = *((int*)8);
}

两个代码示例都没有实际解引用任何内容。它们都会导致访问冲突,但CLR假设后者可能是由空引用引起的(公平地说,这是最有可能的情况),并将其抛出。

因此,我们可以看到字段访问和callvirt都可能导致此问题。

值得注意的是,由于决定不允许C#在安全的情况下对空引用调用方法,callvirt在大多数情况下被用作C#中的IL代码,唯一的例外情况是静态方法或编译时可以证明不在空引用上调用的情况。(编辑:还有几种情况,编译器可以看到callvirt可以被替换为call,即使该方法实际上是虚拟的 [如果编译器可以确定会命中哪个重载],后期编译器会更经常这样做,尽管仍然会比你想象的使用callvirt更频繁)。

一个有趣的情况是优化导致使用callvirt调用的方法可以内联,但在编译时不能保证它保证非空。在这种情况下,在“调用”(实际上不是调用)发生的位置之前,可能会添加字段访问,以便在方法的开始而不是中间触发NullReferenceException。这意味着优化不会改变观察到的行为。


如果一个类的字段总数超过64K,编译器会在字段访问时添加额外的代码以确保类引用不为空吗?大多数类不会有64K的字段,但使用嵌套泛型可以轻松创建大型结构体;具有这些类型字段的类很容易超过64K。 - supercat
@supercat 这样的类仍然只有一个4或8字节的指针。但无论哪种情况,对一个包含16,000个十进制字段的类进行快速测试都会引发NullReferenceException异常。 - Jon Hanna

4
MS实现中,据我所知,使用访问冲突来实现。Null本质上是一个零引用,并且基本上:他们故意保留了该地址空间并使此页未映射。内存访问冲突会自动在CPU /操作系统级别触发(即不需要额外的代码进行空检查),然后CLI将其报告为null引用异常。
有趣的是,由于内存是按页面处理的,因此您实际上可以模拟(如果足够努力)非零但低值的null引用异常,原因相同。
编辑:Eric Lippert在这个相关的问题/答案上进行了讨论:https://dev59.com/eWoy5IYBdhLWcg3wPbud#8681563

这句话的意思是我在原始问题/帖子中提出的陈述是完美的(总体而言)吗? - Sumeet
1
@Sumeet 总的来说 - 我不确定我会使用“完美”这个词; 如果有疑问,请使用Eric的解释!还要注意,这是一个实现细节,可能会在版本、平台等方面发生变化。 - Marc Gravell
实际上,在其他实现和平台上,情况将大致相同。在较低的层面上,Windows 上的 .NET 和 Mono 将对 STATUS_ACCESS_VIOLATION 异常做出反应,而 Linux 上的 Mono 将对 SIGSEGV 信号做出反应,但它们都是同样的事情。它可能是各种各样的东西,但任何具有受保护内存的操作系统都将允许类似于 .NET 的方法。 - Jon Hanna
@Jon 其实,我在想CF、MF等;确实,MF是一个解释器,所以我想象它会进行手动空值检查。 - Marc Gravell
真的。我根本没有看过它,所以你给了我更多好奇心,这将占用我的时间。尽管如此,解释器仍然可以对其尝试执行的异常做出反应,因此它可能接近于.NET表单,但具有额外的层。 - Jon Hanna
@MarcGravell 您在帖子中附加的链接最好解释了.. Eric Lippert 真棒! - Sumeet

1

你读过CLI规范-ECMA-335吗?你会在那里找到一些答案。

11类的语义...当创建一个具有类作为其类型的变量或字段时(例如,通过调用具有类类型的局部变量的方法),该值应最初为null,这是一个特殊值,即使它不是任何特定类的实例,也可以与所有类类型一起使用:=。

还有关于ldnull指令的描述:

ldnull指令将一个空引用(类型O)推送到堆栈上。这用于在位置变为活动状态之前或死亡时初始化位置。 [原理:可能认为ldnull是多余的:为什么不使用ldc.i4.0或ldc.i8.0?答案是ldnull提供了一个大小不确定的null - 类似于不存在的ldc.i指令。但是,即使CIL包括ldc.i指令,保留ldnull指令也有助于验证算法,因为它使类型跟踪更容易。结束原理] 可验证性: ldnull指令始终是可验证的,并生成可分配给任何其他引用类型(§I.8.7.3)的null类型值(§1.8.1.2)。

这并没有准确说明“如何”。 - Marc Gravell
不,那是行为的要求。那是需要检测它的时间。这不是如何做。 - Marc Gravell
它确实可以这样做,但是它并没有这样做(而且在所有地方都进行显式检查会非常慢)。关于如何,请参见我的答案。 - Jon Hanna
2
所有关于“怎么做”的问题都是与具体实现相关的。这就是“实现”一词的含义。 - Jon Hanna
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接