前言

对于C#的内存模型而言 CLR是最主要的处理内存的”中心”

类比下,C#是司机,.Net是汽车,CLR是引擎

本文可能后续还有补充,目前以GC为核心解析

基础概念

  • .Net:以下这些技术的统称
  • .Net Framework/.Net Core/Mono:3种将.Net技术打包发行的方式,其选择的组件不一定相同
  • CLR:Common Language Runtime 一个执行引擎,用于进行一类程序(CLI),提供类型系统、垃圾回收、JIT等功能;目前有3个主要实现,分别为coreclr用于.Net Core、desktop clr用于.Net Framework(有了coreclr之后才获得的一个相对”名称)、Mono(没人给其中的runtime部分单独取名)
  • CLI:Common Language Infrastructure 一个经标准化组织认证的标准(ECMA-335),定义了CLR的基础功能和其上执行的程序的标准
  • IL:Intermediate Language 用于将程序直接输入CLR的格式,包含二进制格式与其对应的文本格式;IL中的任何构造都与CLR内部严格一一对应;因为早期设计预留了自定义特性的空间,所以其版本十余年不曾变化,各种新功能都使用自定义特性来表示
  • CoreFX:随CLR一同发行的发行的一组子程序与类型,任何在CLR上运行的程序皆可调用;其中最底层的部分不能完全用IL表示,有些则是对CLR自身功能的调用,而上层可能依赖对底层的假设,因此其版本与CLR的版本是绑定的
  • C# :一个编程语言,使用C语系风格;除了IL能直接表示的基本功能外,还提供了一些经过一定封装的功能;每个功能都可能需要假定CoreFX提供了特定的类型来完成;另两个对应的、由第一方支持的编程语言为F#和VB,其功能集和C#不完全一致
  • Roslyn:第一方(标准制定者)实现的编译器,Mono也实现了一个(mcs),二者共同遵循C#语言标准
  • .Net Standard:在不同的发行之间为CoreFX的公开接口(即使用方法)及行为规定标准,但不关心其具体实现细节,也允许每个发行提供一些额外内容

了解了一些基础概念(后面也许也用不到bushi),开始解析CLR这个”引擎” 做了什么事情吧

一.值类型与引用类型

1.1 基本概念

1
CLR支持两只类型:引用类型和值类型。这是.NET语言的基础和关键,他们从类型定义、实例创建、参数传递,到内存分配都有所不同。虽然看上去简单,但真正理解其内涵的人却好像并不多。

从上表简单来说就是class(引用类型)和struct(值类型)的区别

1.2 内存结构

内存分布 信息
全局数据区域 static ,类型信息
heap 进程初始化后在进程地址空间上划分的内存空间,存储.NET运行过程中的对象,所有的引用类型都分配在托管堆上,托管堆上分配的对象是由GC来管理和释放的。托管堆是基于进程的,当然托管堆内部还有其他更为复杂的结构,有兴趣的可以深入了解。
stack 线程栈,由操作系统管理,存放值类型、引用类型变量(就是引用对象在托管堆上的地址)。栈是基于线程的,也就是说一个线程会包含一个线程栈,线程栈中的值类型在对象作用域结束后会被清理,效率很高。
Readonly 代码区,Const

1.3 对象的传递

值类型 和引用类型的区别

  • 结构体 是值类型,Class 是引用类型

  • 在函数调用内部指令中,struct 直接在栈中进行内存分配,class 是在堆中进行内存分配

  • 栈中的内存,会在函数调用完成后回收,堆中的内存由c#运行时自动回收(GC)

  • 在函数调用过程中,class 拷贝只是拷贝地址,struct 是所有数据都拷贝(深拷贝)

  • class在创建实例new的时候是在heap中分配内存,在heap中存储数据,在stack中存储那片内存是存储heap地址,返回的是stack中那片地址的内存,引用变量存的是地址

  • struct创建实例是在stack中分配内存,在stack中是直接存储的是数据,值变量存的是直接的数据

  • 如果需要将struct 的引用传递给函数内部,则需要使用关键字ref(reference引用)相当于Vector&v(c++),或者*v(c)

1.4 out 和 ref的主要异同:

  • out 和 ref都指示编译器传递参数地址,在行为上是相同的;
  • 他们的使用机制稍有不同,ref要求参数在使用之前要显式初始化,out要在方法内部初始化;
  • out 和 ref不可以重载,就是不能定义Method(ref int a)和Method(out int a)这样的重载,从编译角度看,二者的实质是相同的,只是使用时有区别

    二.拆箱和装箱

2.1 基础概念

箱子:引用类型对象
装箱:装箱就是值类型转换为引用类型,
拆箱:拆箱就是引用类型(被装箱的对象)转换为值类型。

2.2 过程

1
2
3
int x = 1023;
object o = x; //装箱 值->引用
int y = (int) o; //拆箱 引用->值

装箱:
1.在堆中申请内存,内存大小为值类型的大小,再加上额外固定空间(引用类型的标配:TypeHandle和同步索引块;
2.将值类型的字段值(x=1023)拷贝新分配的内存中;
3.返回新引用对象的地址(给引用变量object o)

拆箱:
1.检查实例对象(object o)是否有效,如是否为null,其装箱的类型与拆箱的类型(int)是否一致,如检测不合法,抛出异常;
2.指针返回,就是获取装箱对象(object o)中值类型字段值的地址;
3.字段拷贝,把装箱对象(object o)中值类型字段值拷贝到栈上,意思就是创建一个新的值类型变量来存储拆箱后的值。

2.3 隐式装箱

不经意的代码导致多次重复的装箱操作

1
2
3
4
5
6
7
8
9
int x = 100;
ArrayList arr = new ArrayList(3);
arr.Add(x);
arr.Add(x);
arr.Add(x);

//这边Add的定义为
public virtual int Add(object value);

在上面的代码会有三次装箱(三次的值类型传入Add的引用类型的函数中)

此时显式装箱就可以避免隐式装箱,下面修改的代码就只有一次装箱了

1
2
3
4
5
6
int x = 100;
ArrayList arr = new ArrayList(3);
object o = x;
arr.Add(o);
arr.Add(o);
arr.Add(o);

2.4 如何避免隐式装箱

编码中,多使用泛型、显示装箱。

三.string与字符操作

3.1 基础概念

1
首先需要明确的,string是一个引用类型,其对象值存储在托管堆中。string的内部是一个char集合,他的长度Length就是字符char数组的字符个数。string不允许使用new string()的方式创建实例,而是另一种更简单的语法,直接赋值(string aa= “000”这一点也类似值类型)

3.2 string 的恒定性

恒定性:任何改变都会产生新的字符串

1
2
string s1=“a”;
string s2=s1+”b”;

在堆中会存在着 :“a” “b” “ab”三个对象

3.3 string 的驻留性

驻留性:在生成新的字符串对象时,会检查堆中是否存在同一值的对象

1
2
3
4
var s1 = "123";
var s2 = "123";
Console.WriteLine(System.Object.Equals(s1, s2)); //输出 True
Console.WriteLine(System.Object.ReferenceEquals(s1, s2)); //输出 True

此时s1,s2同时指向一块内存区域

相同的字符串在内存(堆)中只分配一次,第二次申请字符串时,发现已经有该字符串是,直接返回已有字符串的地址,这就是驻留的基本过程。

字符串驻留的基本原理:

1
2
3
1. CLR初始化时会在内存中创建一个驻留池,内部其实是一个哈希表,存储被驻留的字符串和其内存地址。
2. 驻留池是进程级别的,多个AppDomain共享。同时她不受GC控制,生命周期随进程,意思就是不会被GC回收(不回收!难道不会造成内存爆炸吗?不要急,且看下文)
3. 当分配字符串时,首先会到驻留池中查找,如找到,则返回已有相同字符串的地址,不会创建新字符串对象。如果没有找到,则创建新的字符串,并把字符串添加到驻留池中。

如果大量的字符串都驻留到内存里,而得不到释放,不是很容易造成内存爆炸吗,当然不会了?因为不是任何字符串都会驻留,只有通过IL指令ldstr创建的字符串才会留用。
字符串创建有多种方式:

1
2
3
4
5
var s1 = "123";
var s2 = s1 + "abc";
var s3 = string.Concat(s1, s2);
var s4 = 123.ToString();
var s5 = s2.ToUpper();

在上面的代码中,出现两个字符串常量,“123”和“abc”,这个两个常量字符串在IL代码中都是通过IL指令ldstr创建的,只有该指令创建的字符串才会被驻留,其他方式产生新的字符串都不会被驻留,也就不会共享字符串了,会被GC正常回收。

3.4 StringBuilder可变字符串

首先StringBuilder内部同string一样,有一个char[]字符数组,负责维护字符串内容。因此,与char数组相关,就有两个很重要的属性:

  1. public int Capacity:StringBuilder的容量,其实就是字符数组的长度。
  2. public int Length:StringBuilder中实际字符的长度,>=0,<=容量Capacity。
  3. StringBuilder之所以比string效率高,主要原因就是不会创建大量的新对象,StringBuilder在以下两种情况下会分配新对象:
  • 追加字符串时,当字符总长度超过了当前设置的容量Capacity,这个时候,会重新创建一个更大的字符数组,此时会涉及到分配新对象。
  • 调用StringBuilder.ToString(),创建新的字符串。

追加字符串的过程:

  • StringBuilder的默认初始容量为16;
  • 使用stringBuilder.Append()追加一个字符串时,当字符数大于16,StringBuilder会自动申请一个更大的字符数组,一般是倍增;
  • 在新的字符数组分配完成后,将原字符数组中的字符复制到新字符数组中,原字符数组就被无情的抛弃了(会被GC回收);
  • 最后把需要追加的字符串追加到新字符数组中;

简单来说,当StringBuilder的容量Capacity发生变化时,就会引起托管对象申请、内存复制等操作,带来不好的性能影响,因此设置合适的初始容量是非常必要的,尽量减少内存申请和对象创建。

3.5 string和stringBuilder的区别

  • 少量字符串连接,使用String.Concat,大量字符串使用StringBuilder,因为StringBuilder的性能更好,如果string的话会创建大量字符串对象。
  • 少量字符串时,尽量不要用,StringBuilder本身是有一定性能开销的;
  • 大量字符串连接使用StringBuilder时,应该设置一个合适的容量;

四 GC与内存管理

4.1对象创建及生命周期

一个对象的生命周期简单概括就是:创建>使用>释放,在.NET中一个对象的生命周期:

  • new创建对象并分配内存
  • 对象初始化
  • 对象操作、使用
  • 资源清理(非托管资源)
  • GC垃圾回收

创建新对象的主要流程

1
2
3
4
5
6
7
8
9
对象大小结算 【如Int(4)】
内存申请 【从指针NextObjPtr开始验证,空间是否足够,若不够则触发垃圾回收。】
托管堆检查(GC Heap检查)
if 内存不足:
GC内存回收
分配内存 【从指针NextObjPtr处开始划分44个字节内存块。】
调用构造函数初始化 【首先初始化对象附加成员,再调用User对象的构造函数,对成员初始化,值类型默认初始为0,引用类型默认初始化为NULL;】
NextObjPtr后移 【托管堆指针NextObjPtr后移44个字节。】
返回内存地址 【返回对象的内存地址给引用变量】

4.2 什么是GC

1
GC如其名,就是垃圾收集,当然这里仅就内存而言。Garbage Collector(垃圾收集器,在不至于混淆的情况下也成为GC)以应用程序的root为基础,遍历应用程序在Heap上动态分配的所有对象[2],通过识别它们是否被引用来确定哪些对象是已经死亡的哪些仍需要被使用。已经不再被应用程序的root或者别的对象所引用的对象就是已经死亡的对象,即所谓的垃圾,需要被回收。这就是GC工作的原理。为了实现这个原理,GC有多种算法。比较常见的算法有Reference Counting,Mark Sweep,Copy Collection等等。目前主流的虚拟系统.net CLR,Java VM和Rotor都是采用的Mark Sweep算法。

4.3 Mark-Compact 标记压缩算法

简单把.NET的GC算法看作Mark-Compact算法
阶段1: Mark-Sweep 标记清除阶段
先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的
阶段2: Compact 压缩阶段
对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列,类似于磁盘空间的碎片整理

4.4 Generational 分代算法

程序可能使用几百M、几G的内存,对这样的内存区域进行GC操作成本很高,分代算法具备一定统计学基础,对GC的性能改善效果比较明显

1
将对象按照生命周期分成新的、老的,根据统计分布规律所反映的结果,可以对新、老区域采用不同的回收策略和算法,加强对新区域的回收处理力度,争取在较短时间间隔、较小的内存区域内,以较低成本将执行路径上大量新近抛弃不再使用的局部对象及时回收掉

分代算法的假设前提条件:

  1. 大量新创建的对象生命周期都比较短,而较老的对象生命周期会更长
  2. 对部分内存进行回收比基于全部内存的回收操作要快
  3. 新创建的对象之间关联程度通常较强。heap分配的对象是连续的,关联度较强有利于提高CPU cache的命中率
  • .NET将heap分成3个代龄区域: Gen 0、Gen 1、Gen 2

代龄过程:

  • Heap分为3个代龄区域,相应的GC有3种方式: # Gen 0 collections, # Gen 1 collections, #Gen 2 collections。
  • 如果Gen 0 heap内存达到阀值,则触发0代GC,0代GC后Gen 0中幸存的对象进入Gen1。
  • 如果Gen 1的内存达到阀值,则进行1代GC,1代GC将Gen 0 heap和Gen 1 heap一起进行回收,幸存的对象进入Gen2。
  • 2代GC将Gen 0 heap、Gen 1 heap和Gen 2 heap一起回收

对于代龄的内存分配与开销

1
2
Gen 0和Gen 1比较小,这两个代龄加起来总是保持在16M左右;Gen2的大小由应用程序确定,可能达到几G,因此0代和1代GC的成本非常低,2代GC称为fullGC,通常成本很高。粗略的计算0代和1代GC应当能在几毫秒到几十毫秒之间完成,Gen 2 heap比较大时fullGC可能需要花费几秒时间。大致上来讲.NET应用运行期间2代、1代和0代GC的频率应当大致为1:10:100。

五 非托管资源回收

1
.NET中提供释放非托管资源的方式主要是:Finalize() 和 Dispose()。

5.1 Disposable

.NET的GC机制有这样两个问题:

  • 首先,GC并不是能释放所有的资源。它不能自动释放非托管资源。

  • 第二,GC并不是实时性的,这将会造成系统性能上的瓶颈和不确定性。

GC并不是实时性的,这会造成系统性能上的瓶颈和不确定性。所以有了IDisposable接口,IDisposable接口定义了Dispose方法,这个方法用来供程序员显式调用以释放非托管资源。使用using 语句可以简化资源管理。

下面是一个简单的IDisposable接口实现方式。

1
2
3
4
5
6
7
8
9
public class SomeType : IDisposable
{
public MemoryStream _MemoryStream;
public void Dispose()
{
if (_MemoryStream != null) _MemoryStream.Dispose();
}
}

Dispose需要手动调用,在.NET中有两中调用方式:

1
2
3
4
5
6
7
8
9
10
//方式1:显示接口调用
SomeType st1=new SomeType();
//do sth
st1.Dispose();

//方式2:using()语法调用,自动执行Dispose接口
using (var st2 = new SomeType())
{
//do sth
}

第一种方式,显示调用,缺点显而易见,如果程序猿忘了调用接口,则会造成资源得不到释放。或者调用前出现异常,当然这一点可以使用try…finally避免。

一般都建议使用第二种实现方式,他可以保证无论如何Dispose接口都可以得到调用,原理其实很简单,using()的IL代码如下图,因为using只是一种语法形式,本质上还是try…finally的结构。

5.2 Finalization Queue和Freachable Queue

  • 这两个队列和.net对象所提供的Finalize方法有关。
  • 这两个队列并不用于存储真正的对象,而是存储一组指向对象的指针。
  • 当程序中使用了new操作符在Managed Heap上分配空间时,GC会对其进行分析,如果该对象含有Finalize方法则在Finalization Queue中添加一个指向该对象的指针。
  • 在GC被启动以后,经过Mark阶段分辨出哪些是垃圾。再在垃圾中搜索,如果发现垃圾中有被Finalization Queue中的指针所指向的对象,则将这个对象从垃圾中分离出来,并将指向它的指针移动到Freachable Queue中。
  • 这个过程被称为是对象的复生(Resurrection),本来死去的对象就这样被救活了。为什么要救活它呢?因为这个对象的Finalize方法还没有被执行,所以不能让它死去
  • Freachable Queue平时不做什么事,但是一旦里面被添加了指针之后,它就会去触发所指对象的Finalize方法执行,之后将这个指针从队列中剔除,这是对象就可以安静的死去了。
  • .net framework的System.GC类提供了控制Finalize的两个方法,ReRegisterForFinalize和SuppressFinalize。
  • 前者是请求系统完成对象的Finalize方法,后者是请求系统不要完成对象的Finalize方法。
  • ReRegisterForFinalize方法其实就是将指向对象的指针重新添加到Finalization Queue中。
  • 这就出现了一个很有趣的现象,因为在Finalization Queue中的对象可以复生,如果在对象的Finalize方法中调用ReRegisterForFinalize方法,这样就形成了一个在堆上永远不会死去的对象,像凤凰涅槃一样每次死的时候都可以复生。
1
首先了解下Finalize方法的来源,她是来自System.Object中受保护的虚方法Finalize,无法被子类显示重写,也无法显示调用,是不是有点怪?。她的作用就是用来释放非托管资源,由GC来执行回收,因此可以保证非托管资源可以被释放。
  • 无法被子类显示重写:.NET提供类似C++析构函数的形式来实现重写,因此也有称之为析构函数,但其实她只是外表和C++里的析构函数像而已。
  • 无法显示调用:由GC来管理和执行释放,不需要手动执行了,再也不用担心猿们忘了调用Dispose了。

所有实现了终结器(析构函数)的对象,会被GC特殊照顾,GC的终止化队列跟踪所有实现了Finalize方法(析构函数)的对象。

  • 当CLR在托管堆上分配对象时,GC检查该对象是否实现了自定义的Finalize方法(析构函数)。如果是,对象会被标记为可终结的,同时这个对象的指针被保存在名为终结队列的内部队列中。终结队列是一个由垃圾回收器维护的表,它指向每一个在从堆上删除之前必须被终结的对象。
  • 当GC执行并且检测到一个不被使用的对象时,需要进一步检查“终结队列”来查询该对象类型是否含有Finalize方法,如果没有则将该对象视为垃圾,如果存在则将该对象的引用移动到另外一张Freachable列表,此时对象会被复活一次。
  • CLR将有一个单独的高优先级线程负责处理Freachable列表,就是依次调用其中每个对象的Finalize方法,然后删除引用,这时对象实例就被视为不再被使用,对象再次变成垃圾。
  • 下一个GC执行时,将释放已经被调用Finalize方法的那些对象实例。
  • 1
    简单总结一下:Finalize()可以确保非托管资源会被释放,但需要很多额外的工作(比如终结对象特殊管理),而且GC需要执行两次才会真正释放资源。听上去好像缺点很多,她唯一的优点就是不需要显示调用。

5.3 性能优化建议

  • 关于GC.Collect() 方法

作用:强制进行垃圾回收。

垃圾回收的运行成本较高(涉及到了对象块的移动、遍历找到不再被使用的对象、很多状态变量的设置以及Finalize方法的调用等等),对性能影响也较大,因此我们在编写程序时,应该避免不必要的内存分配,也尽量减少或避免使用GC.Collect()来执行垃圾回收,一般GC会在最适合的时间进行垃圾回收。

而且还需要注意的一点,在执行垃圾回收的时候,所有线程都是要被挂起的(如果回收的时候,代码还在执行,那对象状态就不稳定了,也没办法回收了)。

  • 推荐Dispose代替Finalize

如果你了解GC内存管理以及Finalize的原理,可以同时使用Dispose和Finalize双保险,否则尽量使用Dispose。

  • 选择合适的垃圾回收机制:工作站模式、服务器模式

C# GC机制

六 关于OOP

此部分讲解:类型 方法 继承

类别 描述 示例代码/概念
值类型 Struct默认继承自object struct MyStruct { /* ... */ }
引用类型 Class默认继承自object public class MyClass { /* ... */ }
接口 声明我是什么样子的类型(类型规范),不实现 public interface ILifeCycle { /* ... */ }
抽象类 不能实例化,可以包含抽象方法,可以暂时不实现接口,等待子类实现 public abstract partial class Actor : ILifeCycle { /* ... */ }
继承 Class只能继承1或0个基类,但可以继承多个接口 public class Mage : Actor, ILifeCycle, IDestroy { /* ... */ }
结构体与继承 Struct不能继承Struct或Class,但可以继承接口 struct MyStruct : IMyInterface { /* ... */ }
变量存储 父类变量可以存储子类变量(地址),不能反向存储 Actor actor = new Mage();
面向对象概念 子类是一种特殊的父类 A is Human, B is Teacher. 学生是人,但人不一定都是老师.

C# 中的分部类(Partial Classes)是一种特殊的类定义方式,它允许一个类的定义被分割到多个文件中。这对于一些大型类,或者当类的定义分布在多个自动生成的代码文件中(例如,由设计器或某些工具生成的代码)时,特别有用。

分部类的定义方式很简单,只需在类名前加上 partial 关键字即可。下面是一个简单的例子:

文件 MyClass1.cs

1
2
3
4
5
6
7
8
9
10
namespace MyNamespace
{
public partial class MyClass
{
public void Method1()
{
// 方法实现
}
}
}

文件 MyClass2.cs

1
2
3
4
5
6
7
8
9
10
namespace MyNamespace
{
public partial class MyClass
{
public void Method2()
{
// 方法实现
}
}
}

在上面的例子中,MyClass 是一个分部类,它的定义被分割到了两个文件中:MyClass1.csMyClass2.cs。这两个文件都位于同一个命名空间 MyNamespace 下,并且都定义了同一个名为 MyClass 的类。

编译器在编译时,会将所有具有相同名称和命名空间的分部类定义合并成一个完整的类定义。因此,在上面的例子中,MyClass 类将包含 Method1Method2 两个方法。

需要注意的是,分部类不能分割到不同的命名空间或项目中。此外,分部类主要用于设计器生成的代码或自动代码生成工具,不建议在普通的手动编写的代码中使用。