本文转载自微信公众号「DotNET技术圈」,作者michael。转载本文请联系DotNET技术圈公众号。
大型 .NET 应用程序中的内存问题是某种无声的杀手。有点像高血压。你可以长期吃垃圾食品而忽略它,直到有一天你面临严重的问题。对于 .NET 程序,该严重问题可能是高内存消耗、主要性能问题和彻底崩溃。在这篇文章中,您将看到如何将我们的应用程序的血压保持在健康水平。
你怎么知道你的内存使用情况是否健康?你需要做什么来保持它的健康?这正是本文要讨论的内容。我们将介绍 6 种最佳做法,以保持内存健康并在出现问题时检测问题。您还将看到优化垃圾收集并使您的应用程序非常快速的最佳实践。
1. 应尽快收集对象
为了使您的程序快速运行,主要目标是尽快收集对象。要理解为什么它很重要,您需要了解 .NET 的分代垃圾收集器。当使用该new子句创建对象时,它们是在第 0 代的堆上创建的。那是内存中非常小的空间。如果在有 Gen 0 集合时它们仍然被引用,它们将被提升到 Gen 1。Gen 1 是更大的内存空间。如果它们在有第 1 代集合时仍被引用,则将它们提升到第 2 代。
Gen 0 集合是最频繁的并且非常快。Gen 1 集合涵盖 Gen 0 内存空间和 Gen 1 内存空间,并且它们更昂贵。Gen 2 集合包括整个内存空间,包括大对象堆 (LOH)。它们非常昂贵。GC 已优化为具有许多 Gen 0 集合、较少的 Gen 1 集合和很少的 Gen 2 集合。但是,如果您有许多对象被提升到更高代,那么您将产生相反的效果。这会导致内存压力[1](又名 GC 压力)和性能不佳。
顺便说一下,新对象的分配非常便宜。您唯一需要担心的是集合。
那么如何在低代收集对象呢?很简单,只需确保不会尽快引用它们即可。有些对象,比如单例,必须永远在内存中。没关系,它们通常是不会消耗大量内存的服务。
2. 使用缓存……但要小心
根据定义,像缓存这样的机制很麻烦。这些是长期存在的临时对象,可能会升级到第 2 代。虽然这对 GC 压力不利,但通常值得付出代价,因为缓存确实可以帮助提高性能。但你必须密切关注它。
缓解部分内存压力的一种方法是使用可变缓存对象。这意味着不是替换缓存对象,而是更新现有对象。这意味着 GC 提升对象和启动更多 Gen 0 和 Gen 1 收集的工作更少。
这是一个例子。假设您正在缓存来自在线杂货店的库存商品。您有一个缓存机制来存储经常查询的项目的价格和数据。就像那些会导致高血压的冷冻比萨饼。假设每 5 分钟您必须使缓存无效并重新查询数据库,以防细节发生变化。因此,在这种情况下,不是创建新Pizza对象,而是更改现有对象的状态。
3. 留意 GC 中的时间百分比
如果您想知道垃圾收集对执行时间的影响有多大,这很容易做到。简单看看性能计数器.NET CLR Memory | % GC 时间。这将显示垃圾收集器使用了百分之多少的执行时间。有许多工具可以查看性能计数器。在 Windows 中,您可以使用 PerfMon。在 Linux 中,您可以使用dotnet-trace[2]。要了解更多信息,请查看我的文章Use Performance Counters in .NET to measure Memory, CPU, and Everything[3]。
我将给您一些神奇的数字,但请注意这些数字,因为一切都有其自身的背景。对于大型应用程序,10% 的 GC 时间可能是一个健康的百分比。GC 中 20% 的时间处于临界状态,任何更多都意味着您有问题。
4. 留意那些 Gen 2 Collections
除了 GC 中的时间百分比,您应该监控的另一个重要指标是 Gen 2 收集的数量。或者更确切地说是第 2 代收藏的速度。目标是尽可能少地使用它们。考虑到这些是完整的内存堆集合。当 GC 收集所有内容时,它们有效地冻结了应用程序的所有线程。
对于您应该拥有多少 Gen 2 系列,我无法给出一个神奇的数字。但我建议每隔一段时间积极监控这个数字,如果比率上升,那么你可能会添加一些非常糟糕的行为。您可以通过性能计数器.NET CLR Memory |查看该数字。% Gen 2 集合
PerfMon 显示第 2 代集合
5. 监控稳定的内存消耗
考虑应用程序的常规状态。有些事情一直在发生。它可能是一个服务请求的服务器,一个从队列中提取消息的服务,一个有很多屏幕的桌面应用程序。在此期间,您的应用程序不断创建新对象,执行一些操作,然后释放这些对象并返回到正常状态。这意味着从长远来看,内存消耗应该或多或少相同。当然,它可能会在高峰时间或繁重操作期间达到高水平,但一旦完成它应该会恢复正常。
但是,如果您监控了许多应用程序,您可能知道有时内存会随着时间的推移而增加。内存的平均消费缓慢上升到更高的水平,即使它在逻辑上不应该。这种行为的原因几乎总是内存泄漏[4]。这是一个对象不再使用的现象,但由于某种原因,它仍然被引用,因此从未被收集。
当一个操作导致对象泄漏时,每个这样的操作都会消耗更多的内存。随着时间的推移,记忆力上升。当足够的时间过去时,内存接近其极限。在 32 位进程中,该限制为 4GB。在 64 位进程中,它取决于机器约束。当我们如此接近极限时,垃圾收集器就会恐慌。它开始为每个其他分配触发全内存第 2 代收集,以免内存不足。这很容易使您的应用程序变慢。当更多的时间过去时,内存确实达到了它的极限,并且应用程序会因灾难性的OutOfMemoryException. 你有它 - 相当于心脏病发作。
为了确保您不会达到这种状态,我的建议是随着时间的推移主动监控内存消耗。最好的方法是查看性能计数器Process | Private Bytes。您可以使用Process explorer[5]或 PerfMon轻松完成。
6. 定期查找内存泄漏
内存问题的#1 罪魁祸首毫无疑问是内存泄漏。很容易造成它们,它们可以被长期忽视,最终会造成大量损害。在应用程序持续崩溃的阶段修复内存泄漏非常困难。您必须更改可能导致各种回归错误的旧代码。因此,我将为具有健康内存的应用程序添加第二个主要目标:修复并避免内存泄漏。
期望您的团队永远不会引入内存泄漏是不现实的。并且在每次新提交时检查整个应用程序中的内存泄漏是不切实际的。相反,我建议添加每隔一段时间检查内存泄漏的做法,它可能是每周、每月或每季度,具体取决于你的需求。
一种方法是在每次看到内存上升时检查内存泄漏(如提示 #5 中建议的那样)。但问题是内存占用低的泄漏也会导致很多问题。例如,您可能有一些本应收集但仍处于活动状态的对象,并且仍有代码在其中执行,这会导致不正确的行为。
检测和修复内存泄漏的最佳方法是使用内存分析器。在我的文章Demystifying Memory Profilers in C# .NET Part 2: Memory Leaks 中[6]了解如何做到这一点。
要了解哪种设计会导致内存泄漏,请查看我的文章.NET 中可能导致内存泄漏的 8 种方式[7]。
总结
所以你有它,一个健康记忆状态的秘诀。如果您遵循这些建议,您的应用程序将很快并且消耗很少的内存。但说真的,请吃健康的食物和锻炼??
References
[1] 内存压力: https://michaelscodingspot.com/avoid-gc-pressure/
[2] dotnet-trace: https://github.com/dotnet/diagnostics/blob/master/documentation/dotnet-trace-instructions.md
[3] Use Performance Counters in .NET to measure Memory, CPU, and Everything: https://michaelscodingspot.com/performance-counters/
[4] 内存泄漏: https://michaelscodingspot.com/ways-to-cause-memory-leaks-in-dotnet/
[5] Process explorer: https://docs.microsoft.com/en-us/sysinternals/downloads/process-explorer
[6] Demystifying Memory Profilers in C# .NET Part 2: Memory Leaks 中: https://michaelscodingspot.com/memory-profilers-for-memory-leaks/
[7] .NET 中可能导致内存泄漏的 8 种方式: https://michaelscodingspot.com/ways-to-cause-memory-leaks-in-dotnet/