CLR(.NET)如何在内部分配和传递自定义值类型(结构)?

时间:2010-05-15 13:37:10

标签: .net garbage-collection clr memory-management value-type

问题:

所有CLR值类型(包括用户定义的struct)都是独立存在于评估堆栈中,这意味着垃圾收集器永远不需要回收它们,或者是否存在垃圾回收的情况 - 收集?

背景:

我之前曾问question on SO about the impact that a fluent interface has on the runtime performance of a .NET application。我特别担心创建大量非常短暂的临时对象会通过更频繁的垃圾收集对运行时性能产生负面影响。

现在我发现,如果我将这些临时对象的类型声明为struct(即作为用户定义的值类型)而不是class,则垃圾收集器可能不会参与如果事实证明所有价值类型都完全存在于评估堆栈中。

(这对我来说主要是因为我在思考C ++处理局部变量的方式。通常是自动(auto)变量,它们被分配在堆栈上,因此当程序执行回到调用者 - 完全不涉及new / delete的动态内存管理。我认为CLR只是可能类似地处理struct。)

到目前为止我发现了什么:

我做了一个简短的实验,看看为用户定义的值类型和引用类型生成的CIL有什么不同。这是我的C#代码:

struct SomeValueType     {  public int X;  }
class SomeReferenceType  {  public int X;  }
.
.
static void TryValueType(SomeValueType vt) { ... }
static void TryReferenceType(SomeReferenceType rt) { ... }
.
.
var vt = new SomeValueType { X = 1 };
var rt = new SomeReferenceType { X = 2 };
TryValueType(vt);
TryReferenceType(rt);

这是为最后四行代码生成的CIL:

.locals init
(
    [0] valuetype SomeValueType vt,
    [1] class SomeReferenceType rt,
    [2] valuetype SomeValueType <>g__initLocal0,  //
    [3] class SomeReferenceType <>g__initLocal1,  // why are these generated?
    [4] valuetype SomeValueType CS$0$0000         //
)

L_0000: ldloca.s CS$0$0000
L_0002: initobj SomeValueType  // no newobj required, instance already allocated
L_0008: ldloc.s CS$0$0000
L_000a: stloc.2
L_000b: ldloca.s <>g__initLocal0
L_000d: ldc.i4.1 
L_000e: stfld int32 SomeValueType::X
L_0013: ldloc.2 
L_0014: stloc.0 
L_0015: newobj instance void SomeReferenceType::.ctor()
L_001a: stloc.3
L_001b: ldloc.3 
L_001c: ldc.i4.2 
L_001d: stfld int32 SomeReferenceType::X
L_0022: ldloc.3 
L_0023: stloc.1 
L_0024: ldloc.0 
L_0025: call void Program::TryValueType(valuetype SomeValueType)
L_002a: ldloc.1 
L_002b: call void Program::TryReferenceType(class SomeReferenceType)

我从这段代码中无法弄清楚的是:

  • .locals块中提到的所有局部变量在哪里分配?他们是如何分配的?他们是如何被释放的?

  • (偏离主题:为什么需要这么多匿名局部变量并来回复制,只是为了初始化我的两个局部变量rtvt?)

4 个答案:

答案 0 :(得分:11)

您接受的答案是错误的。

  

值类型和引用类型之间的区别主要是赋值语义之一。在赋值时复制值类型 - 对于结构,这意味着复制所有字段的内容。引用类型仅复制引用,而不复制数据。堆栈是一个实现细节。 CLI规范不承诺分配对象的位置,并且依赖于规范中不存在的行为是一个坏主意。

值类型的特征在于它们的值传递语义,但这并不意味着它们实际上被生成的机器代码复制。

例如,对复数进行平方的函数可以接受两个浮点寄存器中的实部和虚部,并将其结果返回到两个浮点寄存器中。代码生成器优化了所有复制。

有几个人在下面的评论中解释了为什么这个答案是错误的,但是一些主持人删除了所有这些答案。

  

临时对象(本地人)将在GC生成0中存在.GC已经足够智能,可以在它们超出范围时立即释放它们。您无需为此切换到struct实例。

这完全是胡说八道。 GC仅查看运行时可用的信息,此时范围的所有概念都已消失。 GC“一旦超出范围”就不会收集任何东西。 GC将在无法访问后的某个时刻收集它。

  

可变值类型已经倾向于导致错误,因为当您将副本与原始副本进行变更时很难理解。但是在这些值类型上引入引用属性(如使用流畅的接口的情况)将会变得一团糟,因为看起来结构的某些部分会被复制而其他部分则不会被复制(即嵌套属性)参考属性)。我不能强烈反对这种做法,它可能导致长期的各种维护问题。

同样,这完全是胡说八道。在值类型中引用是没有错的。

现在,回答你的问题:

  

所有CLR值类型(包括用户定义的结构)是否都独立存在于评估堆栈中,这意味着它们永远不需要被垃圾收集器回收,或者是否存在垃圾收集的情况?

值类型肯定不会“完全依赖于评估堆栈”。首选是将它们存储在寄存器中。如有必要,它们将溢出到堆栈中。有时他们甚至被堆在盒子里。

例如,如果你编写一个循环遍历数组元素的函数,那么int循环变量(一个值类型)很可能完全存在于一个寄存器中,永远不会溢出到堆栈或写入堆。这就是Eric Lippert(微软C#团队,他自己写的"I don’t know all the details"关于.NET的GC)的意思,当他写道时,价值类型可以在"the jitter chooses to not enregister the value"时溢出到堆栈。对于较大的值类型(如System.Numerics.Complex)也是如此,但较大值类型不适合寄存器的可能性较大。

值类型不在堆栈上的另一个重要示例是当您使用具有值类型元素的数组时。特别是the .NET Dictionary collection uses an array of structs,以便在内存中连续存储每个条目的键,值和哈希值。这大大提高了内存局部性,缓存效率,从而提高了性能。值类型(和具体化的泛型)是.NET在this hash table benchmark上比Java快17倍的原因。

  

我做了一个简短的实验,看看CIL产生的差异是什么......

CIL是一种高级中间语言,因此,不会向您提供有关寄存器分配和溢出到堆栈的任何信息,甚至不能为您提供准确的拳击图片。但是,查看CIL可以让您了解前端C#或F#编译器如何将某些值类型转换为将异步和理解等更高级别的结构转换为CIL。

有关垃圾收集的更多信息,我强烈建议The Garbage Collection HandbookThe Memory Managment Reference。如果您想深入了解VM中值类型的内部实现,那么我建议您阅读我自己HLVM project的源代码。在HLVM中,元组是值类型,您可以看到生成的汇编器以及它如何使用LLVM在可能的情况下将值类型的字段保存在寄存器中,并优化掉不必要的复制,仅在必要时溢出到堆栈。

答案 1 :(得分:5)

请考虑以下事项:

  1. 值类型和引用类型之间的区别主要是赋值语义之一。赋值时复制值类型 - 对于struct,这意味着复制所有内容领域。引用类型仅复制引用,而不复制数据。 The stack is an implementation detail。 CLI规范不承诺分配对象的位置,依赖于规范中不存在的行为通常是一个危险的想法。

  2. 临时对象(本地人)将在GC生成0中生存.GC已经足够聪明,一旦超出范围就会(几乎)释放它们 - 或者每当它实际上最有效时。 Gen0运行得足够频繁,您无需切换到struct实例来有效管理临时对象。

  3. 可变值类型已经倾向于导致错误,因为当您将副本与原始副本进行变更时很难理解。 Many of the language designers themselves recommend making value types immutable whenever possible正是出于这个原因,指南是echoed by many of the top contributors on this site

    在这些值类型上引入引用属性,就像流畅的接口一样,通过创建不一致的语义进一步违反了Principle of Least Surprise。对值类型的期望是它们在赋值时被复制,完整,但是当它们的属性中包含引用类型时,实际上只会得到一个浅表副本。在最坏的情况下,你有一个包含 mutable 引用类型的可变结构,并且这样一个对象的使用者可能会错误地认为一个实例可以在不影响另一个实例的情况下进行变异。

    总有例外 - some of them in the framework itself - 但作为一般经验法则,我不建议编写“优化”代码,(a)取决于私人实施细节,(b)您知道难以实现维护,除非你(a)完全控制执行环境,并且(b)实际分析了你的代码并验证了优化会对延迟或吞吐量产生重大影响。

  4. 由于您正在使用对象初始值设定项,因此存在g_initLocal0和相关字段。切换到参数化构造函数,你会看到它们消失。

  5. 值类型通常在堆栈上分配,引用类型通常在堆上分配,但实际上并不是.NET规范的一部分,而不是保证(在第一个链接的帖子中,Eric甚至指出了一些明显的例外)。

    更重要的是,假设堆栈通常比堆自动便宜,这是完全错误的意味着使用堆栈语义的任何程序或算法将比GC管理的堆运行得更快或更高效。 There a number of papers written on this topic并且GC堆完全可能并且通常可能优于具有大量对象的堆栈分配,因为现代GC实现实际上对不的对象数量更敏感strong>需要释放(而不是完全固定到堆栈上对象数量的堆栈实现)。

    换句话说,如果您已经分配了数千或数百万个临时对象 - 甚至如果您对具有堆栈语义的值类型的假设在您的特定环境中的特定平台上仍然适用 - 利用它< em>仍然会使你的程序变慢!

    因此,我将回到我原来的建议:让GC完成它的工作,并且如果没有在所有可能的执行条件下进行全面的性能分析,不要认为你的实现可以胜过它。如果您从干净,可维护的代码开始,您可以随时优化;但是如果你以可维护性为代价编写你认为是性能优化代码的东西,后来证明你的性能假设是错误的,那么你的项目成本在维护开销,缺陷数量等方面会大得多。

答案 2 :(得分:4)

它是一个JIT编译器实现细节,它将分配.locals。现在,我不知道任何不在堆栈帧上分配它们的东西。它们通过调整堆栈指针“分配”并通过重置它来“释放”。非常快,很难改善。但谁知道,20年后我们可能都在运行具有CPU核心的机器,这些机器经过优化,只能运行具有完全不同内部实现的托管代码。可能是拥有大量寄存器的内核,JIT优化器现在已经使用寄存器来存储本地。

临时代理由C#编译器发出,以便在对象初始化程序抛出异常的情况下提供一些最小的一致性保证。它可以防止您的代码在catch或finally块中看到部分初始化的对象。还可以在using和lock语句中使用它,如果替换代码中的对象引用,它可以防止错误的对象被处置或解锁。

答案 3 :(得分:1)

结构是值类型,在用于局部变量时在堆栈上分配。但是,如果将局部变量强制转换为Object或接口,则该值将被装箱并在堆上分配。

结果是结构在超出范围之后被释放,除了它们被装箱并移动到堆之后,垃圾收集器在没有任何对象的引用时负责释放它们。

我不确定所有编译器生成局部变量的原因,但我认为它们被使用是因为你使用了对象初始化器。首先使用编译器生成的局部变量初始化对象,并且只有在完成执行复制到局部变量的对象初始化程序之后才能对象进行初始化。这可以确保您永远不会看到只执行某些对象初始值设定项的实例。