目录
GC是什么
- 在程序运行过程中,需要内存去保存各种各样的数据,比如说打开文件的句柄,从文件读取字符串, 从字符串转换到数值,这些所谓的数据都会占用内存空间,在不使用这些数据后,应该及时去释放它 们占用的内存空间,否则系统的可用内存空间会越来越少。
- .NET程序可以找出某个时间点上哪些已分配的内存空间没有被程序使用,并自动释放它们。
- 垃圾回收机制主要的工作就是找出堆空间分配的空间中哪些空间不再被程序使用,然后回收这些空间。
栈空间/堆空间
- 每个线程都有独立的栈空间,栈空间用于保存调用函数的数据。
- 如果说某一个数据,只在某个函数中使用,那么可以把数据定义为这个函数的一个本地变量,这样它 就会随着函数一起分配,并且随着函数的返回一起释放,但是不是所有的数据都可以跟随函数去返回 释放的,因为总会有一部分的数据需要在函数返回后继续使用,还有一部分数据需要在多个线程中同 时使用,那么这些数据都是不能从栈空间去分配的。这些数据被分配到堆空间。
- 堆空间是程序中一块独立的空间,从堆空间分配的数据可以被程序中的所有函数和线程访问,并且不 会随着函数的返回与线程的结束而释放。
值类型/引用类型
- 值类型的对象本身存储的就是值。
- 引用类型的对象本身存储的是内存地址,值存储在内存地址指向的堆空间中。
- 值类型的对象会根据定义的位置隐式分配与释放。
- 引用类型的对象需要通过new关键字显示分配,new会从堆空间申请一块空间用于保存值,然后返回 空间的开始地址。
内存泄漏/内存溢出
- 内存溢出:程序在运行的过程中,堆内存的需求超过了计算机分配给程序的内存,从而造成”Out of Memory”之类的错误。
- 如何解决内存溢出问题:
- 计算机本身的内存小,只能增加内存来解决。
- 程序在运行时没能及时释放不用的内存,造成内存越来越大从而溢出,一般是非托管资源。
- 内存泄露:一般是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,造成了内存 不能回收和不能及时回收。当程序不能释放的内存越来越多就会造成程序性能下降或出现内存溢出错 误。一般也跟非托管资源有关。
标记并清除
- 标记并清除的方式会选择一部分引用类型的对象作为根对象,然后去递归标记对象以及对象的引用类 型成员,最后所有已标记的引用类型对象会存活下来,而未被标记的引用类型对象会被回收。
- 选择根对象的方式必须保证从根对象开始可以遍历所有程序中能够访问的对象。
- .NET中的根对象,包括各个线程栈空间上的变量,全局变量,GC句柄和析构队列中的对象。
分代
- .NET中将引用类型的对象分为三类,分别是第0代,第1代与第2代。
- 新分配的对象如果说不超过一定的大小,它会作为小对象被归属为第0代。
- 如果超过一定的大小,就会作为大对象归属为第二代。
- 执行垃圾回收以后存活的第0代对象通常会成为第1代。
- 再有一轮GC执行完成,那么第1代存活的对象会成为第2代。
- 存活的第2代对象会继续留在第2代。
- 这样的处理可以让.NET中的对象按存活的时间分成了三个不同的群组。
- 第0代中的对象存活时间通常最短,第1代中的对象存活时间比较长,第2代中的对象存活时间最长。
- .NET垃圾回收机制中第0代和第1代的处理频率比较高,第2代比较低。
- 分代依据的目的是,尽量增加每次执行垃圾回收处理时,可回收的对象数量,并减少处理所需的时间。
压缩
- 反复执行分配与回收的操作,可能导致堆上产生很多空余的空间,这些空余的空间又被称为碎片空间。
- 压缩机制可以通过移动已分配的空间把碎片空间合并到一起,使得堆可以分配更大的对象。
- .NET运行时提供的GC是支持压缩机制的。
大小对象
- .NET根据引用类型对象的值占用的空间大小来区分是小对象还是大对象。
- 大于或等于85000字节的对象就会成为大对象。
- 大对象和小对象会在不同的堆区域中分配。
- 分配大对象的区域称为大对象堆,分配小对象的区域称为小对象堆。
- 区分大小对象的原因是因为处理大对象需要更长的时间和更多的资源,并且大对象存活时间通常都会 更长一些。
- 所以新分配的小对象都是归属第0代的,新分配的大对象都是归属第2代的。
- 而且移动大对象需要的成本很高。
- 压缩机制默认只在小对象堆启用,大对象堆是不会执行压缩的。
固定对象
- .NET支持托管代码调用非托管代码,如果把一个引用类型的对象传递给非托管代码,那么它的内存地 址就会被复制到非托管代码管理的区域中,而.NET运行时无法得知非托管代码把对象的内存地址到 底保存到了哪里。
- 这个时候就会导致两个问题:
- 无法确定这个非托管代码是否仍然使用这个对象。
- 执行压缩操作已分配空间的地址会改变,改变以后非托管代码中保存的内存地址不能够同步更新。
- 为了解决这两个问题,.NET要求托管代码传递引用类型对象给非托管代码时必须创建固定类型的GC 句柄,并在托管代码中保持这个句柄存活到非托管代码的调用结束。
- 创建了固定类型GC句柄的对象就称为固定对象。
- .NET运行时执行垃圾回收时候会扫描CG句柄并标记所有固定对象存活。
- 并且在执行压缩操作的时候,还会避开这些固定对象,让它们的位置保持不变。
- 固定对象带来的碎片空间是无法合并的,并且固定对象在垃圾回收后可能会发生降代。
析构队列
- .NET支持在回收对象前去调用析构函数,还可以在析构函数中去使用托管代码去编写一些自定义的 逻辑。
- 具体析构过程中会执行什么代码逻辑会执行多长时间,对于.NET运行时来说都是不确定的。
- 如果在垃圾回收的过程中执行析构函数,垃圾回收需要的时间是不可预料的,因为这个逻辑是开发人 员自己写的,谁也不知道你在析构函数里写了什么,所以垃圾回收在执行这些析构函数的时候会有问 题。
- .NET里面定义了一个析构队列以及一个对应的析构线程,在执行垃圾回收的时候,如果对象不再存 活但是定义了析构函数,那么对象会添加到析构队列并标记存活。
- 等GC结束以后,这个时候就会启动一个析构线程,这个析构线程就是专门用来从这个析构队列取 对象,然后来执行它们的析构函数。
- 析构函数执行完毕的对象,可以在下一轮GC中被回收。
- 有析构函数的对象,通常需要至少两轮才能把它给回收掉。
- 析构函数通常都是在使用非托管资源的类型中定义。
- 比如说fileStream类,fileStream类它里面包含了文件句柄,这个文件句柄就是非托管资源,它的析构 函数就会调用Dispose函数,而Dispose函数是会关闭打开的文件句柄。
- 尽管这种非托管资源可以通过析构函数自动释放,但是因为GC的发生时期与析构函数的调用时期不明 确,所以这个托管代码在不使用托管资源以后,往往都应该去主动调用Dispose函数去释放它。
- 并且Dispose函数在释放完资源以后,应该还要抑制析构函数的运行。
STW
- 标记并清除是用GC,它要确定哪些对象正在被程序使用,那么这就要扫描对象之间的引用关系,也就 是说要遍历对象包含的引用类型成员,因为成员值会随着程序运行不断修改。
- 对象之间的引用关系会随着程序运行不断改变,让执行GC的线程与执行其它处理的线程同时运行会带 来一些问题。
- 所以需要让执行GC处理以外的线程全都暂停运行,像这样的停止操作称为STW(Stop The World)
工作站模式/服务器模式
- 工作站模式适用于内存占用量小的程序和桌面程序,可以提供更短的响应时间。
- 服务器模式适用于内存占用量大的程序与服务程序,可以提供更高的吞吐量。
普通GC/后台GC
- 普通GC会导致更长的单次STW停顿时间,但消耗的资源比较小,并且支持压缩处理。
- 后台GC每次STW停顿时间会更短,但停顿次数与消耗的资源会更多,并且不支持压缩处理。
对象头
- 对象头包含了标志与同步块索引等数据。
- 在32位平台上对象头是4个字节,在64位平台上对象头是8个字节。
- 由于对象头只使用4个字节,也就是说它是32位的。
- 在32位里面,高6位用于保存一些重要的标志,低26位保存的内容是根据标记来定的。
- 高1位用于.NET运行中内部检查托管堆状态时,标记对象是否已检查。
- 高2位用于标记是否抑制运行对象的析构函数。
- 高3位用于标记对象是否为固定对象。
- 高4,5,6位用于标记低26位保存了什么内容,其中就包括了获取锁,释放锁和对象Hash值的信息。
类型信息
- 每个引用类型的对象值都保存了一个类型信息。
- 类型信息实际上是一个内存地址,是一个指向的是.NET运行时内部保存的类型数据(MethodTable)的 内存地址。
- 类型数据包含了类型的所属模块名称,字段列表,属性列表,方法列表,以及各个方法入口点的地 址等信息。
- .NET中的反射机制,接口,虚方法,都需要依赖这些类型数据,反射会把类型数据中的内容包装成托 管对象,供托管代码访问。接口与虚方法就需要访问这个类型数据中的方法表,在执行时需要定位实 际调用的方法地址。
内存结构
- GC在执行时会记录哪些对象存活,记录使用每个对象关联的存活标记初始是0,当扫描时对象为1, 最后清除标记为0的对象。
- 存活标记保存在一个全局的位数组中,GC执行的时候还会根据固定类型的GC句柄来标记对象是否固 定,固定对象是不可以被移动的。
托管堆/堆段
- 托管堆用于保存引用类型对象的值。
- 每个堆段默认的大小根据GC模式与运行环境的CPU逻辑核心数量来决定。
分配上下文
- 堆段分配对象值,最简单的方式就是在管理堆段的实例中保存3个实例地址,一般预留小对象分配上 下文。
分代的实例
- 在.NET中,托管堆每个区域的小对象堆有三个代,大对象堆有一个代(第2代),这些代会通过 generation类型的实例进行管理。
- 每个区域都有4个generation类型的实例,每个generation类型包含的数据可以根据可变性分为静态 数据和动态数据。
- 静态数据是实例创建后不会改变的数据,它包含触发GC所需的分配量阈值上限和下限。
- 动态数据会根据运行状况不断改变,包含已分配的大小和回收次数等等。
- 代的开始地址决定了哪些对象在哪些代。
自由对象列表
- GC执行的时候,需要清除没有标记存活的对象,标记为存活的就不清除。
- 这些对象占用的空间需要还给操作系统,或者说再下一次分配对象的时候使用。
- .NET堆段上的对象是连续的,被清除的对象就会变成一个特殊的数组对象,这个数组的元素大小为1, 长度可以自由控制,这个对象的总长度会等于被清除对象的长度用于标记这一处空间没有被使用,像 这样的对象被称为自由对象。
- GC标记对象是否存活,未存活的对象变为自由对象,相邻的自由对象会合并,同时存活标记被清除。
- 如果自由空间出现在已分配空间的尾部,那么它会释放给操作系统,并且所占空间会归为未分配空间。
- 堆段上自由对象所占的空间可以称为碎片空间。
- 如果说碎片空间太多,GC就会选择压缩来减少它们,压缩会把各个对象的值往前移动,这样就会让分 配空间总体减少,如果说整个堆段都没有存活的空间,那么会从堆段的链表中删掉,并且占用的空间 会重新还给操作系统。
- 而管理被释放堆段实例的本身,可能会作为可重用堆段记录下来,以供下次新建对象使用。
- 托管堆的每个区域有4个自由对象列表,它们分别记录第0代的自由对象,第1代的自由对象,第2 代小对象堆段的自由对象,第2代大对象堆段的自由对象。
- GC会先从第0代的自由对象列表获取自由对象,然后把自由对象占用的空间变为分配上下文的空间。
- 分配大对象的时候,先从第2代大对象堆段的自由对象列表里面去获取自由对象,然后把大对象的值 放在这个自由对象占用的空间中。
- 如果说自由对象还有剩余部分,那么会在这个部分创建一个新的自由对象,并且再把它记录到这个自 由对象列表中。
- 为了提升这种重用自由对象的效率,第2代的自由对象列表,还会根据自由对象的大小进行分组。
- 重用自由对象的时候会根据需要的大小,尽量从更小的分组中去找到自由对象并重用。
跨代引用记录
- .NET实现分代的主要原因是为了支持垃圾回收时只处理一部分对象。
- 目前.NET中的GC支持指定目标代。
- 支持指定目标代是可以减少扫描的对象数量。
- 第1代对象的引用类型成员指向了第0代对象称为跨代引用,这就有可能出现漏标记存活的问题。
- 为了解决跨代引用的问题,.NET中有一个数组专门记录跨代引用,这个数组又称为卡片表,卡片表会 标记所有发生跨代引用的位置。
- 当GC扫描的时候除了扫描目标代,还会扫描卡片表中标记的位置,以防止发生漏标记存活,因为卡片 表实际上是一个数组,扫描卡片表中标记的位置是一个线性时间,如果说这个程序内存占用越大,那 么扫描卡片表需要的时间就越长,因为.NET会根据程序的内存占用量来决定是否使用卡片束。
- 卡片束记录卡片表中哪些位置有标记,先扫描卡片束再扫描卡片表就可以减少处理时间。
GC的触发
- 第1个条件是分配对象时找不到可用空间。
- 第2个条件是分配量超过阈值。
- 第3个条件是托管代码主动调用GC.Collect函数。
- 第4个条件是收到物理内存不足的通知。
分配对象时找不到可用空间
- 分配上下文的时候或者分配大对象的时候,先尝试从自由对象列表查找可用的自由对象,再尝试从短 暂堆段或者最新的大对象堆段末尾去分配,如果这些分配都失败了,就会触发GC。
- 第1种是针对第1代的GC,这一种GC会尝试回收短暂堆段上的对象,使得短暂堆段有更多的空间。
- 第2种是针对第2代的GC,也就是完整的GC,这种GC会在物理内存不足或执行第1种GC以后仍然 无法分配时触发。
- 分配小对象时找不到空间,通常是因为新分配对象越来越多导致的,如果GC可以从短暂堆段中回收足 够多的对象,那么短暂堆段会继续使用,否则GC新建一个短暂堆段,而原有的短暂堆段可以变成小对 象堆,其中所有的对象都会变成2代。
- 分配大对象时找不到可用空间,除了由于新分配的对象越来越多导致以外,还有可能是大对象的碎片 空间率比较高导致的,如果说原因是因为碎片太多,.NET默认是不会执行大对象的压缩处理的。
- 在开发过程种,应当尽可能的减少大对象的分配,可以在程序中建立对象池,重用对象,享元模式。
- 如果说程序明确会创建大对象,那么就可以强制开启大对象的压缩。
分配量超过阈值
- .NET中托管堆根据工作模式以及CPU的逻辑核心数量,可以分为多个区域,每个区域都有所谓的四个 实例,每一个实例包含静态数据和动态数据。
- 静态数据包含了分配量阈值的上限和下限。
- 动态数据包含了分配量的阈值。
- 如果在某个代分配的对象值大小合计超过分配量阈值,就会触发针对这个代的GC。
- 分配量的阈值会在每次结束后重新计算。
- 这个东西它没有一个绝对的值,是由GC自己计算的,而且这个值还是动态的。
- 新分配的阈值它会由GC结束后,这个代存活数量的多少以及存活率决定的。
- 存活下来的对象越多,新分配量的阈值就越高。
GC.Collect
托管代码中调用GC.Collect函数可以主动触发GC。
物理内存不足
- 物理内存接近用尽时,操作系统会把物理内存中的部分内容移动到分页文件。
- .NET为了避免因使用分页文件带来的性能低下,会自动检测物理内存是否接近不足,然后触发GC。
- 目前这个机制只支持windows系统。