梁丘眉 发表于 2025-6-1 21:28:30

c# 托管堆和垃圾回收的clr的优化

前言

上一章介绍了基本垃圾回收的思路,那么看一下怎么回收的性能提高
正文

优化性能的手段,一般是找到事物的特征,然后利用这种特征进行优化。

[*]对象越新,生存期越短。
[*]对象越老,生存期越长。
[*]回收堆的一部分,速度快于回收整个堆。
托管堆在初始化时不包含对象。添加到堆的对象称为第 0 代对象。简单地说,第 0 代对象就是那些新构造的对象,垃圾回收器从未检查过它们。
一个新启动的应用程序,它分配了 5 个对象(从 A 到 E)。过了一会儿,对象 C 和 E 变得不可达。

CLR 初始化时为第 0 代对象选择一个预算容量(以 KB 为单位)。如果分配一个新对象造成第 0 代超过预算,就必须启动一次垃圾回收。
假设对象 A 到 E 刚好用完第 0 代的空间,那么分配对象 F 就必须启动垃圾回收。垃圾回收器判断对象 C 和 E 是垃圾,所以会压缩对象 D,使之与对象 B 相邻。在垃圾回收中存活的对象(A,B 和 D)现在成为第 1 代对象。
第 1 代对象已经经历了垃圾回收器的一次检查。

一次垃圾回收后,第 0 代就不包含任何对象了。和前面一样,新对象会分配到第 0 代中。
应用程序继续运行,并新分配了对象 F 到对象 K。另外,随着应用程序继续运行,对象 B, H 和 J 变得不可达,它们的内存将在某一时刻回收。
现在,假定分配新对象 L 会造成第 0 代超出预算,造成必须启动垃圾回收。开始垃圾回收时,垃圾回收器必须决定检查哪些代。前面说过,CLR 初始化时会为第 0 对象选择预算。事实上,它还必须为第 1 代选择预算。
开始一次垃圾回收时,垃圾回收器还会检查第 1 代占用了多少内存。在本例中,由于第 1 代占用的内存远少于预算,所以垃圾回收器只检查第 0 代中的对象。回顾一下基于代的垃圾回收器做出的假设。第一个假设是越新的对象活得越短。因此,第 0 代包含更多垃圾的可能性很大,能回收更多的内存。由于忽略了第 1 代中的对象,所以加快了垃圾回收速度。
显然,忽略第 1 代中的对象能提升垃圾回收器的性能。但对性能有更大提振作用的是现在不必遍历托管堆中的每个对象。如果根或对象引用了老一代的某个对象,垃圾回收器就可以忽略老对象内部的所有引用,能在更短的时间内构造好可达对象图(graph of reachable object)。当然,老对象的字段也有可能引用新对象。为了确保对老对象的已更新字段进行检查,垃圾回收器利用了 JIT 编译器内部的一个机制。这个机制在对象的引用字段发生变化时,会设置一个对应的位标志。这样,垃圾回收器就知道自上一次垃圾回收以来,哪些老对象(如果有的话)已被写入。只有字段发生变化的老对象才需检查是否引用了第 0 代中的任何新对象。
本机代码会生成对一个 write barrier 方法的调用(译注:write barrier 方法是在有数据向对象写入时执行一些内存管理代码的机制)。这个 write barrier 方法检查字段被修改的那个对象是否在第 1 代或第 2 代中,如果在,write barrier 代码就在一个所谓的 code table 中设置一个 bit。card table 为堆中的每 128 字节的数据都准备好了一个 bit。GC 下一次启动时会扫描 card table,了解第 1 代和第 2 代中的哪些对象的字段自上次 GC 以来已被修改。任何被修改的对象引用了第 0 代中的一个对象,被引用的第 0 代对象就会在垃圾回收过程中“存活”。GC 之后,card table 中的所有 bit 都被重置为 0。向对象的引用字段中写入时,write barrier 代码会造成少量性能损失(对应地,向局部变量或静态字段写入便不会有这个损失)。另外,如果对象在第 1 代或第 2 代中,性能会损失得稍微多一些。基于代的垃圾回收器还假设越老的对象活得越长。也就是说,第 1 代对象在应用程序中很有可能是继续可达的。如果垃圾回收器检查第 1 代中的对象,很有可能找不到多少垃圾,结果是回收不了多少内存。因此,对第 1 代进行垃圾回收很可能是浪费时间。如果真的有垃圾在第 1 代中,它将留在那里。

应用程序视图分配对象 T 时,由于第 0 代已满,所以必须开始垃圾回收。但这一次垃圾回收器发现第 1 代占用了太多内存,以至于用完了预算。由于前几次对第 0 代进行回收时,第 1 代可能已经有许多对象变得不可达(就像本例这样)。所以这次垃圾回收器决定检查第 1 代和第 0 代中的所有对象。两代都被垃圾回收后,堆的情况如图 21-11 所示。

托管堆只支持三代:第 0 代、第 1 代和第 2 代。没有第 3 代①。CLR 初始化时,会为每一代选择预算。然而,CLR 的垃圾回收器是自调节的。这意味着垃圾回收器会在执行垃圾回收的过程中了解应用程序的行为。例如,假定应用程序构造了许多对象,但每个对象用的时间都很短。在这种情况下,对第 0 代的垃圾回收会回收大量内存。事实上,第 0 代的所有对象都可能被回收。
如果垃圾回收器发现在回收 0 代后存活下来的对象很少,就可能减少第 0 代的预算。已分配空间的减少意味着垃圾回收将更频繁地发生,但垃圾回收器每次做的事情也减少了,这减小了进程的工作集。事实上,如果第 0 代中的所有对象都是垃圾,垃圾回收时就不必压缩(移动)任何内存;只需让 NextObjPtr 指针指回第 0 代的起始处即可。这样回收可真快!
另一方面,如果垃圾回收器回收了第 0 代,发现还有很多对象存活,没有多少内存被回收,就会增大第 0 代的预算。现在,垃圾回收的次数将减少,但每次进行垃圾回收时,回收的内存要多得多。顺便说一句,如果没有回收到足够的内存,垃圾回收器会执行一次完整回收。如果还是不够,就抛出OutOfMemoryException 异常。
到目前为止,只是讨论了每次垃圾回收后如何动态代用第 0 代的预算。但垃圾回收器还会用类似的启发式算法调整第 1 代和第 2 代的预算。这些代码垃圾回收时,垃圾回收器会检查有多少内存被回收,以及有多少对象幸存。基于这些结果,垃圾回收器可能增大或减小这些代的预算,从而提升应用程序的总体性能。最终的结果是,垃圾回收器会根据应用程序要求的内存负载来自动优化————这非常“酷”!
垃圾回收触发条件:

[*]代码显式调用System.GC的静态 Collect方法
[*]Windows报告低内存情况
[*]CLR 正在卸载 AppDomain
[*]CLR 正在关闭
接下来就是解决大对象的问题:
还有另一个性能提升举措值得注意。CLR 将对象分为大对象和小对象。本章到目前为止说的都是小对象。目前认为 85000 字节或更大的对象是大对象②。CLR 以不同方式对待大小对象。

[*]大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。
[*]目前版本的 GC 不压缩大对象,因为在内存中移动它们代价过高。但这可能在进程中的大对象之间造成地址空间的碎片化,以至于抛出 OutOfMemoryException。CLR 将来的版本可能压缩大对象。
[*]大对象总是第 2 代,绝不可能是第 0 代或 第 1 代。所以只能为需要长时间存活的资源创建大对象。分配短时间存活的大对象。分配短时间存活的大对象会导致第 2 代被更频繁地回收,会损害性能。大对象一般是大字符串(比如 XML 或 JSON)或者用于 I/O 操作的字节数组(比如从文件或网络将字节读入缓冲区以便处理)。
CLR 启动时会选择一个 GC 模式:
● 工作站
该模式针对客户端应用程序优化 GC。GC 造成的延时很低,应用程序线程挂起时间很短,避免使用户感到焦虑。在该模式中,GC 假定机器上运行的其他应用程序都不会消耗太多的 CPU 资源。
● 服务器
该模式针对服务器端应用程序优化 GC。被优化的主要是吞吐量和资源利用。GC 假定机器上没有运行其他应用程序(无论客户端还是服务器应用程序),并假定机器的所有 CPU 都可用来辅助完成 GC。该模式造成托管堆被拆分成几个区域(section),每个 CPU 一个。开始垃圾回收时,垃圾回收器在每个 CPU 上都运行一个特殊线程:每个线程都和其他线程并发回收它自己的区域。对于工作者线程(worker thread)行为一致的服务器应用程序,并发回收能很好地进行。这个功能要求应用程序在多 CPU 计算机上运行,使线程能正真地同时工作,从而获得性能的提升。
应用程序默认以“工作站” GC 模式运行。寄宿①了 CLR 的服务器应用程序(比如 ASP.NET 或 Microsoft SQL Server)可请求 CLR 加载“服务器” GC。但如果服务器应用程序在单处理器计算机上运行,CLR 将总是使用“工作站”GC模式。


下一节,垃圾回收的控制

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: c# 托管堆和垃圾回收的clr的优化