参考教学:https://b23.tv/GYnHVyU
C# GC
GC的作用
垃圾回收的作用是找出堆空间中哪些空间不在被程序使用,并且回收这些空间。
C# GC的原理
在.NET中使用的是最主流的“标记并清楚”的方式。也就是选择一个跟对象,递归的标记这个根对象下的所有引用类型成员,最后回收所有未被标记的成员。选择根对象的标准是这个根对象能够访问到所有程序当中的能够访问的对象。
根对象包括各个线程栈空间上的变量,全局变量,GC句柄和析构队列中的对象。
分代
dotnet将对象分为三类:第0代,第1代,第2代。
新分配的对象,如果不超过一定的大小,则分配为第0代。超过则分配为第二代。
一轮GC之后,第0代存活的对象会进入第1代,第1代存活的对象进入第2代。(某些例外的情况会导致对象不升代,甚至将代)
第0代对象存活的时间通常最短,第1代较长,第2代最长。也就是说,第0代对象最容易被回收,第2代最不容易被回收。
如果一次GC当中所有代都被回收,则成为完整GC。
通常来说第2代占用空间大(静态对象多,大对象,长期没有回收的对象,内存泄露)
分代的目的是,尽量增加每次执行GC时可回收对象的数量,并且减少处理所需时间。
压缩
反复执行分配和回收操作,可能导致堆上产生碎片空间。而压缩机制可以通过移动已分配的空间合并,使得堆可以分配更大的对象。
大小对象
dotnet根据引用类型对象值占用的空间大小(大概是大于等于85000byte)来分配是小对象还是大对象。
大对象和小对象会在不同的堆区域中分配。
分堆的原因是:
1.对大对象的处理需要更长的时间和更多的资源
2.移动大对象的成本高,所以压缩机制默认只对小对象启用,大对象堆一般不压缩
固定对象
托管代码:生成的exe文件中间语言代码(CIL MSIL)直接托管给CLR。
非托管代码:生成的exe文件可以由操作系统直接运行。
如果把一个引用类型的对象传递给非托管代码,那么这个对象的内存地址就会复制到非托管代码的区域中,此时dotnet运行时就无法得知这个对象的地址。此时就会导致dotnet运行无法得知非托管代码是否还在使用这个对象,以及压缩后托管代码中的对象地址改变,而这个改变无法同步更新到非托管代码中。
因此托管代码传递引用类型对象给非托管代码时必须创建引用类型的GC句柄,并且在托管代码中保证GC句柄存活到非托管代码的调用结束。创建了GC句柄的对象就成为固定对象。
在压缩操作时会避开固定对象,因此固定对象可能会带来更多内存碎片。并且固定对象在GC后可能会发生降代。
析构队列
由于可以通过托管代码在析构函数中添加自定义的操作,因此,在GC过程执行这些析构函数所需要的时间是不可预料的。
为了解决这个问题,dotnet定义了一个析构队列以及一个对应的析构线程。如果一个对象标记为不存活但是定义了析构函数,那么对象就会添加到析构队列并且标记为存活,不参与本轮GC。GC结束之后,析构线程启动,从析构队列中取出对象并执行析构函数。这些执行了析构函数的对象可以在下一轮GC中回收。
STW
对象的引用关系会随着程序运行不断改变。在GC线程与其他线程同时运行的过程中,可能导致一些问题。
为了防止问题发生,可以让除了GC线程外的所有线程都停止运行,这种操作就叫STW(Stop The World)
在dotnet中,只会在必要时刻执行STW,而且不会直接让线程停止,而是改变模式(合作模式,抢占模式)
工作站模式与服务器模式
工作站模式:适用于内存占用小的程序和桌面程序,可以提供更短的响应时间。 GC频繁,单线程,使用的是分配对象的线程
服务器模式:适用于内存占用量大的程序与服务程序,可以提供更大的吞吐量。 GC不频繁,多线程,独立线程
dotnet会根据实际情况决定使用哪一个模式,程序开始运行之后不可以更改。
普通GC与后台GC
普通GC会导致更长的单次STW停顿时间(执行完整过程),但是开销小,支持压缩处理,目标代为0,1,2代。线程根据情况决定
后台GC单次STW时间更短(执行部分过程),但是开销大,并且不支持压缩处理,目标代为第2代。独立线程
引用类型的数据结构
声明本地引用类型对象的地址在栈空间,声明引用类型对象的引用类型成员的地址在堆空间。
引用类型对象的值右三个部分组成:对象头,类型信息,各个字段的内容。
对象头
包含了标志和同步块索引等数据。
在32位平台上对象头为4个字节,64位平台为8个字节(只有后面四个字节会使用到)
其中高6位保存一些标志,低26位根据高6位而定。
高1位:用于dotnet运行中内部检查托管堆状态时,标记对象是否已经检查。
高2位:标记是否抑制运行对象的析构函数。
高3位:标记是否为固定对象。
高456位:标记低26位保存什么内容,包括获取锁,释放锁,对象Hash值
类型信息
每个引用类型的对象,都会保存一个类型信息:指向dotnet运行时内部保存的类型数据的类型地址。
类型数据包含了类型所属模块名称,字段列表,属性列表,方法列表,以及各个方法的入口点的地址信息。
dotnet中的反射机制,接口,虚方法都需要依赖类型数据。
dotnet的内存结构
非托管部分
dotnet运行时部分
AppDomain:
高频堆
低频堆
字符串池
函数入口代码堆
托管函数代码堆
托管堆:
小对象堆段
短暂堆段
大对象堆段
可重用堆段
托管堆和堆段
托管堆用于保存引用类型对象的值
堆段:一个预先分派大小的空间,每个堆段默认的大小根据GC模式运行环境的CPU逻辑核心数量来决定
堆段之间用链表的形式链接
堆段空间不足时会创建新的堆段
分配上下文
堆段分配内存的方式:
保存三个地址:开始地址,分配下个对象值的地址,结束地址。这种方式在多线程下需要保证线程安全(获取线程数,会造成开销)
dotnet会在分配时先获取线程数,并且为每个线程预留空间(分配上下文),专供线程使用,直到用尽为止。分配小对象时,会从短暂堆段或者自由分配列表预留分配上下文。
在分配大对象时,直接从大对象堆段分配,不会使用分配上下文。(减少自由空间浪费)
GC分代的实现
在dotnet中,托管堆每个区域的小对象堆有三个代,大对象堆有一个代(第二代),这些代通过generation类型的实例管理。
代的划分通过代的开始地址决定。使得升代变得简单,只需要变更地址。
dotnet要求每一代至少有一个对象,如果没有会创建一个很小的自由对象占位。
自由对象列表
GC执行的时候要清楚没有标记存活的对象,这些对象占用的空间需要释放或者在下一次分配空间的时候使用。dotnet堆段上的对象是连续的,所以被清除的对象就会变成特殊的数组对象,数组的元素大小为1,长度可以自由控制,总长度等于被清除对像的长度,用于标记这一段空间没有被使用。这就是自由对象。
相邻的自由对象会合并。
如果自由对象位于已分配空间,并且超过了一定的大小,就会被自由对象列表保存下来。
自由对象如果出现在已分配空间的末尾,就会释放给操作系统,所占空间被归为未分配空间。
释放的本质是接触虚拟内存页和物理内存页的关系。
堆段上自由对象所占的空间可以成为碎片空间。
如果一个堆段没有对象存活,就会被释放。(管理堆段的实例不会被释放)
托管堆的每个区域有四个自由对象列表:
第0代自由对象
第1代自由对象
第2代小对象堆段的自由对象
第2代大对象堆段的自由对象
分配上下文会先从第0代自由对象列表中获取空间。
分配大对象会从第2代大对象堆段的自由对象列表获取空间。
跨代引用记录
dotnet实现分代的主要原因是为了支持GC时只处理一部分对象,而且支持指定代进行GC。
跨代引用即某一代的引用类型对象成员指向了另一代对象。这样有可能导致漏标记存活的问题。
dotnet有一个数组(卡片表)专门记录跨代引用,会标记所有跨代引用的位置。GC时除了扫描某一代之外还会扫描卡片表。
卡片束记录卡片表中哪些位置有标记,先扫描卡片束再扫描卡片表就能减少处理时间。
卡片束在不同的GC模式下根据内存占用大小启用
析构对象和析构队列
为了支持处理包含了析构函数的对象,使得析构函数可以在后台调用而不影响GC运行,托管堆的每一个区域都有一个管理这些对象类型。(析构对象列表和析构队列)
GC流程
GC的触发
dotnet运行时只在特定条件触发GC:
分派对象时找不到可用的空间
分配量超过阈值
托管代码主动调用GC.Collect
收到物理内存不足的通知
分配对象时找不到可用空间
GC在分配上下文或者分配大对象时,会先尝试从自由对象列表查找可用的自由对象,再尝试从短暂堆段和最新的大对象堆段末尾分配。如果尝试均失败就会触发GC。
第1种:针对第一代的GC,尝试回收短暂堆段的对象。
第2种(完整GC):针对第二代的GC,在物理内存不足或者第一种GC后仍无法分配对象时触发。
分配量超过阈值
托管堆分为多个区域,每个区域有四个实例,每个实例包含了静态数据(包含分配量阈值的上下限)和动态数据(分配量阈值)
如果某个代分配的对象值的大小合计超过分配量阈值,就会触发针对这个代的GC。
分配量阈值会在每次GC结束之后重新计算。
GCCollect
四个参数:
int Generation
enum mode
bool blocking
bool compacting