.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
链接:https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/C# 7.0
ref
返回值/本地变量引用:避免了大值类型内存大量复制的开销(有点像C++
的&
关键字了)
链接:https://devblogs.microsoft.com/dotnet/whats-new-in-csharp-7-0/#user-content-ref-returns-and-localsC# 7.0
Span<T>
和Memory<T>
,简化了ref
引用的代码,甚至让foreach
循环都可以操作修改值类型了
链接:https://docs.microsoft.com/en-us/dotnet/standard/memory-and-spans/memory-t-usage-guidelinesC# 7.2
加入in
修饰符和其它修饰符,相当于C++
中的const TypeName&
链接:https://docs.microsoft.com/zh-cn/dotnet/csharp/whats-new/csharp-7-2#safe-efficient-code-enhancementsC# 8.0 - Preview 5
可Dispose的ref struct
,值类型也能使用Dispose模式了
链接:https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-8#disposable-ref-structs
ASP.NET Core
曾使用Libuv(基于C语言)作为内部传输层,但从ASP.NET Core 2.1
之后,换成了用.NET
重写,链接:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel?view=aspnetcore-2.2#transport-configuration
最后的话
开发经常拿C#
与同样开发Web应用的其它语言作比较,但由于缺乏对值类型的支持,这些语言没办法与C#
相比。
其中Java
还暂不支持自定义值类型。
推荐书籍:《C#从现象到本质》(郝亦非 著)
.NET社区新闻,深度好文,欢迎访问公众号文章汇总 http://www.csharpkit.com