C#查缺补漏

此文为阅读dalao的csdn博客所作之笔记。挑一些自己不太清楚的点记下来。

此文可以说是躺在巨人的肩膀上。

01 值类型和引用类型

C#中的类型分为值类型和引用类型。其分类大概如下图:

所在位置

位置分为两种:

  1. 线程栈
  2. 托管堆

栈分配的速度快,其中的变量一旦跨出作用域就会失效。

值类型不一定都在栈上,但引用类型的内容肯定在堆上。当值类型是作为某个类的成员时,就会被分配在堆上。

因故,struct类型也非一定会被分配在栈上。

参数传递

参数的传递用的都是值传递。

传递值类型的时候直接复制一遍值,传递引用的时候把引用(指向引用内容的指针)复制一遍。

例外是使用out和ref的时候,复制的是指向引用的引用(类似于指向指针的指针),所以看起来就像是把引用也传递了过去。

struct

struct不支持new,且不能有无参的构造方法。

02 装箱与拆箱

装箱就是把值类型变成引用类型,主要步骤是:

  1. 在堆上申请一块内存,大小与值类型一样大
  2. 复制值类型的值到堆上的那块内存上
  3. 返回堆内存的地址给引用

消耗

装箱的消耗要比拆箱大。这是由于装箱的时候需要在堆上开辟内存,而拆箱是把值写入栈中。对栈的操作比对堆的操作要快。

建议多使用泛型,少使用Object

当想让参数支持多类型的时候,尽量使用泛型,少使用Object,可以避免不必要的装箱操作。

03 string

string是一种特殊的引用类型,不能new。

string的不变性

CLR会维持一个string驻留池(实质上是一个哈希表),当新建一个需要驻留的(也有不用驻留的string)string的时候,会先根据其hash值判断是否已存在该字符串,如果存在,则直接返回该字符串的引用。这说明string驻留池里必定不可能存在两个内容一样的string。

1
2
3
4
string a = "123";
string b = "123";
Console.WriteLine(System.Object.Equals(a, b)); // true,对象内容相同
Console.WriteLine(System.Object.ReferenceEquals(a, b)); // true,引用的内存地址相同

有些string会被驻留,有些则不会,这可以大大减小爆内存的可能性。那么,被驻留的条件是什么呢?

被驻留的string有两种:

  1. 客户主动创建的,如string a = "a"
  2. 客户调用了string.Intern(string content)后的

被大多数系统API创建的string不会被驻留,比如

1
2
3
4
5
6
int a = 1;
string strA = a.ToString();
string b = "b";
string strB = b.ToLower();
Console.WriteLine(string.IsIntern(strA)); // false
Console.WriteLine(string.IsIntern(strB)); // false

其中,string string.IsIntern(string);方法会判断string是否被驻留,被驻留的话会返回string本身,否则返回null(不是返回布尔值)。

效率

小剂量操作时,用string.concat(),大剂量操作时,用StringBuilder

考虑如何最高效率反转字符串

  1. 使用capacity大小刚好的StringBuilder
  2. 直接使用char[]

04 对象

1
2
3
4
5
6
int a = 1;
int b = 2;
var typeA = a.GetType();
var typeB = b.GetType();
Console.WriteLine(System.Object.Equals(typeA, typeB)); // true
Console.WriteLine(System.Object.ReferenceEquals(typeA, typeB)); // true

以上两个输出都是true,代表着所有同类型的对象GetType()获取到的类对象是同一个实例。

Load Heap

Load Heap中文加载堆,上面分配内存的生命周期从AppDomain一直延续到结束,不受GC回收控制。

GCHeap上分配的对象内存中,包括了三部分:成员变量,SyncBlockIndex,TypeHandler。其中的TypeHandler又指向了LoadHeap上的一块内存,这块内存上又有两个重要的部分:静态成员变量,类方法表。

类类型(System.Type)在程序一开始就被加载到LoadHeap中,所有该类型实例的TypeHandler都指向同一个唯一的类对象。

方法表与方法覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class A
{
void virtual Func()
{
Console.WriteLine("A");
}
}

class B1 : A
{
void override Func()
{
Console.WriteLine("B1");
}
}

class B2 : A
{
void new Func()
{
Console.WriteLine("B2");
}
}

// 调用
B1 b1 = new B1();
B2 b2 = new B2();
b1.Func(); // B1
b2.Func(); // B2
A a1 = b1;
A a1 = b2;
a1.Func(); // B1
a2.Func(); // A

我们看上面这个例子,结论是new和override都可以定义一个和基类同名的方法,但是当使用基类引用派生类的时候,采用new关键字的会调用基类的方法,而采用override的会调用派生类对应的方法。

为什么会这样呢?首先要看一下上面提到的方法表。

方法表中方法的加载顺序如下:

  1. 万物祖先System.Object的虚方法ToString(), Hash()等
  2. 基类的虚方法和非虚方法
  3. 派生类的方法
  4. 派生类的静态构造函数.cctor和对象构造函数.ctor

根据上面的顺序,我们可以得到结论B1和B2的方法表如下:

B1方法表

  1. ToString(), Hash()…
  2. A.Func() => B.Func
  3. B.Func()
  4. B.cctor B.ctor

B2方法表

  1. ToString(), Hash()…
  2. A.Func()
  3. B.Func()
  4. 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方法。

  1. ToString(), Hash()…
  2. A.Func() => B.Func <= 基类A引用,从这里开始找方法
  3. B.Func()
  4. B.cctor B.ctor

B2方法表

  1. ToString(), Hash()…
  2. A.Func() <= 基类A引用,从这里开始找方法
  3. B.Func()
  4. B.cctor B.ctor

抽象类

直接摘抄大佬的文章:

抽象类提供多个派生类共享基类的公共定义,它既可以提供抽象方法,也可以提供非抽象方法。抽象类不能实例化,必须通过继承由派生类实现其抽象方法,因此对抽象类不能使用new关键字,也不能被密封

基本特点:

  • 抽象类使用Abstract声明,抽象方法也是用Abstract标示;
  • 抽象类不能被实例化;
  • 抽象方法必须定义在抽象类中;
  • 抽象类可以继承一个抽象类;
  • 抽象类不能被密封(不能使用sealed);
  • 同类Class一样,只支持单继承;

接口

接口同样不能被实例化,只能声明函数和属性,不能包含成员变量(这一点与抽象类不同)。

05 常量、字段、属性、特性、委托

常量

const常量符合几个特性:

  1. 在编译的时候确定的,在编译完的时候就会把常量替换到代码里面去,得到的代码是常量全被替换成实际内容的代码
  2. 因此如果只是部分编译,有可能出现你改动了常量,但是其他未被重新编译的旧代码的常量值可能就是旧值,容易引发谜之bug
  3. 常量只支持内建类型,比如int, double, float, char, bool等

建议使用readonly来代替const

相对于const,使用static readonly有几个好处:

  1. 支持丰富多样的类型,随便new一个对象都行
  2. 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
2
3
4
5
6
List<Action> acs = new List<Action>(5);
for (int i = 0; i < 5; i++)
{
acs.Add(() => { Console.WriteLine(i); });
}
acs.ForEach(ac => ac()); // 输出了 5 5 5 5 5

原因在于所有的lambda表达式都引用了for作用域内的同一个i,所以i会被提取出来作为一个upvalue。

之前在python上也遇到过类似的问题,这个属于编译器的优化,不了解的话这个就变成了负优化。

改进:

1
2
3
4
5
6
7
List<Action> acss = new List<Action>(5);
for (int i = 0; i < 5; i++)
{
int m = i;
acss.Add(() => { Console.WriteLine(m); });
}
acss.ForEach(ac => ac()); // 输出了 0 1 2 3 4

很明显m的作用域在每一次循环里面,每个匿名变量引用的并不是同一个i,所以值会不一样。

06 GC与内存管理

一个对象的创建包括以下步骤:

  1. 计算对象大小
  2. 内存申请
  3. 检查托管堆内存是否足够
    1. 如果足够,不做什么
    2. 如果内存不足,执行GC
  4. 为对象分配内存,并初始化TypeHandler和同步索引块
  5. 调用对象的构造函数
  6. NextObjPtr后移
  7. 返回对象内存地址

计算大小

1
2
3
4
5
6
7
8
public class User
{
public int Age { get; set; }
public string Name { get; set; }

public string _Name = "123" + "abc";
public List<string> _Names;
}
  • 属性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。

垃圾回收基本步骤有三个:

  1. 标记:从程序根指针Root开始遍历,生成一个可访达图
  2. 清除:清除所有不可访达对象
  3. 压缩:清除不可访达对象之后内存会变得不连续,这时需要移动所有可访达的对象,让内存重新变得连续,然后更改引用里的指针地址

代龄

代龄(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条目有说明白。

Buy Me A Coffee / 捐一杯咖啡的钱
分享这篇文章~
0%
//