• .NET中的值类型与引用类型


    .NET中的值类型与引用类型

    这是一个常见面试题,值类型(Value Type)和引用类型(Reference Type)有什么区别?他们性能方面有什么区别?

    TL;DR(先看结论)

    值类型 引用类型
    创建位置 托管堆
    赋值时 复制值 复制引用
    动态内存分配 需要分配内存
    额外内存消耗 32位:额外12字节;64位:24字节
    内存分布 连续 分散

    引用类型

    常用的引用类型代码示例:

    void Main()
    {
        // 开始计数器
        var sw = Stopwatch.StartNew();
        long memory1 = GC.GetAllocatedBytesForCurrentThread();
        // 创建C16
        Span<B16> data = new B16[40_0000];
        foreach (ref B16 item in data)
        {
            item = new B16();
            item.V15.V15.V0 = 1;
        }
        long sum = 0; // 求和以免代码被优化掉
        for (var i = 0; i < data.Length; ++i)
        {
            sum += data[i].V15.V15.V0;
        }
        // 终止计数器
        sw.Stop();
        long memory2 = GC.GetAllocatedBytesForCurrentThread();
        // 输出显示结果
        new { Sum = sum, CreateTime = sw.ElapsedMilliseconds, Memory = memory2 - memory1 }.Dump();
    }
    
    class A1
    {
        public byte V0;
    }
    
    class A16
    {
        public A1 V0, V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13, V14, V15;
        public A16()
        {
            V0 = new A1(); V1 = new A1(); V2 = new A1(); V3 = new A1();
            V4 = new A1(); V5 = new A1(); V6 = new A1(); V7 = new A1();
            V8 = new A1(); V9 = new A1(); V10 = new A1(); V11 = new A1();
            V12 = new A1(); V13 = new A1(); V14 = new A1(); V15 = new A1();
        }
    }
    
    class B16
    {
        public A16 V0, V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13, V14, V15;
        public B16()
        {
            V0 = new A16(); V1 = new A16(); V2 = new A16(); V3 = new A16();
            V4 = new A16(); V5 = new A16(); V6 = new A16(); V7 = new A16();
            V8 = new A16(); V9 = new A16(); V10 = new A16(); V11 = new A16();
            V12 = new A16(); V13 = new A16(); V14 = new A16(); V15 = new A16();
        }
    }
    

    这次代码中,我们创建了40万个B16类型,然后对这40万个B16进行了统计,其中:

    • A1是一个字节(byte)的class
    • A16是包含16个A1的class
    • B16是包含16个A16的class

    可以计算出,B16=16·A16=16x16·A1=16x16x256 bytes,一共分配了40万个B16,所以一共有40_0000x256=1_0240_0000 bytes,或约100兆字节

    实际结果输出

    Sum CreateTime Memory
    40_0000 8_681 3_440_000_304

    电脑配置(之后的下文的性能测试结果与此完全相同):

    项目/配置 配置 说明
    CPU E3-1230 v3 @ 3.30GHz 未超频
    内存 24GB DDR3 1600 MHz 8GB x 3
    .NET Core 3.0.100-preview7-012821 64位
    软件 LINQPad 6.0.13 64位,optimize+

    数字涵义:

    • 40万条数据对1求和,结果是40万,正确;
    • 总花费时间一共需要9417毫秒;
    • 总内存开销约为3.4GB。

    请注意看内存开销,我们预估值是100MB,但实际约为3.4GB,这说明了引用类型需要(较大的)额外内存开销。

    一个空对象 要分配多大的堆内存?

    以一个空白引用类型为例,可以写出如下代码(LINQPad中运行):

    long m1 = GC.GetAllocatedBytesForCurrentThread();
    var obj = new object();
    long m2 = GC.GetAllocatedBytesForCurrentThread();
    (m2 - m1).Dump();
    GC.KeepAlive(obj);
    

    注意GC.KeepAlive是有必要的,否则运行在optimize+环境下会将new object()优化掉。

    运行结果:24(在32位系统中,运行结果为:12

    空引用类型(64位)为何要24个字节?

    一个引用类型的堆内存包含以下几个部分:

    • 同步块索引(synchronization block index),8个字节,用于保存大量与CLR相关的元数据,以下基本操作都会用到该内存:
      • 线程同步(lock
      • 垃圾回收(GC
      • 哈希值(HashCode
      • 其它
    • 方法表指针(method table pointer),又叫类型对象指针(TypeHandle),8个字节,用来指向类的方法表;
    • 实例成员,8字节对齐,没有任何成员时也需要8个字节。

    由于以上几点,才导致一个空白的object需要24个字节。

    • 因为没有同步块索引,导致:
      • 值类型不能参与线程同步(lock
      • 值类型不需要进行垃圾回收(GC
      • 值类型的哈希值计算过程与引用类型不同(HashCode
    • 因为没有方法表指针,导致:
      • 值类型不能继承

    值类型的性能

    值类型代码示例

    void Main()
    {
        // 开始计数器
        var sw = Stopwatch.StartNew();
        long memory1 = GC.GetAllocatedBytesForCurrentThread();
        // 创建C16
        Span<B16> data = new B16[40_0000];
        foreach (ref B16 item in data)
        {
            // item = new B16();
            item.V15.V15.V0 = 1;
        }
        long sum = 0; // 求和以免代码被优化掉
        for (var i = 0; i < data.Length; ++i)
        {
            sum += data[i].V15.V15.V0;
        }
        // 终止计数器
        sw.Stop();
        long memory2 = GC.GetAllocatedBytesForCurrentThread();
        // 输出显示结果
        new { Sum = sum, CreateTime = sw.ElapsedMilliseconds, Memory = memory2 - memory1 }.Dump();
    }
    
    struct A1
    {
        public byte V0;
    }
    
    struct A16
    {
        public A1 V0, V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13, V14, V15;
    }
    
    struct B16
    {
        public A16 V0, V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12, V13, V14, V15;
    }
    

    几乎完全一样的代码,区别只有:

    • 将所有的class(表示引用类型)关键字换成了struct(表示值类型)
    • item = new B16()语句去掉了(因为值类型创建数组会自动调用默认构造函数)

    运行结果

    运行结果如下:

    Sum CreateTime Memory
    40_0000 32 102_400_024

    注意,分配内存只有102_400_024字节,比我们预估的102_400_000只多了24个字节。这是因为数组也是引用类型,引用类型需要至少24个字节。

    比较

    运行时间 时间比 分配内存 内存比
    值类型 32 / 102_400_024 /
    引用类型 8_681 271.28x 3_440_000_304 33.59x

    在这个示例中,将引用类型改成值类型需要多出271倍的时间,和33倍的内存占用。

    重新审视值类型

    值类型这么好,为什么不全改用值类型呢?

    值类型的优点,恰恰也是值类型的缺点,值类型赋值时是复制值,而不是复制引用,而当值比较大时,复制值非常昂贵

    在远古时代,甚至是没有动态内存分配的,所以世界上只有值类型。那时为了减少值类型复制,会用变量来保存对象的内存位置,可以说是最早的指针了。

    在近代的的C里,除了值类型,还加入了指向动态分配的值类型的指针。其中指针基本可以与引用类型进行类比:

    • ✔指针和引用类型的引用,都指向真实的对象内存位置
    • ❌动态分配的内存需要手动删除,引用类型会自动GC回收
    • ❌指针指向的内存位置不会变,引用类型指向的内存位置会随着GC的内存压缩而产生变化,可用fixed关键字临时禁止内存压缩
    • ❌指针指向的内存没有额外消耗,引用类型需要分配至少24字节的堆内存

    C++为了解决这个问题,也是卯足了劲。先是加入了值引用运算符 &,而后又发布了一版又一版的“智能”指针,如auto_ptr/shared_ptr/unique_ptr。但这些“智能”指针都需要提前了解它的使用场景,如:

    • 有对象所有权还是没有对象所有权?
    • 线程安全还是不安全?
    • 能否用于赋值?

    而且库与库之前的版本多样,不统一,还影响开发的心情。

    所以引用类型的优势就出来了,不用关心对象的所有权,不用关心线程安全,不用关心赋值问题,而且最重要的,还不用关心值类型复制的性能问题。

    C#中的值类型支持

    引用类型是如此好,以至于平时完全不需要创建值类型,就能完成任务了。但为什么值类型仍然还是这么重要呢?就是因为一旦涉及底层,性能关键型的服务器、游戏引擎等等,都需要关心内存分配,都需要使用值类型。

    因为只有C#才能不依赖于C/C++等“本机语言”,就可写出性能关键型应用程序。

    C#因为有这些和值类型的特性,导致与其它语言(C/C++)相比时完全不虚:

    • 首先,C#可以写自定义值类型
    • C# 7.0 值类型Task(ValueTask):大量异步请求,如读取流时,可以节省堆内存分配和GC 点击查看
    • C# 7.0 ref返回值/本地变量引用:避免了大值类型内存大量复制的开销(有点像C++&关键字了) 点击查看
    • C# 7.0 Span<T>Memory<T>,简化了ref引用的代码,甚至让foreach循环都可以操作修改值类型了 点击查看
    • C# 7.2 加入in修饰符和其它修饰符,相当于C++中的const TypeName& 点击查看
    • C# 8.0 - Preview 5 可Dispose的ref struct,值类型也能使用Dispose模式了 点击查看

    ASP.NET Core曾使用Libuv(基于C语言)作为内部传输层,但从ASP.NET Core 2.1之后,换成了用.NET重写

    最后的话

    开发经常拿C#与同样开发Web应用的其它语言作比较,但由于缺乏对值类型的支持,这些语言没办法与C#相比。

    其中Java还暂不支持自定义值类型。

    推荐书籍:《C#从现象到本质》(郝亦非 著)


    作者:周杰
    出处:https://www.cnblogs.com/sdflysha
    本文采用 知识共享署名-非商业性使用-相同方式共享 2.5 中国大陆许可协议 进行许可,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接。

  • 相关阅读:
    Java面向对象_继承——基本概念以及管理化妆品实例分析
    Java面向对象_单例设计模式
    Java面向对象_增强for可变参数与代码块
    Java面向对象_对象一一对应关系和this关键字
    Java面向对象_对象数组
    Java面向对象_对象内存分析—值传递和引用传递
    Leetcode 203. 移除链表元素
    Leetcode 160. 相交链表
    Leetcode 141. 环形链表
    Leetcode 82. 删除排序链表中的重复元素 II
  • 原文地址:https://www.cnblogs.com/sdflysha/p/20190801-dotnet-value-type-and-reference-type.html
Copyright © 2020-2023  润新知