C# 垃圾回收器高效工作

首先我会专注于Workstation GC(因此所有的数字都是工作站GC的)。然后我会谈谈工作站GC和服务器GC之间的区别(有时候你没有必要选择,稍后我会解释为什么)。

代:

把托管堆上的对象分成3代是为了调优垃圾回收的性能,大多数对象都在0代时消亡。例如:在一个服务器程序中,处理每个请求相关的对象,都会在请求完 成后消亡。本质上1代对象是在新分配对象和常驻内存之间的一个缓冲区。当你在性能计数器中观察2代回收发生的次数比0代回收次数要少的多。而1代回收次数 相对来说不是很重要,回收1代对象比回收0代对象的代价高的不是很多。而回收2代对象就意味着要扫描整个托管堆了,代价相对要大得多。

GC段(segment):

首先让我们看一下GC是如何向操作系统申请内存的。GC以段的方式保留内存。每一个段是16M(服务器模式下可能是64M)。当执行引擎启动时,我们保留初始的GC段,一个给小对象用,另一个段给大对象用。有关大对象堆的垃圾回收请参考这里

在需要的时候可以向操作系统申请更多内存,或者交还给操作系统。当所有段都用完之后我们就申请一个新段。在每一次完整的垃圾回收之后多余的段会交还给操作系统。

大对象有自己的段,垃圾回收器对大对象的处理方式和小对象是不一样的所以大对象不和小对象共享段。

分配:

当你在托管堆上分配一个对象时,要付出什么代价呢?如果我们不考虑回收的话,有两点1是向前移动指针,2是为新对象清空内存。而对于实现Finalize的方法的对象还需要把对象的指针放到终结队列中。

注意我说的是“如果我们不考虑回收”—这意味着分配的代价和对象的大小成正比。申请的越少,GC的代价就越小。如果你需要15个byte,就申请 15个字节;不要像使用maalloc一样申请32个字节。有一个阀值,当超过这个值时,就会触发垃圾回收。你要尽可能少的触发垃圾回收。

GC堆和NT堆还有一点不同:分配对象的时间越接近,对象在GC堆上的也越接近。

在GC堆上分配的每一个对象都需要额外的8byte的开销,4byte用来同步,4byte存放方法表指针。

回收:

首先我们要知道什么时候触发回收? 有如下三种情况会触发:
1. 分配时超过了0代堆的阀值
2. 调用了GC.Collect()方法
3. 操作系统给应用程序发出低内存信号

第1种情况是最典型的触发原因,当分配的对象足够多时,就会触发0代堆的垃圾回收。在每一次回收之后,0代堆就清空了。然后继续分配对象,0代堆填满之后就会触发下一次回收。

你要尽量避免第2种情况,这个很简单,不要在程序代码中调用GC.Collect方法就可以了。通常情况下你不应该调用Collect方法。BCL is basically the only place that should call this (in very limited places);当你在程序中调用GC.Collect方法时,性能会降低,因为回收提前执行了,而垃圾回收器执行回收的调度是经过算法优化的。

第3种情况受操作系统上运行的其他程序影响,这个你的程序没法控制,你只能尽可能的优化好你的程序或模块。

让我们谈一下这意味着什么。首先,托管堆是程序的工作集的一部分。它会消耗私有页。在理想情况下,所有对象都在0代时消亡(这意味着,几乎所有对象 都在0代回收,完全回收从不会发生)因此,你的GC堆永远不会超过0代堆的大小。而事实上这种情况是不可能的,因此,你真的需要保证托管堆的大小是可控 的。

第二,你需要保证垃圾回收消耗的时间资源是可控的。这个意思是一要尽可能少触发GC,二尽可能少发生高代的GC。一次高代的回收要比底一代回收的代价高得多,因为高代的回收要扫描更多的对象,要同时执行所有更低代的回收。

CLRProfiler是一个观察GC堆看堆上的对象被那个对象引用的工具,它非常棒。

如何组织你的数据:

1) 用值类型还是引用类型

如你所知,值类型数据是存放在栈上的,而引用类型对象是存在托管堆上的。因此,人们会问,如何决定什么时候使用值类型,什么使用引用类型呢。值类型 不会触发垃圾回收,但是如果你的值类型经常做装箱操作,装箱操作要比刚开始就创建一个引用类型对象要昂贵的多;当值类型对象作为参数传递时需要复制一份。 但是如果你的引用类型只有一个小成员如果做成引用类型的话,还需要额外4字节的指针开销和同步开销以及方法表开销。因此该使用值类型还是引用类型是由类型 本身决定的。

2) 富引用对象(Reference rich object)
如果一个对象是富引用的,会给分配和回收都带拉压力。每一个内嵌的对象都需要8字节的额外开销。因为分配的开销和对象的大小是成正比的,所以开销就大了一些。另外富引用会导致构建对象图的时间增大,增加了回收的开销。

因此我建议你设计对象时只设计必要的字段,如果对另外一个引用类型的强引用不是必须的,就不要引用它。你应该尽量避免让已存在很长时间的对象引用新分配的对象。

3) 可终结对象(实现Finalize方法的对象)
如垃圾回收原理1中所述终结对象会延长回收的时间,不仅延长可终结对象本身的,还会延长它的引用链下游的所有对象的回收时间。所以如果对象必须是可终结的,你就要尽可能的隔离它,不让它引用其他对象

4) 对象的存储位置:
当你为一个对象的子对象分配空间时,你最好在同一时间分配父对象和子对象,这样父子对象在托管堆上的地址就会在一起,回收起来也会一起回收,回收的效率就会相对高一些。

大对象:

当一个对象占用的内存超过85,000bytes时它就会被分配到LOH上。SOH段永远都不会做移动—而只是清空对象(使用一个空的链表)。但是这个情况是一种实现的细节,你不应该依赖这个实现细节。如果你分配了一个大对象,不希望他发生移动,那么你应该fix它。

只有在2代回收时才会做大对象的回收,而2代回收的代价是很大的。有时候你会发现2代回收之后2代堆的大小并没有发生多大变化,这有可能是因为大对象堆大小超过阀值触发了2代回收。

一个好的实践:分配一个大对象然后重复利用它。如果说你需要一个100k或者120k的大对象,你应该申请一个120k的然后重复利用它。多次分配临时大对象可能会触发2代回收,对性能会有负面影响。

这篇文章我们谈谈GC的不同工作模式,以及各个模式如何工作和他们之间的不同,让你明白你的应用程序该如何选择工作模式。

迄今为止运行时GC工作模式:

1)关闭并发的工作站GC
2)开启并发的工作站GC
3)服务器GC

如果你在写一个独立的托管程序并且没有做任何配置,你使用的GC工作模式是开启并发的工作站GC。这一点多数人可能会感到惊讶,因为我们的文档中并 没有提起并发GC,有时候会把并发GC称为”background GC”(while referring working GC as “foreground GC”).

如果你的程序在宿主程序中运行,宿主可能会为程序选择GC的工作模式。

需要注意的是:如果你的程序配置成服务器GC,你的程序运行在一台高性能的机器上,实际上你的服务器是运行在“关闭并发的工作GC”模式下。因为“关闭并发的工作站GC”为高性能服务器的大吞吐量服务做了优化。

各个GC模式的设计目标:

1) 关闭并发的工作站GC为高性能服务器的高吞吐量做了优化。我们在垃圾回收时根据分配和复活模式做动态调优因此可以程序运行时自动调优GC的工作效率。
2) 开启并发的工作站GC是为要求精确响应时间的交互式应用程序设计的。开启并发使垃圾回收造成的工作进程暂停时间缩短。 这个目的是用一些内存和CPU换来的,因此在这种模式下垃圾回收需要做的工作略多一点需要的回收时间会略长一些。
3) 服务器GC,从名字上我们可以看出这种工作模式是为服务器应用程序设计的;典型的场景是你有一个工作线程池这些线程坐着相似的处理。例如:做处理同样的请 求或者处理相同类型的事务。所有的线程使用几乎相同的分配模式。服务器GC是为要求高吞吐量的和高扩展性的多处理器服务器设计的

各个GC模式如何工作:

让我们从关闭并发的工作站GC说起,其执行流程如下:
1) 一个托管进程做内存分配
2) 分配完所有可用的内存(我会解释这是什么意思)
3) 触发了垃圾回收,垃圾回收操作在做分配的线程上运行
4) GC调用SuspendEE来挂起所有的托管线程
5) GC开始工作
6) GC调用RestartEE来重启工作托管进程
7) 托管进程继续运行

你可以看到在第5步中所有的托管线程都停止执行来等待垃圾回收完成工作。SuspendEE不会挂起本地线程(native threads)。
GC中的每一代都有一个“分配预算”的概念。每一代的“分配预算”在运行时是动态调整的。因为我们经常在0代上做分配,你可以想象0代预算超支了,这样就会触发垃圾回收。这里的预算和GC堆的段大小完全不是一回事,预算比段大小要小得多。

CLR垃圾回收可以做内存移动也可以不做移动。不做移动时也称为“清扫”,清扫的代价要比做压缩的代价低一些,因为他不需要复制移动内存。

在开启并发垃圾回收(Concurrent GC)时,最大的差异是挂起和重启。 我前面提到并发GC允许更少的暂停时间。因此开启并发的垃圾回收会尽可能少的执行垃圾回收,执行时间也非常短。在剩余的时间中如果需要托管线程可以运行和 分配内存。开启并发时我们会在开始时给0代一个很大的分配预算来保证垃圾回收运行期间有足够的空间分配对象。尽管如此,如果在并发回收运行中托管线程需要 分配过多的内存,线程也会被堵塞直到回收完成。

记住0代和1代回收非常快,所以在做0,1代回收时是不会做并发回收的。我们只是在2代回收时才会并发回收。如果我们决定做2代回收,我们会决定是否并发回收。

服务器垃圾回收,这种模式和前两种完全不同。我们会为每一个CPU创建一个回收线程。垃圾回收在这些线程上执行而不是在分配线程上,其工作流程如下:
1. 一个托管线程做回收
2. 分配达到阀值
3. 给GC线程发信号,让GC线程做垃圾回收,等待回收结束
4. GC线程运行,结束时发出回收完成的信号(在回收过程中,所有的托管线程会像工作站模式中一样被挂起)
5. 托管线程收到信号重新开始运行

如果配置各个垃圾回收模式:
要关闭并发回收,在配置文件中添加下面配置项:

?
<configuration>
<runtime>
<gcConcurrentenabled="false"/>
</runtime>
</configuration>

要使用服务器GC,使用下面配置:

?
<configuration>
<runtime>
<gcServerenabled=“true"/>
</runtime>
</configuration>

关于这两个配置节可以参考msdn。

这篇文章我们谈谈固定对象的内存地址(pinning)和弱引用……这两个和垃圾回收处理密切相关的东西。

固定对象的内存地址:

固定对象的内存地址和实现Finalize方法的对象有一点是相同的 …… 两者都是因为我们的程序不得不和本地代码打交道。

怎么固定对象的内存地址呢?有三种方法
1. 使用GCHandle的静态方法Alloc(object val,GCHandleType type) ,将type值设为GCHandleType.Pinned
2. 在C#中使用fixed关键字
3. 在调用本地代码时,本本地代码固定(例如:to marshal LPWSTR as a String object, Interop pins the buffer for the duration of the call)

对于小对象堆来说,在代码中固定对象地址是导致内存碎片的唯一原因,如果没有固定内存地址的对象那么在小对象堆中就不应该有碎片。

而对于大对象堆,固定内存地址的操作是无效的,因为现在的垃圾回收机制是不会移动大对象堆的对象的。不过,这一点只是GC的内部实现,你不应该依赖于这个实现,如果大对象需要固定内存地址,你还是要写固定需要的代码。

内存碎片从来都不是一个好东西。它会增加垃圾回收工作的难度 —— 如果没有固定内存地址的对象,垃圾回收器在移动内存时只需要将非垃圾对象覆盖空闲内存就可以了,而堆上存在固定地址的对象,垃圾回收器就不得不在移动中考虑,不覆盖这类对象,也不能移动它们。

那么如何才能知道你的程序中有多少内存碎片呢?你可以使用!dumpheap命令:“dumpheap –type Free -stat”会给出所有释放对象占用内存的统计信息。 通常情况下如果碎片大小占总大小的比例小于10%的话,就没什么可担忧的。因此如果你看到释放对象的绝对数很大,但是总数少于10%就没必要害怕。

如果你确实需要固定对象的地址,请注意下面几件事情:

1. 短时间的固定开销会很小
“短时间”,多短算短呢? 如果固定内存地址的对象在垃圾回收之前就成为垃圾对象了,那么这个时间就是足够短了。因为固定内存其实就是在对象头置一个bit位,如果在对象存活期没有 发生垃圾回收,那么就没有额外开销。如果在垃圾回收发生后这个对象还活着,垃圾回收器在移动内存时就得做更多的计算保证不会移动此对象,也不会覆盖它。

2. 固定老对象的代价会比固定年轻对象的代价要小一些
何为“老对象”呢?是指经过两次垃圾回收,已经被迁移到2代堆的对象;这时候对象的所在的内存区域已经相对稳定了。造成内存碎片的可能性会小一些。


两个固定对象内存地址好实践:
1. 在大对象堆上分配固定地址的对象,每次使用使使用其中的一部分
这样做的优点是显而易见的,大对象堆不会做内存移动操作,所以就不存在因为固定对象地址导致的开销了;缺点是没有现成的API来把大对象分成一小块一小块使用,这需要开发人员按需编码使用。

2. 分配一个小对象的缓冲池,(and then hand them out when needed)
例如,我有一个缓冲池,方法M有一个byte[]数组需要固定内存地址。如果这个数组已经是2代对象了,就固定它。而如果缓冲区不需要使用很长时间,那么就在0代和1代回收时回收它。这样所有在缓冲池中的对象就都是2代对象了。
void M(byte[] b)
{
if (GC.GetGeneration(b) == GC.MaxGeneration)
{
RealM(b);
return;
}

// GetBuffer will allocate one if no buffers
// are available in the buffer pool.
byte[] TempBuffer = BufferPool.GetBuffer();
RealM(TempBuffer);
CopyBackToUserBuffer(TempBuffer, b);
BufferPool.Release(TempBuffer);
}

弱引用:

弱引用是如何实现的呢?

一个弱引用对象有托管和非托管两个部分。托管部分是WeakReference对象本身。在它的构造函数中我们创建一个GC句柄,它是非托管的部分 —— 这会在AppDomain的句柄表中插入一项(GCHandle类的Alloc方法都是这么做的,只不过是将不同类型插入到各自的表中)。当弱引用指向的 对象没有强引用时就会被垃圾回收器回收掉。因为WeakReference对象本身是一个托管对象,所以它没有强引用时也会被回收。

弱引用没必要引用小对象:

如果你有一个非常小的对象,比如说一个DWORD字段,对象的大小是12byte(12byte是对象的最小尺寸)。而WeakReference 对象有一个IntPtr和一个bool字段,而GC句柄是一个指针的大小(32位机器4byte,64位机器8byte);这就是说你需要使用15个 byte的对象来延长一个12byte对象的长度,这是不划算的。显然你不应该创建很多弱引用对象来引用一些小对象。

那么,弱引用有什么用呢?
弱引用的作用是在垃圾回收发生之前即便对象上没有强引用,也可以再次使用该对象。如果执行垃圾回收它会被回收。

为什么要使用弱引用来跟踪一个对象的释放,而不是用Finalizer方法呢? 使用弱引用的优点是跟踪的对象不会被推迟到下次垃圾回收时才真正的被回收;缺点是需要消耗一点内存,只有当用户代码检查弱引用指向的对象为null时才清除对象。

下面是两个弱对象的使用实例:
Option A):

?
classA
{
  WeakReference _target;
 
  MyObject Target
  {
     set
    {
       _target =newWeakReference(value);
    }
     get
    {
       Object o = _target.Target;
       if(o !=null)
       {
           returno;
       }
       else
       {
          // my target has been GC'd - clean up
          Cleanup();
          returnnull;
       }
    }
}
 
voidM()
{
   // target needs to be alive throughout this method.
  MyObject target = Target;
 
   if(target ==null)
   // target has been GC'd, don't bother
   return;
   else
   {
      // always need target to be alive.
      DoSomeWork();
   }
 
    GC.KeepAlive(target);
}
}

Option B):

?
classA
{
    WeakReference _target;
    MyObject ShortTemp;
  
    MyObject Target
    {
        set
        {
            _target =newWeakReference(value);
        }
  
        get
        {
            Object o = _target.Target;
            if(o !=null)
            {
                returno;
            }
            else
            {
                // my target has been GC'd - clean up
                Cleanup();      
                returnnull;
            }
        }
    }
  
    voidM()
    {
        // target needs to be alive throughout this method.
        MyObject target = Target;
        ShortTemp = target;
  
        if(target ==null)
        {
            // target has been GC'd, don't bother
            return;
        }
        else
        {
            // could assert that ShortTemp is not null.
            DoSomeWork();
        }
  
        ShortTemp =null;
    }
}

使用弱对象维护缓存:

你可以创建一个WeakReference指向对象的数组
WeakReferencesToObjects WeakRefs[];

数组中的每个元素都是弱对象指向的对象。我们可以定期的遍历这个数组看哪个对象已经死了然后释放引用此对象的WeakReference对象。

如果我们经常处理已经释放的对象,缓存就会在每次垃圾回收之后失效。

如果对你来说这还不够,你可以使用二级缓存机制。

维持一段时间的缓存项的强引用列表,在这段时间之后,将强引用转换成弱引用,弱引用意味着这些项将要被剔除。

你可能有不同的策略来处理缓存,比如根据缓存被读取次数——读取次数少的项被转换成弱引用;或者根据缓存项的个数,如果缓存项数超过某个数字就把超过的缓存项设置为弱引用。这取决于你的应用。调优缓存是另一个完整的主题——或许将来我会写一下。

转载于:https://my.oschina.net/dgwutao/blog/139422

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值