C# 数据类型在内存中的存储原理

在C#中,变量的类型就属引用类型,值类型,以及他们之间相互的转换比较难理解,里面更是涉及到了类型在内存中的存储结构,本文通过内存,栈,堆,值类型,引用类型的关系,以及相互转换时产生的装拆箱操作,来给大家梳理一下其中的过程,拨开各种层层的技术迷雾,探究其真正的本质。如果大家对过程产生疑问或者描述过程有错误的地方,欢迎在评论区中多多指正,大家一起学习,一起进步!.

内存

内存的物理结构

在讲数据结构之前,和大家先一起回顾下内存的物理结构是啥,内存的物理结构比较简单,大部分人都见过内存条:C# 数据类型在内存中的存储原理抽象出来之后的内存条模型: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)中。

C# 数据类型在内存中的存储原理

另外,像WR和RD这样可以让IC运行的信号称为控制信号。其中,当WR和RD同时为0时,写入和读出的操作都无法进行。

内存IC内部有大量可以存储8位数据的地方,通过地址指定这些场所,之后即可进行数据的读写。

内存的逻辑模型

内存的逻辑模型可以简单理解为每层都存储着数据的楼房,在这个楼房中,1层可以存储1个字节的数据,楼层号表示的就是地址。同时并不需要过多地关注内存IC的电源和控制信号等。当内存为1KB时,表示有1024层的楼房(编程语言的数据类型空间没有在这里体现):C# 数据类型在内存中的存储原理

在程序中,可以指定数据类型的占用空间的大小(占用的楼层数),程序定义三个变量,内存实际存放的空间如下:

// 定义变量
byte a=123; short b=123; int c=123;
C# 数据类型在内存中的存储原理

其中,字节是二进制数据的单位,常用的字节是8位的字节,即包含8位的二进制数,因此,4字节就是32位,一个字节有符号代表2^7=128 ,无符号代表2^8=256。

内存地址的解析

地址,是用来标志存储资源位置的,在计算机中用一串二进制数据表示,其中包含:

  1. 物理地址:加载到内存地址寄存器中的地址,内存单元的真正地址。在前端总线上传输的内存地址都是物理内存地址,编号从0开始一直到可用物理内存的最高端。这些数字被北桥(Nortbridge chip)映射到实际的内存条上。物理地址是明确的、最终用在总线上的编号,不必转换,不必分页,也没有特权级检查。
  2. **逻辑地址:**CPU所生成的地址。逻辑地址是内部和编程使用的、并不唯一,逻辑地址的生成依赖与编译器(源代码编译为机器码)。

下图是CPU和计算机的基本架构,我们以此图来说明物理/逻辑地址在CPU和计算机中如何被解析处理的:

C# 数据类型在内存中的存储原理
  • CPU中的算数逻辑单元看到的都是逻辑地址

  • 当CPU需要把数据写入内存或从内存中读取时,MMU会把逻辑地址转换成对应的物理地址

  • 控制逻辑把数据、操作请求和物理地址发送到总线,分为读请求和写请求(写请求,则把数据写入内存,读请求,则把数据从内存中读取发送给CPU)

  • MMU负责逻辑地址和物理地址之间的转换

  • 操作系统负责建立逻辑地址和物理地址之间的映射关系

    Windows使用了虚拟寻址系统技术,把程序的可用内存地址映射到硬件内存中的实际地址中,这些任务完全由Windows后台管理,实际结果是32位处理器上的每个进程都可以使用4GB的内存,有2G的用户模式内存(无论计算机上有多少硬盘空间(在64位处理器上这个数值会更大))。这个4GB内存,2G用户模式内存也叫虚拟地址空间,或叫虚拟内存

当然,这里也涉及到了许多内存管理相关的知识,比如连续的内存分配,非连续的内存分配的方式(虚拟内存)进行内存的相关管理和优化。有兴趣的同学可以再深入探索。

程序在内存的分配

当一个exe程序(内容为再分配信息,变量组和函数组)被点击时,此时程序会被加载到虚拟内存中,并且从虚拟内存地址转换成实际的内存地址。虚拟内存会为程序额外生成2个组,那就是栈和堆。

栈是内存数组,是一个后进先出的数据结构(先进先出的称为队列),栈也成为堆栈,线程堆栈,每个正在运行的程序都对应着一个进程(或几个,但是一个进程只能对应一个应用程序)在一个进程内部,可以有一个或多个线程(thread),每个线程都拥有一块“自留地”,称为“线程堆栈”,大小为1M,栈存储下列的几种类型数据:

  • 某些类型变量的值(值类型)
  • 程序当前的执行环境
  • 传递给方法的参数

这部分的内存区域分配和释放不需要程序员管理

堆是内存的一块区域,在堆里可以分配大块的内存用于存储某类型的数据对象,C#中称为托管堆,由CLR进行管理,与栈不同,堆里面的内存能够以任务的顺序存入和移除。同时因为这个特点,也会造成堆存储的空间不连续,需要GC进行相应的处理。

exe文件中并不存在栈和堆的组。栈和堆需要的内存空间是在exe文件加载到内存后开始运行时得到分配的。因而,内存中的程序,就是由用于变量(全局变量,静态变量,常量)的内存空间,用于函数的内存空间,用于栈的内存空间,用于堆的内存空间这4部分构成的。当然在内存中,加载windowds等操作系统的内存空间又是另外一回事了。

如下图所示:

C# 数据类型在内存中的存储原理

栈及堆的相似之处在于,他们的内存空间都是在程序运行时得到申请分配的。不过,在内存的使用方法上,二者存在些许不同。栈中对数据进行存储和舍弃(清理处理)的代码,是由编译器自动生成的,因此不需要程序员的参与。使用栈的数据的内存空间,每当函数被调用时都会得到申请分配,并在函数处理完毕后自动释放。与此相对,堆的内存空间,则要根据程序员编写的程序,来明确进行申请分配或释放。根据编程语言的不同,对堆用的内存空间进行申请分配和释放的程序的编写方法也是多种多样的。

比如C语言需要程序员调用对应的方法函数来手动申请分配和释放,C++需要用运算符来申请和释放,当然C和C++不好操作的地方在于,如果没有在程序中明确释放堆的内存空间,那么即使在处理完毕后,该内存空间仍会一直残留。这个现象称为内存泄露。而C#和Java,都是使用GC自动垃圾回收机制来处理相关的问题,使得程序员不需要关心堆在内存中的管理问题。关于GC如何工作的,请移步:https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/

下面我们来具体看下数据在栈和堆中是怎么分配和存储的

栈和堆

为啥内存中既然有了栈,为啥还用堆这种内存空间。因为栈的工作的方式是先分配内存的变量后面才释放(先进后出),是从上往下填充(高内存地址到低内存地址)。但是很多变量不是单独存在的,可能和其他的变量嵌套,这样就和变量的生命周期起了冲突,为了解决这个问题,堆的设计就是从下往上分配,保证了栈中先进后出的规则不与变量的生命周期起冲突。为啥堆能解决冲突,还要设计栈这个结构呢,因为全部变量保存在堆中,会使得应用程序性能下降。

在介绍数据在栈和堆的存放原理时,需要介绍下C#中的数据类型

C#数据层次结构

数据类型如下如图所示:

C# 数据类型在内存中的存储原理

数据类型分:值类型和引用类型。值类型存放在栈(堆栈)中,引用类型先在栈中存放对应的引用地址,然后在堆(托管堆)中分配空间存放数据。

//创建一个对象
Student st; //声明一个student的引用对象
st=new Student(); 

声明st的对象引用的时候,会在栈中存放对应的引用地址(占用4个字节的空间,地址此时是空的信息,因为还没创建对应的实例对象),这里仅仅是一个引用地址的信息存放,不是对应Student对象,接着第二行代码,堆中的内存会给Student对象分配内存空间,假定Student对象的实例是32个字节,CLR需要搜索一个未使用且连续的内存空间来存储对象的实例(大小为32*8位字节,同时需要提领指针,把分配给Student对象的实例地址赋值给st变量),如果没有,这个时候,会涉及到GC强制的一次垃圾回收,如果回收后空间还是不够,会抛出内存不足异常。

上面的例子告诉我们,建立对象引用的过程比建立值变量的过程复杂,且不能避免性能的降低,为了提升简单和常用的类型的性能,CLR提供了名为“值类型”的轻量级类型。值类型实例变量不包含指向实例的指针。相反,变量中包含了实例本身的字段,由于变量已包含了实例的字段,所以操作实例中的字段不需要提领指针(Int32 a=new Int(); 所以值类型也是有实例对象)。值类型的实例不受垃圾回收器的控制。因此,值类型的使用缓解了托管堆的压力,并减少了应用程序生存期内的垃圾回收的次数,提升了性能。

下面通过例子来演示引用类型和值类型的区别:

// 引用类型-类类型
class StudentRef{
   public int age;
}
// 值类型-结构体类型
struct StudentVal{
   public int age;
}

static void ValueTypeDemo(){
    StudentRef r1=new StudentRef(); //在堆上分配
    StudentVal v1=new StudentVal(); //在栈上分配
    r1.age=18;                      //提领指针
    v1.age=18;                     //在栈上修改
    Console.WriteLine(r1.age);      // 显示"18"
    Console.WriteLine(v1.age);      // 同样显示"18"
    // ****** 分割符 ******//
    StudentRef r2=r1;               // 只复制引用
    StudentVal v2=v1;               // 在栈上分配并复制成员
    r1.age =20;                     // r1.age 和 r2.age 都会更改为20
    v1.age =21;                     // v1.age 会更改为 21,v2.age 的值不会更改,为18
}

如图:(对象指针的作用是用来关联对象,同步索引的作用是用来完成同步(比如线程同步))

C# 数据类型在内存中的存储原理

下面来看更加复杂的对象的存储:

//定义一个学生的基类
class Student{
    public void Study(){
        Console.WriteLine("学习!");
    } 
    public virtual int Credit(int x,int y){
        Console.WriteLine($"总学分:{x+y},必修:{x},选修:{y}");
        return x+y;
    }
    public static void Play(string s){
         Console.WriteLine("玩耍:"+s);
    }
}
//大一学生
class Freshman:Student{
    //重写学分的方法
    public override int Credit(int x,int y){
        Console.WriteLine($"大一学生总学分:{x+y}");
        return x+y;
    }
}
//实例化小明对象:
public void XiaoMing(){
    int score;                 //1.总学分    
    //小明是大一学生
    Student xm=new Freshman(); //2.实例化对象
    score=xm.Credit(30,5);     //3.调用虚方法,实际调用子类重写之后的方法
    xm.Study();                //4.调用实例方法
    xm.Play("游戏");           //5.调用静态方法,内存中,不管有多少个实例对象,静态成员只有一份
 // 第2个实例对象:小花
    // Student xh=new Freshman(); //体现在图中的Freshman实例2
}

如图:C# 数据类型在内存中的存储原理说明:

  • 进程启动,CLR加载到其中,托管堆初始化,创建线程(1M的栈空间),线程已经执行一些代码,后调用XiaoMing()方法时,JIT编译器将方法中的IL代码转换为本机CPU指令,加载方法中定义的类型涉及到的程序集,CLR提取相关数据到内存数据区,并初始化/创建一些数据结构来表示类型本身

  • 在执行XiaoMing()方法之前,参数类型,变量类型已经创建完成(常用类型先加载)

  • 类型对象指针和同步块索引是每个对象都有的成员,每个类型对象都包含一个方法表,在方法表中,类型定义的每个方法都有对应的记录项

    Student类型有3个方法记录项,Freshman类型只有一个,因为继承关系,Freshman类型有专门的字段来引用基类型(其他类型同样有),可以让JIT编译器追溯类层次结构(一直追溯到Object)

装箱和拆箱

装箱

值类型比引用类型“轻”,原因是他们不作为对象在托管堆中分配,不被GC回收,不用通过指针 进行引用。但是许多时候,都需要获取对值类型实例的引用。比如:

//值类型
struct Point{
 public Int 32 x,y;
}
pulic sealed class Program{
    public static void Main(){
        ArrayList a =new ArrayList();
        Point p;          //分配一个Point(不在堆中分配空间)
        for(Int 32 i=0;i<10;i++){
            p.x=p.y=i;   //初始化值类型的成员
            a.Add(p);    // 将值类型装箱,将引用添加到Arratlist中
        }
    }
}

每次循环迭代都初始化一个Point的值类字段,并将该Point存储到ArrarList中。ArrayList的add方法:

public virtual Int32 Add(Object value);

Add方法获取的是一个Object参数,也就是说,Add获取对托管堆上的一个对象的引用(或指针)来作为参数。但是之前的代码传递的是p,也就是Point,是值类型。为了使代码正确工作,Point值类型必须转换成真正的,在堆中托管的对象,而且必须获取对该对象的引用。

将值类型转换为引用类型,需要使用装箱机制,该机制所发生的事情如下总结:

  • 在托管堆中分配内存。分配的内存量是值类型各字段所需的内存量,还要加上托管堆所有对象都有两个额外成员(类型对象指针和同步块索引)所需的内存量。
  • 值类型的字段复制到新分配的堆内存。
  • 返回对象地址。现在该地址是对象引用;值类型成了引用类型。

注意:因为ArrayList会把所有插入其中的数据当作为object类型来处理,在我们使用ArrayList处理数据时,很可能会报类型不匹配的错误,也就是ArrayList不是类型安全的。可以用C#泛型来指定类型安全。

拆箱

讲完装箱,讲讲拆箱,如下代码

Point p=(Point) a[0];

获取ArrayList的元素0包含的引用(或指针),试图将其放到Point值类型的实例p中。为此,已装箱Point对象中的所有字段都必须复制到值类型变量p中,后者在线程栈上,CLR分2步完成复制:

  • 获取已装箱Point对象中的各个Point字段地址。这个过程称为拆箱
  • 将字段包含的值从堆复制到基于栈的值类型实例当中

拆箱不是直接将装箱过程倒过来。拆箱的代价要比装箱低的多。拆箱其实就是获取指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。其实,指针指向的是已装箱实例中的未装箱部分。所以和装箱不同,拆箱不要求在内存中复制任何字节。

装箱和拆箱显然会对应用程序的速度和内存消耗产生不利影响,所以应留意编译器在什么时候生成代码来自动进行这些操作。并尝试手动编写代码,尽量减少这种情况的发生。