65.9K
CodeProject 正在变化。 阅读更多。
Home

深入了解 CLR 中的“装箱”机制

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2017 年 8 月 2 日

CPOL

6分钟阅读

viewsIcon

6975

它是 .NET 的基本组成部分,而且常常在你不知情的情况下发生,但它到底是如何工作的呢?.NET 运行时为了实现装箱做了哪些工作?

它是 .NET 的基本组成部分,而且常常 在你不知情的情况下 发生,但它到底是如何工作的?.NET 运行时为了让装箱成为可能,做了哪些工作?

注意:本文不会讨论如何检测装箱、它如何影响性能或如何移除它(关于这些,请与 Ben Adams 交流!)。它将讨论它是如何工作的

另外,如果你喜欢阅读关于CLR 内部机制的文章,你可能会发现以下文章很有趣

CLR 规范中的装箱

首先,值得指出的是,装箱是由 CLR 规范 ‘ECMA-335’ 强制规定的,因此运行时必须提供它

ECMA Spec - I.8.2.4 Boxing and unboxing of values

这意味着 CLR 需要处理一些关键事项,我们将在本文的其余部分进行探讨。

创建“装箱”类型

运行时需要做的第一件事是为它加载的任何 struct 创建相应的引用类型(“装箱类型”)。你可以在 ‘方法表’ 创建的开头看到这一点,它会首先检查它是否处理的是‘值类型’,然后据此进行操作。因此,任何 struct 的“装箱类型”都会在你的 .dll 被导入时预先创建好,这样在程序执行过程中发生的任何“装箱”操作都可以随时使用。

链接代码中的注释很有趣,它揭示了运行时需要处理的一些底层细节

// Check to see if the class is a valuetype; but we don't want to mark System.Enum
// as a ValueType. To accomplish this, the check takes advantage of the fact
// that System.ValueType and System.Enum are loaded one immediately after the
// other in that order, and so if the parent MethodTable is System.ValueType and
// the System.Enum MethodTable is unset, then we must be building System.Enum and
// so we don't mark it as a ValueType.

特定于 CPU 的代码生成

但要了解程序执行过程中发生了什么,让我们从一个简单的 C# 程序开始。下面的代码创建了一个自定义的 struct(值类型),然后对其进行“装箱”和“拆箱”操作。

public struct MyStruct
{
    public int Value;
}

var myStruct = new MyStruct();

// boxing
var boxed = (object)myStruct;

// unboxing
var unboxed = (MyStruct)boxed;

这会被转换为以下 IL 代码,你可以在其中看到 boxunbox.any IL 指令。

L_0000: ldloca.s myStruct
L_0002: initobj TestNamespace.MyStruct
L_0008: ldloc.0 
L_0009: box TestNamespace.MyStruct
L_000e: stloc.1 
L_000f: ldloc.1 
L_0010: unbox.any TestNamespace.MyStruct

运行时和 JIT 代码

那么 JIT 如何处理这些 IL 操作码呢?在正常情况下,它会连接内联运行时提供的优化过的、手工编写的汇编代码版本的“JIT 辅助方法”。下面的链接指向 CoreCLR 源代码的相关代码行。

有趣的是,唯一获得这种特殊待遇的“JIT 辅助方法”是 objectstringarray 的分配,这足以说明装箱是多么对性能敏感

相比之下,“拆箱”只有一个辅助方法,名为 JIT_Unbox(..),在不常见的情况下会回退到 JIT_Unbox_Helper(..),并且在此处连接CORINFO_HELP_UNBOXJIT_Unbox)。JIT 在常见情况下也会内联辅助函数调用,以节省方法调用的开销,请参阅 Compiler::impImportBlockCode(..)

请注意,“拆箱辅助函数”只获取指向“装箱”数据的引用/指针,然后必须将其放到堆栈上。正如我们上面所看到的,当 C# 编译器进行拆箱时,它使用‘Unbox_Any’ 操作码,而不仅仅是‘Unbox’ 操作码,有关更多信息,请参阅“拆箱不创建值副本,对吗?”

拆箱存根创建

除了“装箱”和“拆箱”struct 之外,运行时还需要在类型保持“装箱”状态时提供帮助。为了理解原因,让我们扩展 MyStruct重写 ToString() 方法,使其显示当前 Value

public struct MyStruct
{
    public int Value;
	
    public override string ToString()
    {
        return <span class="s">"Value = " + Value.ToString();
    }
}

现在,如果我们查看运行时为 MyStruct装箱版本创建的“方法表”(记住,值类型没有“方法表”),我们会看到一些奇怪的事情正在发生。请注意,有 2 个 MyStruct::ToString 条目,其中一个我标记为“拆箱存根”。

 Method table summary for 'MyStruct':
 Number of static fields: 0
 Number of instance fields: 1
 Number of static obj ref fields: 0
 Number of static boxed fields: 0
 Number of declared fields: 1
 Number of declared methods: 1
 Number of declared non-abstract methods: 1
 Vtable (with interface dupes) for 'MyStruct':
   Total duplicate slots = 0

 SD: MT::MethodIterator created for MyStruct (TestNamespace.MyStruct).
   slot  0: MyStruct::ToString  0x000007FE41170C10 (slot =  0) (Unboxing Stub)
   slot  1: System.ValueType::Equals  0x000007FEC1194078 (slot =  1) 
   slot  2: System.ValueType::GetHashCode  0x000007FEC1194080 (slot =  2) 
   slot  3: System.Object::Finalize  0x000007FEC14A30E0 (slot =  3) 
   slot  5: MyStruct::ToString  0x000007FE41170C18 (slot =  4) 
   <-- vtable ends here

(完整的输出可用)

那么这个“拆箱存根”是什么,为什么需要它?

它之所以存在,是因为如果你调用一个装箱版本的 MyStructToString(),它会调用 MyStruct 自身声明的重写方法(这也是你期望的),而不是 Object::ToString() 版本。但是,MyStruct::ToString() 期望能够访问 struct 中的任何字段,例如本例中的 Value。为了实现这一点,在调用 MyStruct::ToString() 之前,运行时/JIT 必须调整 this 指针,如下面的图所示。

1. MyStruct:         [0x05 0x00 0x00 0x00]

                     |   Object Header   |   MethodTable  |   MyStruct    |
2. MyStruct (Boxed): [0x40 0x5b 0x6f 0x6f 0xfe 0x7 0x0 0x0 0x5 0x0 0x0 0x0]
                                          ^
                    object 'this' pointer | 

                     |   Object Header   |   MethodTable  |   MyStruct    |
3. MyStruct (Boxed): [0x40 0x5b 0x6f 0x6f 0xfe 0x7 0x0 0x0 0x5 0x0 0x0 0x0]
                                                           ^
                                   adjusted 'this' pointer |

图例

  1. 原始 struct,位于堆栈
  2. 正在装箱到位于上的 object 中的 struct
  3. 为使 MyStruct::ToString() 工作而对 this 指针进行的调整

(如果你想了解更多关于 .NET 对象内部机制的信息,请参阅这篇有用的文章。)

我们可以在下面的代码链接中看到这一点,请注意,该存根包含几条汇编指令(它不像方法调用那样重量级),并且存在特定于 CPU 的版本。

运行时/JIT 必须执行这些技巧来帮助维持 struct 可以像 class 一样运行的这种假象,尽管在底层它们有很大的不同。有关更多信息,请参阅 Eric Lippert 对“值类型如何派生自 Object(引用类型)但仍是值类型?”的回答。

希望本文能让你对“装箱”发生时幕后的机制有所了解。

延伸阅读

和之前一样,如果你看到这里,你可能会发现以下链接也很有趣

GitHub 问题

其他相似/相关文章

Stack Overflow 问题

文章 深入了解 CLR 中的“装箱”机制 最初发布在我的博客 性能即功能!上。

© . All rights reserved.