它不一定需要这样做(可能会有明确的检查),但它通过捕获访问冲突异常来工作。
一个.NET对象将被转换为本机对象:它的字段变成了按特定方式布局的一块内存,它的方法被编译成本机代码方法,并创建了虚拟方法重载机制的v表或其他。
因此,访问一个字段意味着找到对象的地址,加上成员的偏移量,并读取或写入所引用的一块内存。
调用虚拟方法意味着找到对象的地址,找到其方法表(在对象内设置偏移量),找到方法的地址(在表内设置偏移量)并使用传递对象地址(this指针)调用该地址处的方法。
调用非虚拟方法意味着使用传递的对象地址(this指针)调用方法。
显然,如果在问题地址上没有实际对象,情况1和2将以某种方式出错,而情况3将起作用(但可能反过来导致情况1或2)。这两种情况主要出错方式如下:
它可能访问任意一位不属于我们类型的内存,导致各种精彩且难以跟踪的错误(.NET代码通常不会导致这种情况)。
它可能访问一个受保护的任意内存位,导致访问冲突。
您可能从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
。这意味着优化不会改变观察到的行为。