相信很多C#开发者都没有关注过内存问题,毕竟我们有垃圾自动回收机制,不用像C/C++那样,需要手动去释放。
其实关于内存是自动还是手动回收释放,一直也是有争议的,像C/C++这样的开发者认为,内存这么珍贵,就应该让人去操作,怎么能让没有思维的机器去操作呢,而支持垃圾自动回收的开发者者认为,就是因为内存这么珍贵,才不能让人手动干预,机器更可靠!额,嗯,说的都对,都有道理,反正要么就是人靠谱,要么就是机器靠谱...
好了,言归正传,在C#中,垃圾回收(Garbage Collection, GC)是一个自动内存管理机制,用于回收应用程序中不再被使用的对象的内存,因此,使用托管代码的开发人员无需编写执行内存管理任务的代码。C#中的垃圾回收是基于 .NET Framework 或 .NET Core/.NET 5+的公共语言运行时(CLR)的一部分。
常用的垃圾分类方法有三种:引用计数法、标记删除法、分代回收法。
引用计数法
引用计数法,就是堆每个对象维护一个count字段,用来记录此对象被引用的次数1、当有新的引用指向时,引用计数+12、当该对象的引用减少时,引用计数 -13、如果对象的引用计数为0,该对象将被回收,空间将被释放
一般的,引用技术法存在循环引用的问题,比如A引用B,B又引用A,那么A和B的引用计数都不为0,但是我们可以采用一些方法来解决这个问题。
标记删除法
标记清除是一种基于追踪回收的算法:1、第一步从跟对象出发,一直往后遍历,将那些可以被引用的对象标记为活动对象,那么剩下没有引用标记为非活动对象2、第二步就是将非活动对象删除回收了
一般的,标记删除法存在性能问题,因为它需要扫描所有使用的内存。
分代回收法
分代回收法就是根据回收次数,将内存垃圾氛围3类:1、第0代(Generation 0):这是新分配的对象所在的代,由于新分配的对象很可能很快变为不可达(即不再被使用),因此第0代是垃圾回收最频繁检查的代。2、第1代(Generation 1):当第0代中的对象在一次垃圾回收后仍然存活时,它们会被提升到第1代。第1代中的对象在垃圾回收中的检查频率低于第0代,因为它们存活的时间更长,但是数量一般是最少的。3、第2代(Generation 2):类似地,如果第1代中的对象在另一次垃圾回收后仍然存活,它们会被提升到第2代。第2代是检查频率最低的代,因为其中的对象存活时间最长。
C#的GC(Garbage Collection)
C#的代码运行在CLR中,CRL负责资源申请、释放、异常监控等,内存属于资源的一种,所以,除非必须,C#代码不应该直接申请内存资源,而内存的释放交由GC(Garbage Collection)来控制,GC是分代回收器。
程序启动加载 CLR 时,GC 分配两个初始堆段:一个用于小型对象(小型对象堆或SOH),一个用于大型对象(大型对象堆或LOH)。
大对象:对象的大小大于或等于 85,000 字节,运行时会将其分配到大型对象堆。
对于大对象,它在第0代创建后,将会延续到第2代,放到LOH中,第 2 代垃圾回收未处理的对象仍是第 2 代对象,LOH 未处理的对象仍是 LOH 对象,所以LOH的回收是在第2代回收的时候进行的,第2代执行的垃圾回收被称为完整回收(Full GC),大对象堆(LOH)是我们开发者需要重点关注的。
GC回收的整个过程过程大概是这样:1、分配:申请内存,存放数据用于计算,此时数据可能在SOH或者LOH中2、晋升:根据引用打标记,应用程序中不再被使用的对象视为垃圾,按回收次数分为0、1、2代,数据在不同代中回收3、回收:清理数据,释放内存4、压缩:整理数据,退回多余的内存(LOH没有此过程)
内存压缩
这里解释一下内存压缩,就是GC的最后一步,我们内存是一个连续的块,但是在经历回收之后,它就变得断断续续的了,为了保证性能和资源的更好利用,GC可能会对内存做个压缩。
对于SOH会压缩内存,这样所有的内存均可重新使用,借用官网的图说明一下:
1、开始创建有四个对象obj0、obj1、obj2、obj3,这个时候它们在第0代2、现在obj1和obj3被释放了,那么它们的内存就空出来了,GC就会做个压缩,把obj2的位置就变到原来obj1开始的位置,同事它被提升到第1代3、如果又有obj4、obj5、obj6被提升到第1代,那么它们就会放在obj2后面,这样空间就被利用起来了4、如果obj2和obj5又被释放,那么又被压缩,obj4、obj6会向前移
而对于LOH由于压缩成本太高,因此一般是不会进行压缩(代码可以配置),在需要内存时,查看是否有可用的片段,没有则申请新的内存,但是用于申请的内存往往不一定刚好是空闲的长度,所以会流出很多Free的空间。借用官网的图说明一下:
1、开始我们有obj0、obj1、obj2、obj3几个大对象,注意他们属于第2代2、当obj1、obj2被回收后,中间就会留下空闲片段(Free),GC一般不会去压缩3、当我们创建obj4需要新的空间时,会先看空闲片段是否满足需要,满足则使用,不满足就重新申请内存,但是哪怕满足,大概率也会有小的空闲片段
注意:GC执行垃圾回收时是阻塞操作,它会将所以线程挂起来,直至GC执行完成后继续执行。
所以常说的GC会影响程序性能,其实就是只线程挂起来阻塞的时间,要缩短这个时间,就要尽可能少的产生垃圾,要尽可能快的执行回收,而且,如果频繁的执行GC,会发现CPU的使用率偏高,这可能对系统中的所有服务都回有一些影响。
GC何时执行?
GC的执行是个很复杂的过程,我们程序代码无法控制GC的执行,但是一般的,我们可以通过GC.Collect()
方法来手动触发执行(虽然也不一定会执行),但是对于LOH,它往往在以下三种情况下执行:
1、分配超出第0代或大型对象阈值2、调用 GC.Collect() 方法3、系统处于内存不足的状况
总结
在开发的过程中,为了避免GC影响性能,我们应该多注意一下。
1、尽可能少的产生垃圾* 数据类型上,尽可能少的使用字符串、结构体之类的* 字符串使用上,注重使用字符串池(字符串暂存区),以内存的开销,对于碎片化的字符串,应采用StringBuilder优化* 注重复用,采用一些池技术优化,比如对象池2、尽可能快的执行回收,第2代回收频率是最低的,所以要尽量避免垃圾延续到第2代,特别是LOH,LOH存在的意义就是存放必须的大数据,所以非必须下不要使用LOH,LOH有着非常高的分配、回收成本。LOH中存放的数据往往是:大型集合、大字符串* 对于大型集合,比如大型的对象数据,我们需要在使用完成之后,将他们清空,以此取消他们之间的引用关系,从而可以让GC回收* 大字符串往往来自于三个地方:* 文件读取: var content = File.ReadAllText(filePath);* 格式化,如JSON格式: var json = value.ToJSON();* 数据库
参考资料:
https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/large-object-heap