静态值类型字段是不是在 C# 的堆中装箱?

Posted

技术标签:

【中文标题】静态值类型字段是不是在 C# 的堆中装箱?【英文标题】:Is a static value type field boxed in the heap in C#?静态值类型字段是否在 C# 的堆中装箱? 【发布时间】:2014-11-02 16:59:22 【问题描述】:

出于好奇 - 考虑以下示例:

public class A

    public static int Foo;


public class Program

    static void Main()
    
        // The following variable will be allocated on the
        // stack and will directly hold 42 because it is a
        // value type.
        int foo = 42;

        // The following field resides on the (high frequency)
        // heap, but is it boxed because of being a value type?
        A.Foo = 42;
    

我的问题如下:Foo 字段的值是否被装箱,因为它位于堆上?或者它是在一个特殊的容器对象/内存部分中,就像一个实例值类型字段是堆上对象的一部分一样封装它?

我会假设它没有装箱,但我不确定,我找不到任何文档。

感谢您的帮助。

【问题讨论】:

不,它没有装箱。它将保持原样。是什么让你觉得它可能是盒装的? 嗯,值类型的值不能单独驻留在堆上——除非它们被装箱。当它们是实例字段时,它们成为相应对象的一部分并且没有被装箱,但我找不到任何关于它们是静态字段时会发生什么的信息。 @feO2x - 值类型可以存在于堆上而不被装箱。仅当您需要将值类型视为引用类型时才需要装箱。 参考 Eric Lipert 的关于值类型的博客文章:blogs.msdn.com/b/ericlippert/archive/2010/09/30/… This may help 【参考方案1】:

CLR 确实有一个类的每个字段都需要具有相同存储类型的限制。只有实例成员最终在 GC 堆上。静态成员在加载器堆中分配。或者当字段具有 [ThreadStatic] 属性时,在线程本地存储中。这当然会强制执行一个静态成员由类对象的每个实例共享的约定。

顺便说一句,实现非常简单,抖动分配存储并知道字段的地址。所以任何加载和存储都直接使用变量的地址。没有额外的指针解引用,非常高效。

所以,不,根本不需要装箱,一个静态的 int 只会占用 4 个字节。

如果您想亲自查看,请使用调试 + Windows + 反汇编窗口。显示机器代码,您将直接使用变量的地址看到它。每次运行程序时它都会是一个不同的地址,这是一种恶意软件对策。

【讨论】:

【参考方案2】:

由于 Sriram 和 Lee 在问题的 cmets 中给出了答案,但没有提供答案,我将总结一下调查结果:

不,该值未装箱。值类型可以驻留在堆上,只有当它们像引用类型一样使用时才会被装箱。

您还可以看到我的示例的 IL 代码中没有涉及装箱:

.method private hidebysig static void  Main() cil managed

  .entrypoint
  // Code size       12 (0xc)
  .maxstack  1
  .locals init ([0] int32 foo)
  IL_0000:  nop
  IL_0001:  ldc.i4.s   42
  IL_0003:  stloc.0
  IL_0004:  ldc.i4.s   42
  IL_0006:  stsfld     int32 StaticValueTypeFieldBoxing.A::Foo
  IL_000b:  ret
 // end of method Program::Main

【讨论】:

【参考方案3】:

TL;DR:是的,但不是语义上的,并且仅适用于非内置值类型。

以下内容基于我自己对 CLR 应用程序内部工作原理的逆向工程。

 

所提供的答案并不完全正确,事实上,颇具误导性。

这是一个有趣的问题。视情况而定。

int、float 等内置类型(由 VES 直接支持)原始存储在静态变量的地址中。

但有趣的是,System.Decimal、System.DateTime 等非内置类型和用户定义的值类型都被装箱了。

但有趣的是,它们实际上有点,有点……双盒装。想象一下:

public struct MyStruct

    public int A;


public static class Program

    public static MyStruct X;

    public static void Main()
    
        Program.X.A = 1337;
        Program.DoIt();
    

    public static void DoIt()
    
        Program.PrintA(Program.X);
        Program.PrintType(Program.X);
    

    private static void PrintType(object obj)
    
        Console.WriteLine(obj.GetType().FullName);
    

    public static void PrintA(MyStruct myStruct)
    
        Console.WriteLine(myStruct.A);
    

现在,这将按您的预期工作,MyStruct 将为 PrintType 装箱,而不是为 PrintA 装箱。

然而,Program.X 实际上并不像在实例变量或局部变量中那样直接包含 MyStruct 实例。相反,它在堆中包含对它的引用,其中实例作为具有对象头和所有对象的对象存在。

正如最初提到的,这不适用于内置类型。因此,如果您有一个包含 int 的静态变量,则该静态变量将占用 4 个字节。但是如果你有一个用户定义类型的静态变量,例如。 struct IntWrapperpublic int A;,那么静态变量将在 32 位进程中占用 4 个字节,在 64 位进程中占用 8 个字节来存储 IntWrapper 结构的盒装版本的地址,其中它在 32 位进程中占用 8 个字节-bit 进程和 64 位进程中的 12 个字节(对象头指针为 4/8 个字节,int 为 4 个字节),忽略任何潜在的填充。

但是,从语义上讲,它的工作方式与您期望的一样。调用 PrintA(Program.X) 时,程序会将 Program.X 指向的对象中的 struct 部分(对象头后面的数据)复制并传递给 PrintA。

当调用 PrintType(Program.X) 时,它确实将实例装箱。代码创建一个带有对象头的新 MyStruct 对象,然后将 Program.X 引用的对象中的 A 字段复制到新创建的对象中,然后将该对象传递给 PrintType。

总而言之,Program.X 包含一个装箱的 MyStruct 的地址(如果我们将装箱定义为将值类型转换为引用类型),但仍会装箱(或克隆)该对象,就好像它是一个值类型一样,因此语义保持不变,就好像它直接作为值类型存储在静态变量中一样。

就像我说的,我不确定他们为什么要这样做,但确实如此。

我已经包含了上面 C# 代码的 JIT 反汇编并对其进行了注释。 请注意,我已经在反汇编中找到了所有名称。

对调用的评论:对托管方法的所有调用都是通过指针发生的。在第一次调用时,指针指向负责 JIT 编译方法的代码。 JIT编译后,指针被替换为JIT编译后代码的地址,因此后续调用速度很快。

Program.Main:
    MOV     EAX, DWORD PTR DS:[<Program.X>]                 ; Move the address stored in static variable Program.X into register EAX.
    MOV     DWORD PTR DS:[EAX + 4], 539h                    ; Set field at offset 4 (Offset 0 is the object header pointer) to 1337.
    CALL    DWORD PTR DS:[<Program.DoIt Ptr>]               ; Call Program.DoIt.
RET                                                         ; Return and exit the program.

Program.DoIt:
    PUSH    EBP                                             ; Function prologue.
    MOV     EBP, ESP                                        ; Function prologue.
    MOV     EAX, DWORD PTR DS:[<Program.X>]                 ; Move the address stored in static variable Program.X into register EAX.
    MOV     ECX, DWORD PTR DS:[EAX + 4]                     ; Copy the struct part (the dword after the object header pointer) into ECX (first argument (this)), essentially an unboxing.
    CALL    DWORD PTR DS:[<Program.PrintA Ptr>]             ; Call Program.PrintA.
    ; Here, the MyStruct stored in the static value is cloned to maintain value semantics (Essentially boxing the already boxed MyStruct instance).
    MOV     ECX, <MyStructObjectHeader>                     ; Boxing for PrintType: Copy the address of the object header for MyStruct into ECX (First argument).
    CALL    <CreateObject>                                  ; Boxing for PrintType: Create a new object (reference type) for MyStruct.
    MOV     ECX, EAX                                        ; Copy the address of the new object into ECX (first argument for Program.PrintType).
    MOV     EAX, DWORD PTR DS:[<Program.X>]                 ; Boxing for PrintType: Move the address stored in static variable Program.X into register EAX.
    MOV     EAX, DWORD PTR DS:[EAX + 4]                     ; Boxing for PrintType: Get value of MyStruct.A from the object stored in Program.X (MyStruct.A is at offset 4, since the object header is at offset 0).
    MOV     DWORD PTR DS:[ECX + 4], EAX                     ; Boxing for PrintType: Store that value in the newly created object (MyStruct.A is at offset 4, since the object header is at offset 0).
    CALL    DWORD PTR DS:[<Program.PrintType Ptr>]          ; Call Program.PrintType.
    POP     EBP                                             ; Function epilogue.
RET                                                         ; Return to caller.

Program.PrintA:
    PUSH    EAX                                             ; Allocate local variable.
    MOV     DWORD PTR SS:[ESP], ECX                         ; Store argument 1 (the MyStruct) in the local variable.
    MOV     ECX, DWORD PTR SS:[ESP]                         ; Copy the MyStruct instance from the local variable into ECX (first argument to WriteLine).
    CALL    <mscorlib.ni.System.Console.WriteLine(object)>  ; Call WriteLine(object) overload.
    POP     ECX                                             ; Deallocate local variable.
RET                                                         ; Return to caller.

Program.PrintType:
    PUSH    EBP                                             ; Function prologue.
    MOV     EBP, ESP                                        ; Function prologue.
    CMP     DWORD PTR DS:[ECX], ECX                         ; Cause an access violation if 'this' is null, so the CLR can throw a null reference exception.
    CALL    <GetType>                                       ; GetType.
    MOV     ECX, EAX                                        ; Copy the returned System.Type object address into ECX (first argument).
    MOV     EAX, DWORD PTR DS:[ECX]                         ; Dereference object header pointer.
    MOV     EAX, DWORD PTR DS:[EAX + 38h]                   ; Retrieve virtual function table.
    CALL    DWORD PTR DS:[EAX + 10h]                        ; Call virtual function at offset 10h (get_FullName method).
    MOV     ECX, EAX                                        ; Copy returned System.String into ECX (first argument).
    CALL    <mscorlib.ni.System.Console.WriteLine(int)>     ; Call WriteLine.
    POP     EBP                                             ; Function epilogue.
RET                                                         ; Return to caller.

 

下面是long等内置类型与其他值类型的区别对比。

public static class Program

    public static long X;

    public static void Main()
    
        Program.X = 1234567887654321;
    

编译为:

Program.Main:
    PUSH    EBP                                                 ; Function prologue.
    MOV     EBP, ESP                                            ; Function prologue.
    MOV     DWORD PTR DS:[DD4408], 3C650DB1                     ; Store low DWORD of 1234567887654321.
    MOV     DWORD PTR DS:[DD440C], 462D5                        ; Store high DWORD of 1234567887654321.
    POP     EBP                                                 ; Function epilogue.
RET                                                             ; Return.

 

在本例中,MyStruct 包含一个 long。

public static class Program

    public static MyStruct X;

    public static void Main()
    
        Program.X.A = 1234567887654321;
    

编译为:

Program.Main:
    PUSH    EBP                                                 ; Function prologue.
    MOV     EBP, ESP                                            ; Function prologue.
    MOV     EAX, DWORD PTR DS:[3BD354C]                         ; Retrieve the address of the MyStruct object stored at the address where Program.X resides.
    MOV     DWORD PTR DS:[EAX + 4], 3C650DB1                    ; Store low DWORD of 1234567887654321 (The long begins at offset 4 since offset 0 is the object header pointer).
    MOV     DWORD PTR DS:[EAX + 8], 462D5                       ; Store high DWORD of 1234567887654321 (High DWORD of course is offset 4 more from the low DWORD).
    POP     EBP                                                 ; Function epilogue.
RET                                                             ; Return.

 

附带说明:这些结构对象是为类的所有值类型静态变量分配的,第一次调用访问类中任何静态变量的方法时。

也许这就是他们这样做的原因。为了节省内存。如果静态类中有很多结构,但没有在使用它们的类上调用任何方法,则使用的内存更少。如果它们被内联在静态类中,那么即使您的程序从不访问它们,每个结构也会无缘无故地占用它们在内存中的大小。通过在第一次访问它们时将它们作为对象分配到堆上,您只在访问它们时占用它们在内存中的大小(+ 对象头的指针),并且在不访问它们时每个变量最多占用 8 个字节。这也使库更小。但这只是我对他们为什么会这样做的猜测。

【讨论】:

【参考方案4】:

https://docs.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.fixedaddressvaluetypeattribute?view=net-5.0

...在“备注”下明确地说,“静态值类型字段被创建为装箱对象。这意味着它们的地址可以随着垃圾回收的执行而改变”。

【讨论】:

以上是关于静态值类型字段是不是在 C# 的堆中装箱?的主要内容,如果未能解决你的问题,请参考以下文章

装箱与拆箱

Unity常见面试题大全

C# 反射 设置字段值无效的解决办法

c#装箱与拆箱

Java中的值类型

C#基础:理解装箱与拆箱