序
此文为阅读dalao的csdn博客所作之笔记。挑一些自己不太清楚的点记下来。
此文可以说是站躺在巨人的肩膀上。
01 值类型和引用类型
C#中的类型分为值类型和引用类型。其分类大概如下图:
所在位置
位置分为两种:
- 线程栈
- 托管堆
栈分配的速度快,其中的变量一旦跨出作用域就会失效。
值类型不一定都在栈上,但引用类型的内容肯定在堆上。当值类型是作为某个类的成员时,就会被分配在堆上。
因故,struct类型也非一定会被分配在栈上。
参数传递
参数的传递用的都是值传递。
传递值类型的时候直接复制一遍值,传递引用的时候把引用(指向引用内容的指针)复制一遍。
例外是使用out和ref的时候,复制的是指向引用的引用(类似于指向指针的指针),所以看起来就像是把引用也传递了过去。
struct
struct不支持new,且不能有无参的构造方法。
02 装箱与拆箱
装箱就是把值类型变成引用类型,主要步骤是:
- 在堆上申请一块内存,大小与值类型一样大
- 复制值类型的值到堆上的那块内存上
- 返回堆内存的地址给引用
消耗
装箱的消耗要比拆箱大。这是由于装箱的时候需要在堆上开辟内存,而拆箱是把值写入栈中。对栈的操作比对堆的操作要快。
建议多使用泛型,少使用Object
当想让参数支持多类型的时候,尽量使用泛型,少使用Object,可以避免不必要的装箱操作。
03 string
string是一种特殊的引用类型,不能new。
string的不变性
CLR会维持一个string驻留池(实质上是一个哈希表),当新建一个需要驻留的(也有不用驻留的string)string的时候,会先根据其hash值判断是否已存在该字符串,如果存在,则直接返回该字符串的引用。这说明string驻留池里必定不可能存在两个内容一样的string。
1 | string a = "123"; |
有些string会被驻留,有些则不会,这可以大大减小爆内存的可能性。那么,被驻留的条件是什么呢?
被驻留的string有两种:
- 客户主动创建的,如
string a = "a"
- 客户调用了
string.Intern(string content)
后的
被大多数系统API创建的string不会被驻留,比如
1 | int a = 1; |
其中,string string.IsIntern(string);
方法会判断string是否被驻留,被驻留的话会返回string本身,否则返回null(不是返回布尔值)。
效率
小剂量操作时,用string.concat()
,大剂量操作时,用StringBuilder
。
考虑如何最高效率反转字符串
- 使用capacity大小刚好的
StringBuilder
- 直接使用
char[]
04 对象
1 | int a = 1; |
以上两个输出都是true,代表着所有同类型的对象GetType()获取到的类对象是同一个实例。
Load Heap
Load Heap中文加载堆,上面分配内存的生命周期从AppDomain一直延续到结束,不受GC回收控制。
GCHeap上分配的对象内存中,包括了三部分:成员变量,SyncBlockIndex,TypeHandler。其中的TypeHandler又指向了LoadHeap上的一块内存,这块内存上又有两个重要的部分:静态成员变量,类方法表。
类类型(System.Type)在程序一开始就被加载到LoadHeap中,所有该类型实例的TypeHandler都指向同一个唯一的类对象。
方法表与方法覆盖
1 | class A |
我们看上面这个例子,结论是new和override都可以定义一个和基类同名的方法,但是当使用基类引用派生类的时候,采用new关键字的会调用基类的方法,而采用override的会调用派生类对应的方法。
为什么会这样呢?首先要看一下上面提到的方法表。
方法表中方法的加载顺序如下:
- 万物祖先System.Object的虚方法ToString(), Hash()等
- 基类的虚方法和非虚方法
- 派生类的方法
- 派生类的静态构造函数.cctor和对象构造函数.ctor
根据上面的顺序,我们可以得到结论B1和B2的方法表如下:
B1方法表
- ToString(), Hash()…
A.Func()=> B.Func- B.Func()
- B.cctor B.ctor
B2方法表
- ToString(), Hash()…
- A.Func()
- B.Func()
- B.cctor B.ctor
可以注意到二者的区别是使用了override关键字的B1的方法表中,A的Func会被B的Func重写,这一步在加载方法表的时候发生。
执行就近原则
首先我们要明白,CLR在找一个方法的时候是从底下往上找的,和方法表的加载顺序相反。
当我们使用A引用来分别持有B1和B2对象的时候,搜寻方法的时候都是从上面的2往上搜寻方法。当找到B1中的A.Func()时,CLR注意到这个方法已经被B.Func()给override了,就会直接返回B.Func();而对于B2来说,A.Func()仍然原封不动地躺在那里,所以会直接返回基类A版本的Func方法。
- ToString(), Hash()…
A.Func()=> B.Func <= 基类A引用,从这里开始找方法- B.Func()
- B.cctor B.ctor
B2方法表
- ToString(), Hash()…
- A.Func() <= 基类A引用,从这里开始找方法
- B.Func()
- B.cctor B.ctor
抽象类
直接摘抄大佬的文章:
抽象类提供多个派生类共享基类的公共定义,它既可以提供抽象方法,也可以提供非抽象方法。抽象类不能实例化,必须通过继承由派生类实现其抽象方法,因此对抽象类不能使用new关键字,也不能被密封。
基本特点:
- 抽象类使用Abstract声明,抽象方法也是用Abstract标示;
- 抽象类不能被实例化;
- 抽象方法必须定义在抽象类中;
- 抽象类可以继承一个抽象类;
- 抽象类不能被密封(不能使用sealed);
- 同类Class一样,只支持单继承;
接口
接口同样不能被实例化,只能声明函数和属性,不能包含成员变量(这一点与抽象类不同)。
05 常量、字段、属性、特性、委托
常量
const常量符合几个特性:
- 在编译的时候确定的,在编译完的时候就会把常量替换到代码里面去,得到的代码是常量全被替换成实际内容的代码
- 因此如果只是部分编译,有可能出现你改动了常量,但是其他未被重新编译的旧代码的常量值可能就是旧值,容易引发谜之bug
- 常量只支持内建类型,比如int, double, float, char, bool等
建议使用readonly来代替const
相对于const,使用static readonly有几个好处:
- 支持丰富多样的类型,随便new一个对象都行
- readonly是运行时常量,只是代表赋值后无法再进行更改引用,不会被替换到代码之中,部分编译的时候没有上面提到的const的危险
枚举
枚举其实就是一个const常量集合,和const有一样的性质,会直接替换代码里的值,同样有部分编译引坑的危险。
成员变量
文中说到了成员初始化器,可以参见另一篇文章里面提到的内容。
属性
实质上是堆私有字段的封装。
自定义的属性get和set可以返回自己指定的私有字段。当然也可以什么也不指定,只写一个{set; get;}
1 | public int Id{ get; set; }; |
这种情况下,CLR会自动生成一个私有字段,以及getter和setter方法。
委托
delegate的本质是生成了一个继承于System.MulticastDelegate的类,类名与delegate的名字相同,其中包含一个Invoke方法,签名与delegate定义的方法相同(签名相同即意味着参数长度和类型相同,返回值相同)。
当传入一个delegate作为参数的时候,事实上是传入了生成的包含Invoke方法的这个实例。
所以我们可以说,传递delegate其实也是在传递类实例的引用。
闭包
这是C#闭包的一个坑。
1 | List<Action> acs = new List<Action>(5); |
原因在于所有的lambda表达式都引用了for作用域内的同一个i,所以i会被提取出来作为一个upvalue。
之前在python上也遇到过类似的问题,这个属于编译器的优化,不了解的话这个就变成了负优化。
改进:
1 | List<Action> acss = new List<Action>(5); |
很明显m的作用域在每一次循环里面,每个匿名变量引用的并不是同一个i,所以值会不一样。
06 GC与内存管理
一个对象的创建包括以下步骤:
- 计算对象大小
- 内存申请
- 检查托管堆内存是否足够
- 如果足够,不做什么
- 如果内存不足,执行GC
- 为对象分配内存,并初始化TypeHandler和同步索引块
- 调用对象的构造函数
- NextObjPtr后移
- 返回对象内存地址
计算大小
1 | public class User |
- 属性int类型32bit,4字节
- 属性Name,初始值NULL,4字节,指向空地址
- 成员变量_Name,本身作为指针占据4字节。两个字符串常量会被优化成一个
123abc
,每个char占据2字节,字符串内容总共占据12字节,再加上TypeHandler和同步索引块分别4字节,该string对象总共20字节。(string不是值类型,当然不会直接把内存分配在整个User之内,而是会分配在string驻留池中) List<string>
是一个引用类型,同样4字节- 附加成员TypeHandler和同步索引块分别4字节
GC
GC只负责回收GCHeap,而不会动到LoaderHeap。
垃圾回收基本步骤有三个:
- 标记:从程序根指针Root开始遍历,生成一个可访达图
- 清除:清除所有不可访达对象
- 压缩:清除不可访达对象之后内存会变得不连续,这时需要移动所有可访达的对象,让内存重新变得连续,然后更改引用里的指针地址
代龄
代龄(Generation)。.NET将GCHeap分为三个代龄区域以及一个大对象区域(大于85000 bytes)。大对象不会被移动。
- 第0代:最新分配在堆上的对象,没有经历过一次垃圾回收过程。任何一个非大对象刚被分配的时候都会被分配在第0代上。
- 第1代:当第0代满了的时候,会触发第0代的GC,回收后剩下的对象会被迁移到第1代。
- 第2代,当0代GC完内存还不够用,会继续触发1代的GC,其后第0代升为第1代,第1代升为第2代。
大多数情况下GC只需要回收第0代,可以显著提高GC效率。
线程相关
在GC执行回收的时候,首先要安全地挂起所有线程。GC回收后在恢复所有线程。所以线程越多,GC要做的事情就会越多。
非托管资源回收
什么是非托管资源?比如你新建了一个数据库连接,这个连接的一部分内存是分配在数据库软件里的,并不属于本程序管理,自然也就无法托管以及GC。使用完非托管资源之后,我们需要手动地调用非托管资源的释放方法,以保证资源能够正确释放。
关于这部分,之前写过一个笔记第17条目有说明白。