C#中的值类型和引用类型

在学习Unity脚本开发的时候接触到了引用类型和值类型,记录一下自己的理解

栈和堆

首先在项目内存分配中会使用到线程栈和堆,先对栈进行分析,当我们执行方法时,会将方法压栈,执行完毕后弹出,所占内存清空。栈有下面这些特性:

  1. 栈是内存自我管理的结构,压栈时自动分配内存,出栈时自动清空
  2. 栈中的内存不能动态请求,只能为大小确定的数据分配内存,灵活性不高,但栈的执行效率很高

  3. 栈的可用空间不大

接下来是堆,跟栈只能对一端数据进行操作不同,堆可以随意存取。在C#中堆可以用来存储实例对象,能存储大量数据且能动态分配存储空间,但其也有缺点:

  1. 执行效率不如栈高
  2. 不能自动回收使用过的对象

所以这就带来了垃圾回收的问题,在C++编程中,经常被提醒说new了对象使用完后一定要记得delete,否则会造成内存溢出,就是由于堆上存储是程序员所申请的,也应由程序员进行清理,

而.NET和java等提供了GC,可以自动回收堆中过期的对象

有哪些引用类型和值类型

任何被称为“类”的都是引用类型,在C#一般可以使用3个关键字来声明一个自定义的引用类型

1
Class,Interface,Delegate

C#中也有一些内建的引用类型

1
Dynamic,Object,String(这里会有个误区,string类型的值其实不是实际的字符串,而是对字符串的引用)

Unity中大部分类型都是引用类型,例如Component,Rigidbody

而值类型大体可以分为结构和枚举两类,结构又可以大体分为

  1. 数字型结构,如System.Int32,System.Float等
  2. 布尔型结构,如System.Boolean等

  3. 用户自定义结构

Unity中常见的值类型有Vector2,Vector3,Color等

引用类型和值类型的区别

引用类型的实例都会分配在托管堆上,new操作符会返回一个指向托管堆上该对象的内存地址,也就是说创建引用类型时,runtime会分配两个空间,一个分配在托管堆上,存储对象本身的数据,另一个分配在栈上,存储对堆上数据的引用。过程如下:

1
2
Myclass c;
c = new Myclass();

image-20200817172034276

先在栈上分配一小块空间用于存储引用类型的值的地址,接着在托管堆中分配空间实际存储值,然后将地址返回并存储在栈上。

image-20200817172250524

所以当我们执行下面这段代码创建一个新的类型d时,d指向同一个内存地址,也就是“引用”

1
2
3
4
Myclass d;
d = c;
c.value=10 //假设Myclass有成员变量value,默认值为5
Console.WrithLine(d.value) //输出10,由于引用的是堆中同一块内存空间

image-20200817172510381

需要注意的是,如果托管堆上内存不够分配对象时,会进行垃圾回收,多次进行垃圾回收会导致程序性能下降。

值类型创建时,runtime只会为其分配一个空间,分配在变量创建的地方

  • 如果值类型在方法内部创建,则跟随方法一起入栈,分配到栈上存储
  • 如果值类型是引用类型的成员变量,则跟随引用类型,存储在堆上

例如下面这一段代码

1
2
3
4
int i;
i = 20;
int j;
j = i;

当int型变量声明时,不管是否赋值,编译器都会根据其类型为其分配内存空间,且这个内存空间里就存储着值本身,跟引用类型不同。所以下面进行的j=i赋值操作只是拷贝值类型的内容,两个变量是相互独立的,修改时不会相互影响

image-20200817173826622

总结下来就是,值类型实例的值是自己本身,而引用类型的实例的值是一个引用,复制时一个是逐字段复制,另一个是内存地址的复制,所以我们队大对象赋值时避免使用值类型

装箱和拆箱

前面提到,引用类型在使用多了以后会面临垃圾回收带来性能下降的风险,而值类型虽然轻量,但大量使用的话也会有损程序性能,这就是装箱和拆箱的问题

装箱就是将值类型转化为引用类型,过程如下

  1. 在托管堆中分配内存,分配的内存空间除了值类型各字段需要内存外,还要加上托管堆上所有对象都有的两个额外成员(类型对象指针,同步索引块)所需的内存
  2. 将值类型的字段复制到新分配的堆内存中
  3. 返回对象地址

拆箱就是将已装箱的值类型实例转化为实类型

  1. 获取已装箱实例中各字段的地址(这一步被称为拆箱!!!)
  2. 将各个字段的值从托管堆复制到线程栈的新的值类型实例中

所以拆箱并不是装箱的逆过程,拆箱的代价要小许多,需要注意的是编辑器需要在拆箱时知道需要将引用类型拆箱成什么类型,否则会抛出异常

装箱和拆箱、复制会对程序的速度和内存空间产生不利影响,更甚者会由于频繁操作托管堆而增加GC的次数,所以在开发中需要注意编译器会在什么时候生成代码来自动进行装箱拆箱操作,并尝试手写这些代码,避免自动生成代码的情况。

总结

装箱和拆箱的学习也引出了对泛型概念的理解,泛型在C#2被提出,使得大量安全检查从运行时转移到了编译时进行,也可以减少装箱操作,在Unity常用的List\也是对泛型很好的实践。接下来就需要花时间对泛型进行系统地学习

《Unity 3D脚本编程:使用C#语言开发跨平台游戏》陈嘉栋

https://www.cnblogs.com/xiaodongy/p/7989711.html

https://www.cnblogs.com/woaixiaozhi/p/5116137.html