第二代垃圾回收器是否总是回收无用对象?

24
通过监控全新的.NET 4.5服务器应用程序的CLR #Bytes in all Heaps性能计数器,在过去的几天里,我注意到了一种模式,这让我觉得Gen2收集器并不总是收集死对象,但我很难理解到底发生了什么。 该服务器应用程序在.NET Framework 4.5.1上运行,使用Server GC / Background。 这是一个控制台应用程序,通过Topshelf框架作为Windows服务托管。 该服务器应用程序正在处理消息,吞吐量目前保持相当稳定。 从CLR #Bytes in all Heaps图表中可以看到,内存从18MB开始增长到大约在20-24小时内达到35MB(在此时间范围内进行了20-30次Gen2收集),然后突然降回到18MB的名义值,然后再次增长到大约35MB,并且如此循环(我可以看到该模式在过去6天中一直在重复...)。内存增长不是线性的,需要大约5个小时才能增加10MB,然后需要15-17个小时才能增加剩余的10MB左右。 问题在于,通过查看#Gen0/#Gen1/#Gen2 collections的perfmon计数器,可以看到在20-24小时的时间段内进行了大量的Gen2收集(可能有30个),但没有一个使内存降回到名义的18MB。然而,奇怪的是,通过使用外部工具来强制进行GC(在我的情况下为Perfview),然后我可以看到#Induced GC上升了1个(调用了GC.Collect,因此这是正常的),并且立即将内存恢复到名义的18MB。这让我想到,要么#Gen2收集器的计数器不正确,在20-22小时左右只会发生一次单独的Gen2收集(我真的不这么认为),要么Gen2收集器并不总是收集死对象(看起来更有可能)……但是如果是这种情况,为什么通过GC.Collect强制进行垃圾回收就可以解决问题呢?显式调用GC.Collect和应用程序生命周期中自动触发的垃圾回收之间有什么区别。
我相信有一个很好的解释,但从我找到的关于GC的不同文档来源-太少了 :( - 可以得出:无论如何,Gen2收集器都会收集死对象。所以也许文档不是最新的,或者我误读了......欢迎任何解释。谢谢!
编辑: 请查看4天内#Bytes in all heaps图表的截图
(点击查看更大的视图)
https://istack.dev59.com/NyCsn.webp 这比试图在脑海中绘制图形容易得多。您可以在图表上看到我上面说的东西...内存在20-24小时内增加(在此期间进行了20-30次Gen2收集),直到达到约35MB,然后突然下降。您将注意到在图表末尾,我通过外部工具触发的诱导GC立即将内存降至名义上的值。
编辑#2: 我对代码进行了很多清理,主要涉及终结器。我有很多类引用了可丢弃类型,因此必须在这些类型上实现IDisposable。然而,我被一些文章误导成无论如何都要在Disposable模式中实现终止器。阅读了一些MSDN文档后,我明白了只有当类型本身持有本机资源时才需要终止器(即使在这种情况下也可以使用SafeHandle避免)。所以我从所有这些类型中删除了终结器。代码还有一些其他修改,但主要是业务逻辑,与.NET框架无关。现在,这张图表看起来非常不同,它是一条大约20MB的平直线,已经持续了好几天......正是我所期望看到的!现在问题已经解决,但是我仍然不知道问题的原因......似乎可能与finalizers有关,但仍然无法解释我所注意到的情况,即使我们没有调用Dispose(true)-抑制finalizer-,finalizer线程也应该在垃圾回收之间启动,而不是每20-24小时启动?考虑到我们现在已经远离了这个问题,需要时间回到“有缺陷”的版本并再次重现它。尽管如此,我可能会尝试一下并找到问题的根源。

编辑:添加了Gen2收集图表(点击查看更大的视图)

graph


5
有第三种可能性,即当您认为第二代集合中的对象已死亡时,它们实际上并未死亡。我建议使用分析工具来跟踪对象的生命周期,并查看是否有一些长期存活的对象(存活时间为20-22小时),在不应该持有引用的情况下仍然保留着引用。另外,您之前从未提到过收集前后的对象计数,您是否有一个单独的17MB对象需要花费20个小时才能被释放,还是有成百上千个小对象都被一个长期存活的对象所引用而无法释放? - Scott Chamberlain
@Scott:是的,但在这种情况下,为什么诱导GC会收集这些对象并返回到标准内存,而应用程序生命周期中的普通Gen2 GC却没有这样做呢?请参见附图以更好地理解。 - darkey
2
你的应用程序池设置多久回收一次? - NotMe
这是什么类型的“应用程序”?它是否在IIS中运行,并受到应用程序池问题的影响? - Reed Copsey
这是一个控制台应用程序,使用Topshelf框架作为Windows服务托管。它不是由IIS托管的,因此不受应用程序池回收的影响。 - darkey
显示剩余5条评论
6个回答

7

http://msdn.microsoft.com/en-us/library/ee787088%28v=VS.110%29.aspx#workstation_and_server_garbage_collection

垃圾回收的条件

垃圾回收在以下情况之一为真时发生:

  • 系统物理内存不足。

  • 托管堆上已分配对象的内存超过可接受的阈值。此阈值会随着进程运行而不断调整。

  • 调用GC.Collect方法。在几乎所有情况下,您都不必调用此方法,因为垃圾回收器会持续运行。此方法主要用于特殊情况和测试。

看起来你正在遇到第二种情况,35是阈值。如果35太大,你应该能够将阈值配置为其他值。


gen2集合没有任何特殊之处,会导致它们偏离这些规则。(参见https://dev59.com/C17Va4cB1Zd3GeqPKYBM#8582251)


谢谢Hogan。我知道这些规则。然而,据我理解,这些规则说明了什么会触发一个集合(无论是Gen0/Gen1还是Gen2,它们都有预算(也称为阈值))。一旦触发了一个集合,它就会收集死对象(或者等待下一个集合,如果存在终结器)。因此,如果GC决定我的Gen2阈值为35MB,那么只有在达到阈值时才会触发Gen2 GC。这意味着根据我的图表,每20-24小时才会发生一次Gen2集合。然而,在此期间,GC会触发大约20-30个Gen2集合。 - darkey
@darkey - 我觉得我可能受到限制,因为我无法看到图片(这里有防火墙),我回家后会查看它们,也许那时我就能理解了。 - Hogan
@darkey - 这个图表看起来像是每20-24小时会触发一次集合,这是你所期望的。你为什么认为还有更多呢? - Hogan
2
如果您阅读我的帖子,您会发现在每个20-24小时的周期内,大约会进行20-30次Gen 2收集。当您看到周期结束时的下降是由于Gen2收集引起的。现在我的问题是为什么期间内的前20-30个先前的Gen2收集没有回收内存,我希望有一个相对平稳的线条...这不是因为对象存活了20-24小时,因为正如您在图表末尾所看到的,如果我通过外部工具(Perfview)手动触发诱导GC(GC.Collect)在周期开始时,所有内存都被Gen2回收了。 - darkey
@darkey,我看了你的帖子,问题仍然存在,你认为这20-30个集合是为什么发生的?如果只是因为线路下降,还有其他的解释方式。GC事件并不是唯一释放此内存的方法。 - Hogan
1
因为我监视“#Gen 2 Collections”性能计数器,并且我知道在某个时间段内会发生20-30次Gen2。来自MSDN的说明:“显示应用程序启动以来垃圾回收代数2对象被垃圾回收的次数。计数器在代数2垃圾回收(也称为完整垃圾回收)结束时递增。”…考虑到图表表示“#Bytes in all heaps”(“此计数器指示在垃圾回收堆上分配的当前内存字节数。”),我不知道除了GC之外还有什么能够释放这些内存。 - darkey

1
阅读您的第一个版本,我认为这是正常行为。 但在这种情况下,为什么通过GC.Collect强制进行垃圾回收会起作用,在应用程序生命周期内自动触发的收集与显式调用GC.Collect之间有什么区别。
有两种类型的收集:完全收集和部分收集。自动触发的收集是部分收集,但调用GC.Collect时将进行完全收集。
同时,如果您告诉我们您正在所有对象上使用终结器,那么我可能已经找到了原因。如果由于任何原因其中一个对象被提升到#2代,则只有在进行#2代收集时才会运行终结器。
以下示例将演示我刚才说的内容。
public class ClassWithFinalizer 
{
    ~ClassWithFinalizer()
    {
        Console.WriteLine("hello from finalizer");
        //do nothing
    }
}

static void Main(string[] args)
{
    ClassWithFinalizer a = new ClassWithFinalizer();
    Console.WriteLine("Class a is on #{0} generation", GC.GetGeneration(a));
    GC.Collect();
    Console.WriteLine("Class a is on #{0} generation", GC.GetGeneration(a));
    GC.Collect();
    Console.WriteLine("Class a is on #{0} generation", GC.GetGeneration(a));

    a = null;

    Console.WriteLine("Collecting 0 Gen");
    GC.Collect(0);
    GC.WaitForPendingFinalizers();

    Console.WriteLine("Collecting 0 and 1 Gen");
    GC.Collect(1);
    GC.WaitForPendingFinalizers();

    Console.WriteLine("Collecting 0, 1 and 2 Gen");
    GC.Collect(2);
    GC.WaitForPendingFinalizers();

    Console.Read();
}

输出结果将是:
Class a is on #0 generation
Class a is on #1 generation
Class a is on #2 generation
Collecting 0 Gen
Collecting 0 and 1 Gen
Collecting 0, 1 and 2 Gen
hello from finalizer

正如您所看到的,只有在对包含对象的生成进行集合时,带有终结器的对象的内存才会被回收。


感谢您的回答,布鲁诺。关于部分/完整,我认为不存在“部分”集合这样的东西。完整/全面的GC等同于任何Gen2集合(来自MSDN:“第二代垃圾收集也称为完整垃圾收集,因为它回收所有代中的所有对象”)。因此,调用GC.Collect()与自动触发的Gen2集合之间应该没有区别。 关于可终结类型,确实需要两个GC才能清理(如果未被复活),但考虑到大约20-30个Gen2 GC在20-22小时内发生,它们应该会被回收。 - darkey
我无法理解为什么如此少的内存会如此频繁地触发Gen2。你确定有20-30个#2 Gen吗? - Bruno Costa
@darkey,部分集合是存在的,请访问http://msdn.microsoft.com/en-us/library/ms973837.aspx。 - Bruno Costa
我已经在我的帖子中编辑了Gen2集合图,时间大致相同(由于某种原因,缩放不再起作用,所以只需复制img url并将其粘贴到浏览器中以显示更大)。对于部分收集,我认为这指的是Gen0 / Gen1收集(不是由Gen2触发),它仅部分地回收空间(仅针对该代)。而完整的GC(Gen2)会收集所有Gen(包括0和1)。 - darkey

1

我有一些大对象,但 LOH(大对象堆)的内存占用非常小(4MB 基本保持不变)。此外,当我手动触发垃圾回收(如图所示)时,从第二代堆大小中释放的内存会变小。然而,使用自动 Gen2 收集(整个图表约有 80-90 次),每小时约有 20-30 次,我们可以看到 Gen2 堆大小下降仅在进行 Gen2 收集后的 20-24 小时才会发生,而不是每次进行 Gen2 收集之后。 - darkey

1
如果启用了gcTrimCommitOnLowMemory,这一切都可以很容易地解释清楚。通常情况下,GC会为进程保留一些额外的内存。但是,当内存达到一定阈值时,GC将“修剪”掉额外的内存。

从文档中得知:

启用gcTrimCommitOnLowMemory设置后,垃圾回收器评估系统内存负载,并在负载达到90%时进入修剪模式。 它将保持修剪模式,直到负载降至85%以下。

这很容易解释您的情况-内存储备被保留(并使用),直到您的应用程序达到某个点,似乎每20-24小时一次,在该点上检测到90%的负载,并将内存修剪到其最小要求(18mb)。


嗨,里德,谢谢你的回答。这确实是有道理的,但这是一个asp.net功能,我们不依赖它。该应用程序是作为Windows服务托管的纯控制台项目。然而,出于偏执的考虑,我已经检查了所有配置文件,并没有看到任何指定此配置参数的地方。 - darkey
@Darkey 感谢您的更新 - 我会考虑一下,如果我想到其他潜在的替代方案,明天会进行编辑。总的来说,35MB的使用量并不多。 - Reed Copsey
确实,35MB的使用量并不算多;)我并不是非常担心内存消耗本身,而是非常沮丧地不理解背后发生了什么,导致这种奇怪的内存回收模式。 顺便说一下,我们现在已经解决了这个问题,我在我的帖子中添加了一个编辑(在结尾处)。 - darkey

0

我想给出我的两分意见。虽然我不是专家,但也许这会帮助您的调查。

如果您正在使用64位平台,请尝试在.config文件中添加此内容。我读到过这可能是一个问题。

<configuration>
    <runtime>
        <gcAllowVeryLargeObjects enabled="true" />
    </runtime>
</configuration>

我要指出的另一件事是,如果您控制源代码,您可以通过从内部排除故障来证明假设。

调用类似于此的东西作为应用程序主要消耗内存的类,并将其设置为定时间隔运行,可以揭示实际发生的情况。

private void LogGCState() {
    int gen = GC.GetGeneration(this);

    //------------------------------------------
    // Comment out the GC.GetTotalMemory(true) line to see what's happening 
    // without any interference
    //------------------------------------------
    StringBuilder sb = new StringBuilder();
    sb.Append(DateTime.Now.ToString("G")).Append('\t');
    sb.Append("MaxGens: ").Append(GC.MaxGeneration).Append('\t');
    sb.Append("CurGen: ").Append(gen).Append('\t');
    sb.Append("CurGenCount: ").Append(GC.CollectionCount(gen)).Append('\t');
    sb.Append("TotalMemory: ").Append(GC.GetTotalMemory(false)).Append('\t');
    sb.Append("AfterCollect: ").Append(GC.GetTotalMemory(true)).Append("\r\n");

    File.AppendAllText(@"C:\GCLog.txt", sb.ToString());

}

此外,有一篇非常好的文章在这里介绍了如何使用GC.RegisterForFullGCNotification方法。显然,这将使您能够包括完整收集的时间跨度,以便您可以根据自己的需求调整性能和收集频率。该方法还允许您指定堆阈值来触发通知(或收集?)。

可能还有一种方法可以在应用程序的.config文件中设置它,但我没有查看。就大多数情况而言,35MB对于服务器应用程序来说是一个相当小的占用空间。我的网络浏览器有时会达到300-400MB :) 因此,框架可能只将35MB视为释放内存的良好默认点。

无论如何,通过您的问题的深思熟虑,我可以看出我可能只是在指出显而易见的事情。但是,这似乎值得一提。祝你好运!

有趣的一点

在本帖子的顶部,我最初写下了“if(您正在使用64位平台)”。那让我笑了起来。保重!


0

我在我的WPF应用程序中有完全相同的情况。我的代码中没有终结器。然而,似乎正在进行的GC实际上收集了Gen 2对象。我可以看到,在触发Gen2收集后,GC.GetTotalMemory()结果减少了多达150mb。

因此,我有印象Gen2堆大小不显示由活动对象使用的字节数。它更像是一个堆大小或为Gen2目的分配的字节数。你可能有很多空闲内存。

在某些条件下(不是每次Gen 2收集),这个堆大小会被修剪。在这个特定的时刻,我的应用程序会遇到巨大的性能问题 - 它可能会挂起几秒钟。想知道为什么...


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接