2 | 类和结构

类和结构的区别以及装箱和拆箱,基本上都是老生常谈了,不过,在开发过程中,还是会产生一个疑问:我的数据该使用类还是结构?这个问题接下来的几个部分都会有涉及到。

2.1 如何估算对象和结构体的大小


结构是值类型,它的结构体实例是存放在栈或者堆中的。在栈中我们保有的是实例的值,所以每一次赋值,都会在栈中多赋值一份实例出来。结构体在内存中所占大小,就是其字段所占大小,但是,结构体的大小并不是简单的所有字段的大小相加,而是存在一个对齐规则,在默认的对齐规则中,基本类型字段是按照自身大小对齐的,如byte是按1字节对齐,int是按4字节对齐。如下面的结构体:

struct S
    {
        byte b1;
    }


这个结构体的大小是1,如果在下面添加一个字段:

struct S
    {
        byte b1;
        int i1;
    }


这个结构体的大小是8,因为int是4字节对齐的,所以只能从第四个字节开始。
如果再添加一个字段:

struct S
    {
        byte b1;
        int i1;
        byte b2;
    }


这个结构体的大小是12,由于struct本身也是要对齐的,所以它的对齐规则是按照其中元素最大的对齐规则决定的。如当前这个结构体是按照i1的对齐规则决定的,也就是四字节对齐,不足四字节则不齐。如果想优化其大小,调整顺序如下,结构体的大小就变成了8。

struct S
    {
        byte b1;
        byte b2;
        int i1;
    }


类是引用类型,它的对象实例存放在堆中,对象实例一定是会占用堆内存的,而在栈中,我们保有的是实例的引用,对象在堆内存中大概是如下图所示:

其中vtable是类的共有数据,包含静态变量和方法表(在Mono中,结构的静态变量也存放在vtable里,它是缓存在一个叫tablecache的哈希表当中的,而IL2CPP中类和结构的静态变量存在一个单独的类里)。Monitor是线程同步用的,这两个指针分别占用一个IntPtr.Size大小(32位中是4字节,64位中是8字节),再下面是所有字段,字段是从第9个字节或17个字节开始的,字段的对齐规则与结构体的对齐规则相同,区别是Mono中对象实例会把引用类型的引用摆在最前面。一个对象实例的大小(instance_size)就是IntPtr.Size * 2+字段所占大小,结构体被装箱后在堆内存的大小也一样。

通过调整字段顺序,可以优化对象和结构体大小,特别是有容器存放多个对象或结构体的,可以减少堆内存占用。

此外,我们还可以通过StructLayoutAttribute自定义类和结构字段的对齐方式。比如下面的结构体:

[StructLayout(LayoutKind.Sequential, Pack = 1)]
    public struct S
    {
        byte b1;
        int i1;
        byte b2;
    }


该结构体强制按1字节对齐,所以它的大小就是6。

[StructLayout(LayoutKind.Explicit)]
    public struct S
    {
        [FieldOffset(0)]byte b1;
        [FieldOffset(0)]int i1;
        [FieldOffset(1)] byte b2;
    }


这个结构体的大小是4,它实现了类似C/C++中union的类型,b1、b2与i1共用同一段内存,b1和b2也代表了i1的前两个字节。

注意,内存对齐是会考虑硬件优化的,使用StructLayout修改对齐方式有可能会降低性能。



2.2 装箱和拆箱


装箱和拆箱的过程很多文档都会有描述,这里就不再细说了。只说几个比较容易忽视的地方。

  • 如果结构体实现了某个接口,那么结构体转换为接口会发生装箱。
  • 对值类型实例调用GetType()会发生装箱。
  • 对结构体调用ToString(),GetHashCode():在Mono中,直接调用不会发生装箱,但是在IL2CPP中却会有装箱。如果重写了这两个方法,调用时就都不会发生装箱,但是如果调用了base.ToString()或base.GetHashCode(),还是会发生装箱。
  • 某些容器类操作时发生的装箱会在下面提到。