欢迎光临散文网 会员登陆 & 注册

编写高性能.net代码--垃圾回收的前世今生

2023-03-19 14:51 作者:79732017138_bili  | 我要投稿

垃圾回收的内容太多,比较繁琐,先整理提纲,后续丰富更多实践经验,还有收集PrefView和Windbug的使用

垃圾回收简介

垃圾回收将会是你一直关注的性能因素

垃圾回收将会是你一直关注的性能因素。大部分容易察觉的性能问题,“显然”都是由垃圾回收引起的。这些问题修正起来速度最快,也是需要你持续关注并时刻检查的。我用了“显然”这个词,是因为我们将会发现,很多问题实际上都是由于对垃圾回收器的行为和预期结果理解有误。在.NET环境中,你需要更多地关注内存的性能,至少要像对CPU性能一样。较好的内存性能是.NET程序流畅运行的重要基础

垃圾回收可能导致系统开销,非常不安

很多情况下垃圾回收器实际上会整体提高内存堆的性能,因为它能高效地完成内存分配和碎片整理工作。垃圾回收肯定能为你的应用程序带来好处。

本机代码内存管理--费尽脑经减少内存碎片

Windows的本机代码模式下,内存堆维护着一张空闲内存块的列表,用于内存的分配。尽管用到了低碎片化的内存堆(Low Fragmentation Heaps),很多长时间运行的本机代码应用还是得费尽心机地对付内存碎片问题。内存分配操作的速度会越来越慢,因为系统分配程序遍历空闲内存表的时间会越来越长。内存的占用率会持续增长,进程肯定也需要重启以开始新的生命周期。为了减少内存碎片,有些本机代码程序用大量代码实现了自己的内存分配机制,把默认的malloc函数给替换掉了

.NET环境,内存分配工作量小

.NET环境中,内存分配的工作量很小,因为内存总是整段分配的,通常情况下不会比内存的扩大、减小或比较增加多少开销。在通常情况下,不存在需要遍历的空闲内存列表,也几乎不可能出现内存碎片。其实GC内存堆的效率还会更高,因为连续分配的多个对象往往在内存堆中也是连续存放的,提高了就近访问的可能性(Locality)

内存分配流程

在默认的内存分配流程中,会有一小段代码先检查目标对象的大小,看看内存分配缓冲区中所剩的内存还够不够用。只要缓冲区还够用,内存分配过程就十分迅速,不存在资源争用问题。如果内存分配缓冲区已被耗尽,就会交由GC分配程序来检索足以容纳目标对象的空闲内存。然后一个新的分配缓冲区会被保留下来,用于以后的内存分配

简单演示

简单演示内存分配过程的C#代码如下。class MyObject {   int x;   int y;   int z; } static void Main(string[] args) {   var x = new MyObject(); } 首先,让我们分解一下。以下是调用内存分配函数的代码。;把类的方法表指针拷贝到ecx中 ;作为new()的参数 ;可以用!dumpmt查看值 mov ecx,3F3838h ;调用new call 003e2100 ;把返回值(对象的地址)拷贝到寄存器中 mov edi,eax 下面是实际的分配函数。;注意:为了格式统一,大部分代码的地址都未给出 ; ;把eax值设为0x14,也就是需要分配给对象的内存大小 ;数值来自于方法表 mov eax,dword ptr [ecx+4] ds:002b:003f383c=00000014 ;把内存分配缓冲区数据写入edx mov edx,dword ptr fs:[0E30h] ;edx+40存放着下一个可用的内存地址 ;把其中的值加上对象所需大小,写入eax add eax,dword ptr [edx+40h] ;把所需内存地址与分配缓冲区的结束地址进行比较 cmp eax,dword ptr [edx+44h] ;如果超出了内存分配缓冲区 ;跳转到速度较慢的分配流程 ja 003e211b ;更新空闲内存指针(在旧值上增加0x14字节) mov dword ptr [edx+40h],eax ;将指针减去对象大小 ;指向新对象的起始位置 sub eax,dword ptr [ecx+4] ;将方法表指针写入对象的前4字节 ;现在eax指向的是新对象 mov dword ptr [eax],ecx ;返回调用者 ret ;慢速分配流程(调用CLR方法) 003e211b jmp clr!JIT_New (71763534)

服务器模式

如果你的垃圾回收配置成服务器模式,内存分配过程就没有快速和慢速之分,因为每个处理器都有各自的内存堆。.NET的内存分配流程比较简单,而解除分配的过程则复杂得多,但这个复杂的过程不需要你直接处理。你只需要学习如何优化即可

2.1  基本运作方式

内存堆

本机内存堆(Native Heap)

是由VirtualAlloc这个Windows API分配的,是由操作系统和CLR使用的,用于非托管代码所需的内存,比如Windows API、操作系统数据结构、很多CLR数据等

托管堆(Managed Heap)

CLR在托管堆(Managed Heap)上为所有.NET托管对象分配内存,也被成为GC堆,因为其中的对象均要受到垃圾回收机制的控制 托管堆又分为两种——小对象堆和大对象堆(LOH),两者各自拥有自己的内存段(Segment)。每个内存段的大小视配置和硬件环境而定,对于大型程序可以是几百MB或更大。小对象堆和LOH都可拥有多个内存段

小对象堆

小对象堆的内存段进一步划分为3代

暂时段(Ephemeral Segment)第0代和第1代总是位于同一个内存段中

而第2代可能跨越多个内存段

大对象堆(LOH)

跨越多个内存段

小对象堆中分配内存的对象的生存期

默认快速分配

对象小于85 000字节,CLR都会把它分配在小对象堆中的第0代,通常紧挨着当前已用内存空间往后分配

快速分配失败

如果快速分配失败,对象就可能会被放入第0代内存堆中的任意地方,只要能容纳得下就行

空间不足

如果没有合适的空闲空间,那么分配器就会扩大第0代内存堆,以便能存入新对象

垃圾回收

如果扩大内存堆时超越了内存段的边界,则会触发垃圾回收过程

对象保持存活

对象总是诞生于第0代内存堆。只要对象保持存活,每当发生垃圾回收时,GC都会把它提升一代。第0代和第1代内存堆的垃圾回收有时候被称为瞬时回收(Ephemeral Collection)

01代的碎片整理

 

对象总是诞生于第0代内存堆。只要对象保持存活,每当发生垃圾回收时,GC都会把它提升一代。第0代和第1代内存堆的垃圾回收有时候被称为瞬时回收(Ephemeral Collection)

碎片整理引用的开销

每一代内存堆都有可能发生碎片整理。因为GC必须修正所有对象的引用,使它们指向新的位置,所以碎片整理的开销相对较大,还有可能需要暂停所有托管线程。正因如此,垃圾回收器只在划算(Productive)时才会进行碎片整理

2代内存与完全垃圾回收

如果对象到达了第2代内存堆,它就会一直留在那里直至终结。这并不意味着第2代内存堆只会一直变大。如果第2代内存堆中的对象都终结了,整个内存段也没有存活的对象了,垃圾回收器会把整个内存段交还给操作系统,或者作为其他几代内存堆的附加段。在进行完全垃圾回收(Full Garbage Collection)时,就可能发生这种第2代内存堆的回收

存活的意思

如果GC能够通过任一已知的GC根对象(Root),沿着层层引用访问到某个对象,那它就是存活的。GC根对象可以是程序中的静态变量,或者某个线程的堆栈被正在运行的方法占用(用于局部变量),或者是GC句柄(比如固定对象的句柄,Pinned Handle),或是终结器队列(Finalizer Queue)。请注意,有些对象可能没有受GC根对象的引用,但如果是位于第2代内存堆中,那么第0代回收是不会清理这些对象的,必须等到完全垃圾回收才会被清理到

代的提升

 

如果第0代堆即将占满一个内存段,而且垃圾回收也无法通过碎片整理获取足够的空闲内存,那么GC会分配一个新的内存段。新的内存段会用于容纳第1代和第0代堆,老的内存段将会变为第2代堆。老的第0代堆中的所有对象都会被放入新的第1代堆中,老的第1代堆同理将提升为第2代堆(提升很方便,不必复制数据)

2代堆

2代堆继续变大,就可能会跨越多个内存段。LOH堆同样也可能跨越多个内存段。无论存在多少个内存段,第0代和第1代总是位于同一个段中

大的对象直接LOH分配

LOH则遵从另一套回收规则。大于85 000字节的对象将自动在LOH中分配内存,且没有什么“代”的模式。超过这个尺寸的对象通常也就是数组和字符串了。出于性能考虑,在垃圾回收期间LOH不会自动进行碎片整理

LOH碎片

LOH中,垃圾回收器用一张空闲内存列表来确定对象的存放位置,如果是在调试器中查看位于LOH的对象,你会发现有可能整个LOH都小于85 000字节,而且可能还有对象的大小是小于已分配值的。这些对象通常都是CLR分配出去的,可以不予理睬

垃圾回收原则

如果回收了第1代,则也会同时回收第0代

如果回收了第2代,则所有内存堆都会回收,包括LOH

如果发生了第0代或第1代垃圾回收,那么程序在回收期间就会暂停运行

对于第2代垃圾回收而言,有部分回收是在后台线程中进行的,这要根据配置参数而定

垃圾回收步骤

挂起(Suspension)

所有托管线程都被强行中止

标记(Mark)

GC根对象开始,垃圾回收器沿着所有对象引用进行遍历并把所见对象记录下来

碎片整理(Compact)

将对象重新紧挨着存放并更新所有引用,以便减少内存碎片。在小对象堆中,碎片整理会按需进行,无法控制。在LOH中,碎片整理不会自动进行,但你可以在必要时通知垃圾回收器来上一次

恢复(Resume)

托管线程恢复运行

垃圾回收的开销

在标记阶段并不需要遍历内存堆中的所有对象,只要访问那些需要回收的部分即可。比如第0代回收只涉及到第0代内存堆中的对象,第1代回收将会标记第0代和第1代内存堆中的对象

而第2代回收和完全回收,则需遍历内存堆中所有存活的对象,这一过程的开销有可能非常大

高代内存堆中的对象有可能是低代内存堆对象的根对象。这样就会导致垃圾回收器遍历到一部分高代内存堆的对象

总结

垃圾回收过程的耗时几乎完全取决于所涉及“代”内存堆中的对象数量,而不是你分配到的对象数量

这就是说,即使你分配了1棵包含100万个对象的树,只要在下一次垃圾回收之前把根对象的引用解除掉,这100万个对象就不会增加垃圾回收的耗时

垃圾回收的频率取决于所涉及“代”内存堆中已被占用的内存大小。只要已分配内存超过了某个内部阈值,就会发生该“代”垃圾回收

这个阈值是持续变化的,GC会根据进程的执行情况进行调整。如果某“代”回收足够划算(提升了很多对象所处的“代”),那垃圾回收就会发生得频繁一些,反之亦然

另一个触发垃圾回收的因素是所有可用内存,与你的应用程序无关

如果可用内存少于某个阈值,为了减少整个内存堆的大小,垃圾回收可能会更为频繁地发生

通过控制内存分配模式来控制垃圾回收的统计指标,就是一种最容易实现的优化方法。这需要理解垃圾回收的工作机制、可用的配置参数、你的内存分配率,还需要对对象的生存期有很好的控制能力

2.2 配置参数

垃圾回收器的配置及调优,很大程度上由硬件配置、可用资源和程序的行为决定。屈指可数的几个参数也是用于控制很高层的行为,且主要取决于程序的类型

2.2.1 工作站模式Workstation还是服务器模式Server

垃圾回收默认采用工作站模式

在工作站模式下,所有的GC都运行于触发垃圾回收的线程中,优先级(Priority)也相同

服务器模式

GC会为每个逻辑处理器或处理器核心创建各自专用的线程。这些线程的优先级是最高的(THREAD_PRIORITY_HIGHEST),但在需要进行垃圾回收之前会一直保持挂起状态。垃圾回收完成后,这些线程会再次进入休眠(Sleep)状态

CLR还会为每个处理器创建各自独立的内存堆

每个处理器堆都包含1个小对象堆和1个LOH。从应用程序角度来看,就只有一个逻辑内存堆,你的代码不清楚对象属于哪一个堆,对象引用会在所有堆之间交叉进行(这些引用共用相同的虚拟地址空间

多个内存堆的存在会带来一些好处

垃圾回收可以并行进行

每个垃圾回收线程负责回收一个内存堆。这可以让垃圾回收的速度明显快于工作站模式

某些情况下,内存分配的速度也会更快一些

特别是对LOH而言,因为会在所有内存堆中同时进行分配

服务器模式还有一点与工作站模式不同,就是拥有更大的内存段,也就意味着垃圾回收的间隔时间可以更长一些

 

到底是用工作站还是服务器模式进行垃圾回收?

如果应用程序运行于专为你准备的多处理器主机上,那就无疑要选择服务器模式。这样在大部分情况下,都能让垃圾回收占用的时间降至最低

果需要与多个托管进程共用一台主机,那么选择就不那么明确了。服务器模式的垃圾回收会创建多个高优先级的线程

如果多个应用程序都这么设置,那线程调度就会相互带来负面影响。这时可能还是选用工作站模式垃圾回收更好

如果你确实想让同一台主机上的多个应用程序使用服务器模式的垃圾回收,还有一种做法,就是让存在竞争关系的应用程序都集中在指定的几个处理器上运行,这样CLR只会为这些处理器创建自己的内存堆

2.2.2 后台垃圾回收

后台垃圾回收(Background GC)只会影响第2代内存堆的垃圾回收行为。第0代和第1代的垃圾回收仍会采用前台垃圾回收,也就是会阻塞所有应用程序的线程。

服务器模式的后台垃圾回收

每个逻辑处理器都拥有一个额外的后台GC线程

如果采用服务器模式垃圾回收和后台垃圾回收,那每个处理器就会有两个GC专用线程

后台垃圾回收与应用程序的线程是并行发生的,但也有可能同时发生了阻塞式垃圾回收

后台GC线程会和其他应用程序线程一起暂停运行,等待阻塞式垃圾回收的完成

后台垃圾回收的关闭

 

提示建议

在实际应用中,应该很少会有关闭后台垃圾回收的理由。如果你想阻止后台垃圾回收的线程占用应用程序的CPU时间,而且不介意完全垃圾回收和阻塞垃圾回收时可能增加的时间和频次,那就可以把它关闭

2.2.3 低延迟模式(Low Latency Mode)

确保较高的性能,可以通知GC不要执行开销很大的第2代垃圾回收

LowLatency——仅适用于工作站模式GC,禁止第2代垃圾回收

SustainedLowLatency——适用于工作站和服务器模式的GC,禁止第2代完全垃圾回收,但允许第2代后台垃圾回收。必须启用后台垃圾回收,本参数才会生效

缺点:

因为不会再进行碎片整理了,所以这两种参数都会显著增加托管堆的大小。如果你的进程需要大量内存,就应该避免使用这种低延迟模式

建议:

在即将进入低延迟模式前,最好是能强制执行一次完全垃圾回收,这通过调用GC.Collect(2, GCCollectionMode.Forced)即可完成。

当代码离开低延迟模式后,马上再做一次完全垃圾回收

请勿将低延迟模式作为默认模式来使用。低延迟模式确实是用于那些必须长时间不被中断的应用程序,但不是100%的时间都得如此

一个很好的例子就是股票交易,在开市期间,当然不希望发生完全垃圾回收。而在休市时间里,就可以关闭低延迟模式并执行完全垃圾回收,等到下一次开市时再切换回来

开启低延迟模式的条件

完全垃圾回收的持续时间过长,是程序正常运行时绝对不能接受的

应用程序的内存占用量远低于可用内存数

无论是关闭低延迟模式期间、程序重启,还是手动执行完全垃圾回收期间,应用程序都可以保持存活状态

无论是关闭低延迟模式期间、程序重启,还是手动执行完全垃圾回收期间,应用程序都可以保持存活状态

2.3 减少内存分配量

如果你减少了内存分配数量,也就减轻了垃圾回收器的运行压力,同时还可以减少内存碎片整理量和CPU占用率。要想减少内存分配量,得动些脑筋才行,还有可能与其他设计目标发生冲突

严格审查每个对象

是否真的需要这个对象?

对象中有没有什么成员是可以摒弃的?

数组能否减小一些?

基元类型(Primitive)能否减小体积(比如Int64换成Int32)?

有些对象是否很少用到,仅在必要时再行分配?

有些类能否转成“结构”(Struct)?这样就能存放在堆栈中,或者是成为其他对象的成员

分配的内存很多,是否只用了一小部分?

能否用其他途径获取数据?

2.4 首要规则

只对第0代内存堆中的对象进行垃圾回收

垃圾回收器,存在一条基本的高性能编码规则。其实垃圾回收器明显就是按照这条规则进行设计的

对象的生存期应该尽可能短暂,这样垃圾回收器根本就不会去触及它们

或者做不到转瞬即逝,那就让对象尽快提升到第2代内存堆并永远留在那里,再也不会被回收

通常这也意味着要把可重用的对象进行池化(Pooling),特别是LOH中的所有对象

内存堆的代数越高,垃圾回收的代价就越大

应该避免大部分第1代回收的发生,因为从第0代提升到第1代的对象,往往会被适时提升到第2代。第1代内存堆可以说是第2代堆的一种缓冲区

编码时的注意

理想状态下,所有对象都应该在下一次第0代回收到来之前离开作用域(Scope)。你可以测算出两次0代回收之间的间隔时间,并与数据在应用程序中的存活时间进行比较

2.5 缩短对象的生存期

对象的作用域越小,在垃圾回收时就越没有机会被提升到下一代

在使用对象时,应该确保对象尽快地离开作用域

如果你的代码要对某个对象进行多次操作,请尽量缩短第一次和最后一次使用的间隔,这样GC就能尽早地回收这个对象了

如果某个对象的引用是一个长时间存活对象的成员,有时你得把这个引用显式地设置为null

也许会稍微增加一点代码的复杂度,因为你得随时准备多检查一下null值,并且还有可能导致功能有效性和完整性之间的矛盾,特别是在调试的时候

另一种平衡功能性和完整性的做法,就是专为调试作出临时修改,让程序(或满足特定需求的部分功能)运行时不对引用设置null,尽可能保持存活

2.6 减少对象树的深度

GC将会沿着对象引用遍历

在服务器模式GC中,一次会有多个线程同时遍历

你肯定希望能尽可能地利用这种并发机制,但如果有某个线程陷入一条很长的嵌套对象链中,那么整个垃圾回收过程就得等这个线程完成工作后才会结束

目前GC线程采用了work-stealing算法来更好地平衡负载

提示:如果你怀疑代码中有很深的对象树存在,那么检查一下还是有好处的

2.7 减少对象间的引用

如果对象引用了很多其他对象,垃圾收集器对其遍历时就要耗费更多的时间

如果垃圾回收引起的暂停时间较长,往往意味着有大型、复杂的对象间引用关系存在

如果难以确定对象所有的被引用关系,那还有一个风险就是很难预测对象的生存期

减少对象引用的复杂度,不仅对提高代码质量有利,而且可以让代码调试和修正性能问题变得更加容易

减少对象引用的复杂度,不仅对提高代码质量有利,而且可以让代码调试和修正性能问题变得更加容易

比如第2代内存堆中有个对象包含了对第0代内存堆对象的引用,这样每次第0代垃圾回收时,总有一部分第2代内存堆中的对象不得不被遍历到,以便确认它们是否还持有对第0代对象的引用。这种遍历的代价虽然没有像完全垃圾回收那么高,但不必要的开销还是能免则免

2.8 避免对象固定(Pining)

对象固定(Pinning)是为了能够安全地将托管内存的引用传递给本机代码。最常见的用处就是传递数组和字符串。如果不与本机代码进行交互,就完全不应该有对象固定的需求

内存地址固定

对象固定会把内存地址固定下来,垃圾回收器就无法移动这类对象。虽然固定操作本身开销并不大,但会给垃圾回收工作造成一定困扰,增加出现内存碎片的可能。垃圾回收器是会记住那些被固定的对象,以便能利用固定对象之间的空闲内存,但如果固定对象过多,还是会导致内存碎片的产生和内存堆的扩大

对象固定形式-显示

对象固定既可能是显式的,也可能是隐式的。使用GCHandleType.Pinned类型的GCHandle或者fixed关键字,可以完成显式对象固定,代码块必须标记为unsafe。用关键字fixed和GCHandle之间的区别类似于using和显式调用Dispose的差别。fixed/using用起来更方便,但无法在异步环境下使用,因为异步状态下不能传递handle,也不能在回调方法中销毁handle

隐士的对象固定

隐式的对象固定更为普遍,但也更难被发现,消除则更困难。最明显的来源就是通过P/Invoke传给非托管代码的所有对象。这种P/Invoke并不仅仅是由你编写的代码发起的,你调用的托管API可以而且经常会调用本机代码,也都需要对象固定

建议:

理想状态下,应该尽可能消除对象固定

如果真的做不到,请参照缩短托管对象生存期的规则,尽可能地缩短固定对象的生存期

如果对象只是暂时被固定,那影响下一次垃圾回收的机会就比较少

你还应该避免同时固定很多对象

位于第2代堆或LOH中的固定对象一般不会有问题,因为移动这些对象的可能性比较小

2.9 避免使用终结方法

若非必要,永远不要实现终结方法(Finalizer)。终结方法是一段由垃圾回收器引发调用的代码,用于清理非托管资源

终结方法 Finalizer

终结方法由一个独立的线程调用,排成队列依次完成,而且只有在一次垃圾回收之后,对象被垃圾回收器声明为已销毁,才会进行调用如果类实现了终结方法,对象就一定会滞留在内存中,即便是在垃圾回收时应该被销毁的情况下。终结方法不仅会降低垃圾回收的整体效率,而且清理对象的过程肯定会占用CPU资源

IDispossable

如果实现了终结方法,那就必须同时实现IDisposable接口以启用显式清理,还要在Dispose方法中调用GC.SuppressFinalize(this)来把对象从移除终结队列中移除。只要能在下一次垃圾回收之前调用Dispose,那就能适时把对象清理干净,也就不需要运行终结方法了

演示代码

class Foo : IDisposable {  ~Foo()  {   Dispose(false);  }  public void Dispose()  {   Dispose(true);   GC.SuppressFinalize(this);  }  protected virtual void Dispose(bool disposing)  {   if (disposing)   {    this.managedResource.Dispose();   } // 清理非托管资源   UnsafeClose(this.handle);   // 如果基类是IDisposable   // 请务必调用   //base.Dispose(disposing);  } }

注意:

有些人以为终结方法肯定会被执行到。一般情况下确实如此,但并不绝对

如果程序被强行终止,就不会再运行任何代码,进程也会立即被销毁

而且即便是在进程正常关闭时,所有终结方法的总运行时间也是有限制的

如果你的终结方法被排在了队列的末尾,就有可能被忽略掉

是逐个执行的,如果某个终结方法陷入死循环,那么排在后面的终结方法就都无法运行了

虽然终结方法不是运行在GC线程中,但仍需由GC引发调用。如果没有发生垃圾回收,那么终结方法就不会运行

2.10 避免分配大对象

大对象的界限被设为85 000字节,判断的依据是基于当天的统计学分析。任何大于这个值的对象都被认为是大对象,并在独立的内存堆中进行分配

应该尽可能避免在LOH中分配内存

不仅是因为LOH的垃圾回收开销更大,更多原因是因为内存碎片会导致内存用量不断增长

需要严格控制程序在LOH中的分配。

LOH中的对象应该在整个程序的生存期都持续可用,并以池化的方式随时待命。

2.11 避免缓冲区复制

任何时候都应该避免复制数据

比如你已经把文件数据读入了MemoryStream(如果需要较大的缓冲区,最好是用池化的流),一旦内存分配完毕,就应把此MemoryStream视为只读流,所有需要访问MemoryStream的组件都能从同一份数据备份中读取数据 如果需要表示整个缓冲区的一段,请使用ArraySegment<T>类,可用来代表底层byte[]类型缓冲区的一部分区域。此ArraySegment可以传给API,而与原来的流无关,甚至可以被绑定到一个新的MemoryStream对象上。这些过程都不会发生数据复制

代码

var memoryStream = new MemoryStream(); var segment = new ArraySegment(memoryStream.GetBuffer(), 100, 1024); …… var blockStream = new MemoryStream(segment.Array,              segment.Offset,              segment.Count);

优化方法

内存复制造成的最大影响肯定不是CPU,而是垃圾回收。如果你发现自己有复制缓冲区的需求,那就尽量把数据复制到另一个池化的或已存在的缓冲区中,以避免发生新的内存分配

2.12 对长期存活对象和大型对象进行池化

池化实际上是一种人工的内存管理策略,但在这种场合却真的收效甚佳

强烈推荐池化的对象,就是在LOH中分配的对象,典型例子就是集合类对象

池化的方法没有一定之规,也没有标准的API可用,确实只能自己开发,可以针对整个应用,也可以只为特定的池化对象服务

简单的池化代码

interface IPoolableObject : IDisposable {  int Size { get; }  void Reset();  void SetPoolManager(PoolManager poolManager); } class PoolManager {  private class Pool  {   public int PooledSize { get; set; }   public int Count { get { return this.Stack.Count; } }   public Stack Stack { get; private set; }   public Pool()   {    this.Stack = new Stack();   }  }  const int MaxSizePerType = 10 * (1 << 10); // 10 MB  Dictionary pools =    new Dictionary();  public int TotalCount {   get   {    int sum = 0;    foreach (var pool in this.pools.Values)    {     sum += pool.Count;     }    return sum;   }  }  public T GetObject()    where T : class, IPoolableObject, new()  {   Pool pool;   T valueToReturn = null;   if (pools.TryGetValue(typeof(T), out pool))   {    if (pool.Stack.Count > 0)    {     valueToReturn = pool.Stack.Pop() as T;    }   }   if (valueToReturn == null)   {    valueToReturn = new T();   }   valueToReturn.SetPoolManager(this);   return valueToReturn;  }  public void ReturnObject<T>(T value)    where T : class, IPoolableObject, new()  {   Pool pool;   if (!pools.TryGetValue(typeof(T), out pool))   {    pool = new Pool();    pools[typeof(T)] = pool;   }   if (value.Size + pool.PooledSize < MaxSizePerType)   {    pool.PooledSize += value.Size;    value.Reset();        pool.Stack.Push(value);   }  } } class MyObject : IPoolableObject {  private PoolManager poolManager;  public byte[] Data { get; set; }  public int UsableLength { get; set; }  public int Size  {   get { return Data!= null?Data.Length : 0; }  }  void IPoolableObject.Reset()  {   UsableLength = 0;  }  void IPoolableObject.SetPoolManager(   PoolManager poolManager)  {   this.poolManager = poolManager;  }  public void Dispose()  {   this.poolManager.ReturnObject(this);  } }

注意事项

在每次把池化对象归还共享池时,你的代码必须把对象重置为已知的、安全的状态

池化对象的回收也是一件特别棘手的事情,因为你不是真的要销毁内存(这也是池化的全部意义所在),但你必须能通过可用空间表示出“空集合”的概念

还有一条策略就是,为你的可池化类实现终结方法,以作为保险机制。如果终结方法得以运行,就意味着Dispose没被调用过,也就是存在错误。这时可以把信息写入日志,可以让程序异常终止,或者是把错误信息显示出来

共享池的尺寸应该限定边界(字节数或是对象数),只要超过了规定大小,就应该把对象扔给GC进行清理

2.13 减少LOH的碎片整理

如果做不到完全避免LOH分配,那你就应该尽力避免碎片整理

2.14 某些场合可以强制执行完全回收

默认不应该干扰GC

除了GC正常的调度计划中安排的之外,你不应该再强制执行完全垃圾回收。那样会干扰垃圾回收器的自动调优活动,还可能导致整体性能下降

高性能系统中的考虑

为了避免以后发生不合时宜的完全垃圾回收过程,在某个更合适的时间段强制执行一次完全回收也许会有所收益

你采用了低延迟GC模式。这种模式下内存堆的大小可能会一直增长,需要适时进行一次完全垃圾回收。关于低延迟GC模式,请阅读本章前面的相关内容。

偶尔你会创建大量对象,并会存活很长时间(理想状态是一直保持存活)。这时最好是把这些对象尽快提升到第2代内存堆中。如果这些对象覆盖了即将成为垃圾的其他对象,通过一次强制垃圾回收就能立即销毁这些垃圾对象

你正处于要对LOH进行碎片整理的状态

GC.Collect

参数为需要回收的代数,即可执行完全垃圾回收。此外还可以附带一个参数,值为GCCollectionMode枚举,指明完全回收的时间由GC决定。参数值有3种可能

Default——立即进行强制完全回收

Forced——由垃圾回收器立即启动完全回收

Forced——由垃圾回收器立即启动完全回收

GC.Collect(2);  =》GC.Collect(2, GCCollectionMode.Forced);

2.15 必要时对LOH进行碎片整理

GCSettings.LargeObjectHeapCompactionMode =  GCLargeObjectHeapCompactionMode.CompactOnce;

2.16 在垃圾回收之前获得通知

如果你的应用程序绝对不能受到第2代垃圾回收的破坏,那么可以让GC在即将执行完全垃圾回收时通知你。这样你就有机会暂停程序的运行,也许是停止向这台主机发送请求,或者是让你的应用程序进入更合适的状态

以下条件成立时,你才能从垃圾回收通知中受益

完全垃圾回收的开销过大,以至于程序在正常运行期间无法承受

你可以完全停止程序的运行(也许这时的工作可以由其他计算机或处理器承担)

你可以迅速停止程序运行(停止运行的过程不会比真正执行垃圾回收的时间更久,你就不会浪费更多的时间)

2代垃圾回收很少发生,因此执行一次还是划算的

2.17 用弱引用作为缓存

弱引用(Weak Reference)指向的对象允许被垃圾回收器清理。与之相反,强引用(Strong Reference)会完全阻止所指对象被垃圾回收

2.18 评估和研究垃圾回收性能

2.18.9 内存碎片的产生时机

2.18.10 对象位于第几代内存堆中

2.18.11 第0代内存堆中存活着哪些对象

2.18.12 谁在显式调用GC.Collect方法

2.18.13 进程中存在哪些弱引用

小结

为了能让应用程序真正获得性能的优化,你需要深入了解垃圾回收的过程

请为应用程序选择正确的配置参数,比如在独占主机时选用服务器模式的垃圾回收机制

请尽量缩短对象的生存期,减少内存分配次数。把那些生存期必须长于平均垃圾回收频率的对象全部都进行池化,或者让它们在第2代内存堆中永久性存活下去

尽可能避免对象固定和使用终结方法。所有LOH中的内存分配都应该池化并维持永久存活,以避免发生完全垃圾回收

让对象维持统一大小,偶尔也适时进行一次碎片整理,以减少LOH中的内存碎片

为了避免不合时宜的完全垃圾回收对应用程序的影响,可以考虑使用垃圾回收通知

垃圾回收器的行为是确定可控的,通过仔细调整对象分配频率和生存期,你就可以控制垃圾回收器的行为


本文使用 文章同步助手 同步


编写高性能.net代码--垃圾回收的前世今生的评论 (共 条)

分享到微博请遵守国家法律