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





5.00/5 (2投票s)
它是 .NET 的基本组成部分,而且常常在你不知情的情况下发生,但它到底是如何工作的呢?.NET 运行时为了实现装箱做了哪些工作?
它是 .NET 的基本组成部分,而且常常 在你不知情的情况下 发生,但它到底是如何工作的?.NET 运行时为了让装箱成为可能,做了哪些工作?
注意:本文不会讨论如何检测装箱、它如何影响性能或如何移除它(关于这些,请与 Ben Adams 交流!)。它将只讨论它是如何工作的。
另外,如果你喜欢阅读关于CLR 内部机制的文章,你可能会发现以下文章很有趣
- CLR 如何加载一个类型
- 数组和 CLR——一种非常特殊的关系
- CLR 线程池的“线程注入”算法
- 在执行你代码的任何一行之前,CLR 所做的 68 件事
- .NET 委托是如何工作的?
- 反射为什么慢?
- ‘fixed’ 关键字是如何工作的?
CLR 规范中的装箱
首先,值得指出的是,装箱是由 CLR 规范 ‘ECMA-335’ 强制规定的,因此运行时必须提供它
这意味着 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 代码,你可以在其中看到 box
和 unbox.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 源代码的相关代码行。
- 特定于 CPU 的优化版本(这些版本在运行时连接)
- JIT_BoxFastMP_InlineGetThread(AMD64 - 多处理器或服务器 GC,隐式 TLS)
- JIT_BoxFastMP(AMD64 - 多处理器或服务器 GC)
- JIT_BoxFastUP(AMD64 - 单处理器和工作站 GC)
- JIT_TrialAlloc::GenBox(..)(x86),它被独立连接
- JIT 在常见情况下会内联辅助函数调用,请参阅 Compiler::impImportAndPushBox(..)
- 通用、不太优化的版本,用作备用 MethodTable::Box(..)
- 最终会调用 CopyValueClassUnchecked(..)
- 这与 Stack Overflow 上这个问题的答案“为什么小于 16 字节的 struct 更好?”相关。
有趣的是,唯一获得这种特殊待遇的“JIT 辅助方法”是 object
、string
或 array
的分配,这足以说明装箱是多么对性能敏感。
相比之下,“拆箱”只有一个辅助方法,名为 JIT_Unbox(..),在不常见的情况下会回退到 JIT_Unbox_Helper(..),并且在此处连接(CORINFO_HELP_UNBOX
到 JIT_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
(完整的输出可用)
那么这个“拆箱存根”是什么,为什么需要它?
它之所以存在,是因为如果你调用一个装箱版本的 MyStruct
的 ToString()
,它会调用 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 |
图例
- 原始
struct
,位于堆栈上 - 正在装箱到位于堆上的
object
中的struct
- 为使
MyStruct::ToString()
工作而对 this 指针进行的调整
(如果你想了解更多关于 .NET 对象内部机制的信息,请参阅这篇有用的文章。)
我们可以在下面的代码链接中看到这一点,请注意,该存根只包含几条汇编指令(它不像方法调用那样重量级),并且存在特定于 CPU 的版本。
- MethodDesc::DoPrestub(..)(调用
MakeUnboxingStubWorker(..)
) - MakeUnboxingStubWorker(..)(调用
EmitUnboxMethodStub(..)
来创建存根)
运行时/JIT 必须执行这些技巧来帮助维持 struct
可以像 class
一样运行的这种假象,尽管在底层它们有很大的不同。有关更多信息,请参阅 Eric Lippert 对“值类型如何派生自 Object(引用类型)但仍是值类型?”的回答。
希望本文能让你对“装箱”发生时幕后的机制有所了解。
延伸阅读
和之前一样,如果你看到这里,你可能会发现以下链接也很有趣
与装箱/拆箱存根相关的有用代码注释
- MethodTableBuilder::AllocAndInitMethodDescChunk(..)
- MethodDesc::FindOrCreateAssociatedMethodDesc(..) (在 genmeth.cpp 中)
- Compiler::impImportBlockCode(..)
GitHub 问题
其他相似/相关文章
- .NET 类型内部机制 - 从 Microsoft CLR 的视角(关于“装箱和拆箱”的部分)
- C# 值类型底层装箱机制(关于“接口调用到值类型实例方法”的部分)
- 值类型方法 – 调用、调用虚拟方法、约束和隐藏装箱
- 性能测试 #12 – 良好哈希的成本 – 解决方案(Rico Mariani)
- 装箱还是不装箱(Eric Lippert)
- 注意值类型的隐式装箱
- 值类型的方法调用和装箱
Stack Overflow 问题
- CLR 关于装箱的规范
- CLR 调用 struct 方法时的工作原理
- 调用 struct 的 ToString() 时装箱
- 在 .NET 中调用值类型的方法是否会导致装箱?
- 为什么隐式调用值类型的 toString 会导致装箱指令
- 为什么小于 16 字节的 struct 更好
- 何时创建值类型的类型对象?
- 如果我的 struct 实现 IDisposable,它在使用语句时会被装箱吗?
- using 语句何时会装箱其参数,当它是 struct 时?
文章 深入了解 CLR 中的“装箱”机制 最初发布在我的博客 性能即功能!上。