C# 中结构的实例和成员的堆和堆栈如何工作?

Posted

技术标签:

【中文标题】C# 中结构的实例和成员的堆和堆栈如何工作?【英文标题】:How does the heap and stack work for instances and members of struct in C#? 【发布时间】:2021-04-29 23:09:37 【问题描述】:

我正在读一本书,上面写着:

表示结构实例的变量不包含指向实例的指针;该变量包含实例本身的字段。因为变量包含实例的字段,所以不必取消引用指针来操作实例的字段。下面的代码演示了引用类型和值类型的区别

class SomeRef  public Int32 x; 
struct SomeVal  public Int32 x; 

static void ValueTypeDemo() 
   SomeRef r1 = new SomeRef();        // Allocated in heap
   SomeVal v1 = new SomeVal();        // Allocated on stack
   r1.x = 5;                          // Pointer dereference
   v1.x = 5;                          // Changed on stack

我来自 C 背景,对结构变量 v1 有点困惑,我觉得 v1.x = 5; 仍然涉及指针取消引用,就像 C 中的数组变量是指向该数组中第一个元素的地址的指针,我觉得v1一定是指向SomeVal中第一个字段的地址(当然不是堆)的指针,如果我的理解是正确的,那么v1.x = 5;也必须涉及指针解引用?如果没有,如果我们要访问结构中的随机字段,因为编译器需要生成该字段的偏移量,如何不涉及指针,仍然必须涉及指针?

【问题讨论】:

SomeVal v1 的行为与 C 结构绝对相同...除了 C 没有为结构调用不可能的构造函数的语法,因此您可以忽略 = new SomeVal()完全是一部分...不太确定为什么您认为 C 中的类似代码会涉及堆... @Alexei 我不是说涉及堆,我是说涉及指针解引用,指针指向栈 指针不是这样工作的。如果您只是访问内存地址,那就太酷了。但是如果你要去那里读取另一个地址,那就是一个指针。我不明白您为什么认为涉及取消引用。 如果结构存储在堆栈上,那么编译器可以计算堆栈指针的偏移量并执行一次取消引用。堆栈指针实际上是一个“空闲”指针/取消引用,我们不倾向于计算它,因为我们不必在访问相对于它的内存之前检索该指针 first,它总是在 CPU 上。任何其他指针都必须首先自行加载,而额外的加载/引用往往被视为取消引用。 这能回答你的问题吗? What and where are the stack and heap? 和 Stack and heap in c sharp 和 Memory allocation: Stack vs Heap? 和 Stack and Heap allocation 【参考方案1】:

理论上,运行时并不能保证结构将如何存储,只要行为相同,它就可以随意存储它。

实际上,您的示例将存储为方法堆栈框架的一部分。所以 v1 将保留结构的空间,即 4 个字节。对结构字段的访问将简单地转换为相应的字段,就像您直接使用 int32 一样。

如果结构有多个字段,编译器只需将多个偏移量相加,一个到结构的开头,一个到实际字段。所有这些在编译时都是已知的,因此编译器可以弄清楚这一点。

请注意,虽然 CIL 使用基于堆栈的模型,但抖动可能会优化变量以存储在寄存器中。还有ref-keyword 允许引用值类型,有点类似于指针。

【讨论】:

无法保证该结构会在堆栈中。它足够小,JIT 可以选择将其保存在寄存器中。 @Damien_the_unbeliever 是的,我认为“可以随心所欲地存储它”暗示了这一点,但可能值得明确提及。 "不保证":确实,.NET 是一个虚拟平台。实际上,自从 CPU 开始和堆栈寄存器的发明以来,在类似 Intel 的微处理器技术(x86、x32、x64 和类似的实际硅技术)上,行为就像它一样。但事实上,在未来,潜在的东西可能会与任何其他技术(如量子)有所不同。【参考方案2】:

相关答案:How does a struct instance's virtual method get located using its type object in heap?How boxing a value type work internally in C#?Is everything in .NET an object?

正如 @ Damien_The_Unbeliever 所说,以下内容仅适用于当前的计算技术,因为 .NET 是一个虚拟平台。实际上,自从 CPU 开始和堆栈寄存器的发明以来,在类似 Intel 的微处理器(x86、x32、x64 和类似的)上,行为就是这样。但在未来,潜在的东西可能与任何其他技术(如量子)有所不同。

作为类成员的struct实例是与对象本身一起分配的,所以在堆中,但在方法中声明的局部结构变量是在堆栈中分配的。

此外,作为方法参数传递的变量始终使用堆栈:引用以及结构的内容是 PUSHed 和 POPed,因此建议不要过度使用和不要过度使用结构和匿名类型的限制大。

为了简化和理解,假设堆是整个房间,而堆栈是这个房间中的一个橱柜。

这个柜子是用于运行程序的局部值类型变量和引用,以及在方法之间传递数据并在这些方法是函数而不是过程时获取这些方法的结果:引用、值类型、整数类型、结构内容、匿名类型和委托作为临时容器在此橱柜中推送和弹出。

房间是为对象本身而设的(我们传递对象的引用),除了不在对象中的结构体(我们传递所有的结构体内容,当我们传递一个类中的结构体时也是如此,我们将整个结构作为副本传递)。

例如:

class MyClass
 
  MyStruct MyVar;

在任何地方创建对象时,都是在头部创建的结构变量“并不孤单”。

但是:

void MyMethod()
 
  MyStruct MyVar;

是在堆栈中创建的结构的本地“单独”实例以及整数。

因此,如果一个类有 10 个整数,则在调用方法时仅将引用推入堆栈(x32 上为 4 个字节,x64 上为 8 个字节)。但如果它是一个结构,它需要 PUSH 10 个整数(x32 和 x64 上 40 个字节)。

换句话说,正如您所写:因此,单独的结构实例(因此,分配给结构类型的局部变量)不会存储在堆中。但是类的成员(因此,分配给结构类型的字段)存储在堆中

也就是说:

堆中结构的成员(整数和引用指针“值”)通过使用MOV 操作码和等效(虚拟或目标机器代码)的直接内存访问进行访问。

堆栈中结构的成员使用堆栈寄存器基数+偏移量访问。

第一个慢,第二个更快。

How would the memory look like for this object?

What and where are the stack and heap?

Stack and heap in c sharp

Memory allocation: Stack vs Heap?

Stack and Heap allocation

Stack and Heap memory

Why methods return just one kind of parameter in normal conditions?

List of CIL instructions

.NET OpCodes Class

Stack register

The Concept of Stack and Its Usage in Microprocessors

Introduction of Stack based CPU Organization

What is the role of stack in a microprocessor?

为了更好地理解和提高您的计算技能,您可能会发现有趣的是调查assembly language 以及CPU 的工作原理。您可以从IL 和modern Intel 开始,但从过去的8086 to i386/i486 开始可能会更简单、更具形成性和互补性。

【讨论】:

【参考方案3】:

您是正确的 - 涉及到结构的指针,但结构内字段的偏移量是在编译时计算的。

用于在字段中存储(非引用)值的 IL 指令是 stfld,从字段中加载(非引用)值的指令是 ldfield

当然,这些 IL 指令由 JIT 编译器转换为汇编,这可能会应用许多优化,例如避免多次加载相同的指针,但这会因编译器版本以及您是否启用 DEBUG 而异或发布版本。

例如,考虑以下结构:

struct SomeVal

    public Int32 x; 
    public Int32 y;

还有代码:

SomeVal v1 = new SomeVal();
v1.x = 5;
v1.y = 6;
Console.WriteLine(v1.x + v1.y);

为此为 RELEASE 构建生成的 IL 是:

.entrypoint
.locals init (
    [0] valuetype ConsoleApp1.SomeVal V_0
)

IL_0000: ldloca.s V_0
IL_0002: initobj ConsoleApp1.SomeVal
IL_0008: ldloca.s V_0
IL_000a: ldc.i4.5
IL_000b: stfld int32 ConsoleApp1.SomeVal::x
IL_0010: ldloca.s V_0
IL_0012: ldc.i4.6
IL_0013: stfld int32 ConsoleApp1.SomeVal::y
IL_0018: ldloc.0
IL_0019: ldfld int32 ConsoleApp1.SomeVal::x
IL_001e: ldloc.0
IL_001f: ldfld int32 ConsoleApp1.SomeVal::y
IL_0024: add
IL_0025: call void [mscorlib]System.Console::WriteLine(int32)
IL_002a: ret

v1.x = 5 的 IL 是:

IL_0008: ldloca.s V_0
IL_000a: ldc.i4.5
IL_000b: stfld int32 ConsoleApp1.SomeVal::x

注意它是怎么回事:

    使用ldloca.s V_0将结构的地址压入堆栈 使用 ldc.i4.5 将常量 int32 值 5 推入堆栈 将该 int32 值存储到位于由 ConsoleApp1.SomeVal::x 使用 stfld int32 ConsoleApp1.SomeVal::x 定义的恒定偏移处的字段中

在使用add 将它们相加之前,您可以看到用于加载xy 字段的类似IL 代码。

【讨论】:

以上是关于C# 中结构的实例和成员的堆和堆栈如何工作?的主要内容,如果未能解决你的问题,请参考以下文章

JVM的内存结构里的那个堆和栈,和数据结构里的堆和栈是一个东西吗?

数据结构中的堆和栈 与 内存分配中的堆区和栈区 分析

堆和堆栈内存是如何管理、实现和分配的?

c++堆栈的各自大小,堆和栈的各自定义

内存里的堆和栈只读区静态全局区

C语言中堆栈队列