在C#中,变量的类型就属引用类型,值类型,以及他们之间相互的转换比较难理解,里面更是涉及到了类型在内存中的存储结构,本文通过内存,栈,堆,值类型,引用类型的关系,以及相互转换时产生的装拆箱操作,来给大家梳理一下其中的过程,拨开各种层层的技术迷雾,探究其真正的本质。如果大家对过程产生疑问或者描述过程有错误的地方,欢迎在评论区中多多指正,大家一起学习,一起进步! 内存
在讲数据结构之前,和大家先一起回顾下内存的物理结构是啥,内存的物理结构比较简单,大部分人都见过内存条: ![]() 抽象出来之后的内存条模型: 内存实际上是一种名为内存IC的电子元件,内存IC中有电源、地址信号、数据信号、控制信号等用于输入输出的大量引脚(IC的引脚),通过为其指定地址,来进行数据的读写。VCC和GND是电源,A0~A9是地址信号的引脚,D0~D7是数据信号的引脚,RD和WR是控制信号的引脚。将电源连接到VCC和GND后,就可以给其他引脚传递比如0或者1这样的信号。大多数情况下,+ 5V的直流电压表示1,0V表示0。 上面的内存IC能存储多少数据呢,数据信号引脚有D0~D7共八个,表示一次可以输入输出8位(=1字节)的数据。此外,地址信号引脚有A0~A9共十个,表示可以指定0000000000~1111111111共1024个地址。而地址用来表示数据的存储场所,因此我们可以得出这个内存IC中可以存储1024个1字节的数据。因为1024=1K,所以该内存IC的容量就是1KB。 现在大家使用的计算机至少有512M的内存。这就相当于512000个(512MB÷1KB=512000K)1KB的内存IC。当然,一台计算机中不太可能放入如此多的内存IC。通常情况下,计算机使用的内存IC中会有更多的地址信号引脚,这样就能在一个内存IC中存储数十兆字节的数据。因此,只用数个内存IC,就可以达到512MB的容量。如上实图1GB的内存条,引脚比较多。
内存的写入的实现,我们继续来看刚才所说的1KB的内存IC。首先,我们假设要往该内存IC中写入1字节的数据。为了实现该目的,可以给VCC接入+5V,给GND接入0V的电源,并使用A0~A9的地址信号来指定数据的存储场所,然后再把数据的值输入给D0~D7的数据信号,并把WR(write=写入的简写)信号设定成1。执行完这些操作,就可以在内存IC内部写入数据(如下图中的a)了。 内存的读取的实现,读出数据时,只需通过A0~A9的地址信号指定数据的存储场所,然后再将RD(read=读出的简写)信号设成1即可。执行完这些操作,指定地址中存储的数据就会被输出到D0~D7的数据信号引脚(下图中的b)中。 ![]() 另外,像WR和RD这样可以让IC运行的信号称为控制信号。其中,当WR和RD同时为0时,写入和读出的操作都无法进行。 内存IC内部有大量可以存储8位数据的地方,通过地址指定这些场所,之后即可进行数据的读写。
内存的逻辑模型可以简单理解为每层都存储着数据的楼房,在这个楼房中,1层可以存储1个字节的数据,楼层号表示的就是地址。同时并不需要过多地关注内存IC的电源和控制信号等。当内存为1KB时,表示有1024层的楼房(编程语言的数据类型空间没有在这里体现): 在程序中,可以指定数据类型的占用空间的大小(占用的楼层数),程序定义三个变量,内存实际存放的空间如下: 其中,字节是二进制数据的单位,常用的字节是8位的字节,即包含8位的二进制数,因此,4字节就是32位,一个字节有符号代表2^7=128 ,无符号代表2^8=256。
地址,是用来标志存储资源位置的,在计算机中用一串二进制数据表示,其中包含:
下图是CPU和计算机的基本架构,我们以此图来说明物理/逻辑地址在CPU和计算机中如何被解析处理的: ![]()
当然,这里也涉及到了许多内存管理相关的知识,比如连续的内存分配,非连续的内存分配的方式(虚拟内存)进行内存的相关管理和优化。有兴趣的同学可以再深入探索。
当一个exe程序(内容为再分配信息,变量组和函数组)被点击时,此时程序会被加载到虚拟内存中,并且从虚拟内存地址转换成实际的内存地址。虚拟内存会为程序额外生成2个组,那就是栈和堆。 栈是内存数组,是一个后进先出的数据结构(先进先出的称为队列),栈也成为堆栈,线程堆栈,每个正在运行的程序都对应着一个进程(或几个,但是一个进程只能对应一个应用程序)在一个进程内部,可以有一个或多个线程(thread),每个线程都拥有一块“自留地”,称为“线程堆栈”,大小为1M,栈存储下列的几种类型数据:
这部分的内存区域分配和释放不需要程序员管理 堆是内存的一块区域,在堆里可以分配大块的内存用于存储某类型的数据对象,C#中称为托管堆,由CLR进行管理,与栈不同,堆里面的内存能够以任务的顺序存入和移除。同时因为这个特点,也会造成堆存储的空间不连续,需要GC进行相应的处理。 exe文件中并不存在栈和堆的组。栈和堆需要的内存空间是在exe文件加载到内存后开始运行时得到分配的。因而,内存中的程序,就是由用于变量(全局变量,静态变量,常量)的内存空间,用于函数的内存空间,用于栈的内存空间,用于堆的内存空间这4部分构成的。当然在内存中,加载windowds等操作系统的内存空间又是另外一回事了。 如下图所示: 栈及堆的相似之处在于,他们的内存空间都是在程序运行时得到申请分配的。不过,在内存的使用方法上,二者存在些许不同。栈中对数据进行存储和舍弃(清理处理)的代码,是由编译器自动生成的,因此不需要程序员的参与。使用栈的数据的内存空间,每当函数被调用时都会得到申请分配,并在函数处理完毕后自动释放。与此相对,堆的内存空间,则要根据程序员编写的程序,来明确进行申请分配或释放。根据编程语言的不同,对堆用的内存空间进行申请分配和释放的程序的编写方法也是多种多样的。 比如C语言需要程序员调用对应的方法函数来手动申请分配和释放,C++需要用运算符来申请和释放,当然C和C++不好操作的地方在于,如果没有在程序中明确释放堆的内存空间,那么即使在处理完毕后,该内存空间仍会一直残留。这个现象称为内存泄露。而C#和Java,都是使用GC自动垃圾回收机制来处理相关的问题,使得程序员不需要关心堆在内存中的管理问题。关于GC如何工作的,请移步:https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/ 下面我们来具体看下数据在栈和堆中是怎么分配和存储的 栈和堆为啥内存中既然有了栈,为啥还用堆这种内存空间。因为栈的工作的方式是先分配内存的变量后面才释放(先进后出),是从上往下填充(高内存地址到低内存地址)。但是很多变量不是单独存在的,可能和其他的变量嵌套,这样就和变量的生命周期起了冲突,为了解决这个问题,堆的设计就是从下往上分配,保证了栈中先进后出的规则不与变量的生命周期起冲突。为啥堆能解决冲突,还要设计栈这个结构呢,因为全部变量保存在堆中,会使得应用程序性能下降。 在介绍数据在栈和堆的存放原理时,需要介绍下C#中的数据类型
数据类型如下如图所示: ![]() 数据类型分:值类型和引用类型。值类型存放在栈(堆栈)中,引用类型先在栈中存放对应的引用地址,然后在堆(托管堆)中分配空间存放数据。 声明st的对象引用的时候,会在栈中存放对应的引用地址(占用4个字节的空间,地址此时是空的信息,因为还没创建对应的实例对象),这里仅仅是一个引用地址的信息存放,不是对应Student对象,接着第二行代码,堆中的内存会给Student对象分配内存空间,假定Student对象的实例是32个字节,CLR需要搜索一个未使用且连续的内存空间来存储对象的实例(大小为32*8位字节,同时需要提领指针,把分配给Student对象的实例地址赋值给st变量),如果没有,这个时候,会涉及到GC强制的一次垃圾回收,如果回收后空间还是不够,会抛出内存不足异常。 上面的例子告诉我们,建立对象引用的过程比建立值变量的过程复杂,且不能避免性能的降低,为了提升简单和常用的类型的性能,CLR提供了名为“值类型”的轻量级类型。值类型实例变量不包含指向实例的指针。相反,变量中包含了实例本身的字段,由于变量已包含了实例的字段,所以操作实例中的字段不需要提领指针(Int32 a=new Int(); 所以值类型也是有实例对象)。值类型的实例不受垃圾回收器的控制。因此,值类型的使用缓解了托管堆的压力,并减少了应用程序生存期内的垃圾回收的次数,提升了性能。 下面通过例子来演示引用类型和值类型的区别: 如图:(对象指针的作用是用来关联对象,同步索引的作用是用来完成同步(比如线程同步)) 下面来看更加复杂的对象的存储: 如图: ![]() 说明:
|